feat: add support for technical details in changelog entries

This commit is contained in:
Maze Winther 2026-04-14 23:55:20 +02:00
parent 91e1232112
commit 8fe66090e7
3 changed files with 322 additions and 227 deletions

View File

@ -1,68 +1,88 @@
"use client";
import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { getSectionTitle, groupAndOrderChanges } from "../utils";
import type { Change } from "../utils";
import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
function buildMarkdown({
description,
changes,
}: {
description?: string;
changes: Change[];
}): string {
const lines: string[] = [];
if (description) {
lines.push(description, "");
}
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
export function CopyMarkdownButton({
description,
changes,
}: {
description?: string;
changes: Change[];
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const markdown = buildMarkdown({ description, changes });
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Button
size="sm"
variant="text"
onClick={handleCopy}
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
title="Copy as markdown"
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<ClipboardIcon className="size-4" />
)}
{copied ? "Copied!" : "Copy markdown"}
</Button>
);
}
"use client";
import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import {
getSectionTitle,
groupAndOrderChanges,
isSectionCollapsible,
} from "../utils";
import type { Change } from "../utils";
import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
function buildMarkdown({
description,
changes,
}: {
description?: string;
changes: Change[];
}): string {
const lines: string[] = [];
if (description) {
lines.push(description, "");
}
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
if (isSectionCollapsible({ type })) {
lines.push(
"<details>",
`<summary>${getSectionTitle({ type })}</summary>`,
"",
);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
lines.push("", "</details>", "");
continue;
}
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
export function CopyMarkdownButton({
description,
changes,
}: {
description?: string;
changes: Change[];
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const markdown = buildMarkdown({ description, changes });
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Button
size="sm"
variant="text"
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5",
copied && "pointer-events-none",
)}
title="Copy as markdown"
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<ClipboardIcon className="size-4" />
)}
{copied ? "Copied!" : "Copy markdown"}
</Button>
);
}

View File

@ -1,106 +1,152 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/utils/ui";
import { getSectionTitle, groupAndOrderChanges } from "../utils";
import type { Release } from "../utils";
export function ReleaseArticle({
variant,
isLatest,
children,
}: {
variant: "list" | "detail";
isLatest?: boolean;
children: ReactNode;
}) {
if (variant === "list") {
return (
<article className="relative sm:pl-10">
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
<div
className={cn(
"size-[11px] rounded-full border-[1.5px]",
isLatest
? "border-foreground bg-foreground"
: "border-muted-foreground/30 bg-background",
)}
/>
</div>
<div className="flex flex-col gap-5">{children}</div>
</article>
);
}
return <article className="flex flex-col gap-8">{children}</article>;
}
export function ReleaseMeta({ release }: { release: Release }) {
return (
<span className="text-sm font-medium tracking-widest text-muted-foreground">
{release.version} {release.date}
</span>
);
}
const titleSizes: Record<"h1" | "h2", string> = {
h1: "text-4xl",
h2: "text-2xl",
};
export function ReleaseTitle({
as: As,
href,
children,
}: {
as: "h1" | "h2";
href?: string;
children: ReactNode;
}) {
return (
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
{href ? (
<Link href={href} className="hover:underline underline-offset-4">
{children}
</Link>
) : (
children
)}
</As>
);
}
export function ReleaseDescription({ children }: { children: ReactNode }) {
return (
<p className="text-base text-foreground leading-relaxed max-w-xl">
{children}
</p>
);
}
export function ReleaseChanges({ release }: { release: Release }) {
const { grouped, orderedTypes } = groupAndOrderChanges({
changes: release.changes,
});
return (
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle({ type })}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => (
<li
key={change.text}
className="text-base text-foreground leading-relaxed"
>
{change.text}
</li>
))}
</ul>
</div>
))}
</div>
);
}
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/utils/ui";
import {
getSectionTitle,
groupAndOrderChanges,
isSectionCollapsible,
type Change,
type Release,
} from "../utils";
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function ReleaseArticle({
variant,
isLatest,
children,
}: {
variant: "list" | "detail";
isLatest?: boolean;
children: ReactNode;
}) {
if (variant === "list") {
return (
<article className="relative sm:pl-10">
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
<div
className={cn(
"size-[11px] rounded-full border-[1.5px]",
isLatest
? "border-foreground bg-foreground"
: "border-muted-foreground/30 bg-background",
)}
/>
</div>
<div className="flex flex-col gap-5">{children}</div>
</article>
);
}
return <article className="flex flex-col gap-8">{children}</article>;
}
export function ReleaseMeta({ release }: { release: Release }) {
return (
<span className="text-sm font-medium tracking-widest text-muted-foreground">
{release.version} {release.date}
</span>
);
}
const titleSizes: Record<"h1" | "h2", string> = {
h1: "text-4xl",
h2: "text-2xl",
};
export function ReleaseTitle({
as: As,
href,
children,
}: {
as: "h1" | "h2";
href?: string;
children: ReactNode;
}) {
return (
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
{href ? (
<Link href={href} className="hover:underline underline-offset-4">
{children}
</Link>
) : (
children
)}
</As>
);
}
export function ReleaseDescription({ children }: { children: ReactNode }) {
return (
<p className="text-base text-foreground leading-relaxed max-w-xl">
{children}
</p>
);
}
export function ReleaseChanges({ release }: { release: Release }) {
const { grouped, orderedTypes } = groupAndOrderChanges({
changes: release.changes,
});
return (
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => (
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
))}
</div>
);
}
function ReleaseChangeSection({
type,
changes,
}: {
type: string;
changes: Change[];
}) {
const title = getSectionTitle({ type });
if (isSectionCollapsible({ type })) {
return (
<details className="group flex flex-col gap-1.5">
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden">
<span>{title}:</span>
<HugeiconsIcon
icon={ArrowRightIcon}
className="size-3 shrink-0 text-muted-foreground group-open:rotate-90"
/>
</summary>
<ReleaseChangeList changes={changes} />
</details>
);
}
return (
<div className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">{title}:</h3>
<ReleaseChangeList changes={changes} />
</div>
);
}
function ReleaseChangeList({
changes,
className,
}: {
changes: Change[];
className?: string;
}) {
return (
<ul className={cn("list-disc space-y-1.5 pl-5", className)}>
{changes.map((change) => (
<li
key={change.text}
className="text-base leading-relaxed text-foreground"
>
{change.text}
</li>
))}
</ul>
);
}

View File

@ -1,53 +1,82 @@
import { allChangelogs } from "content-collections";
export type Change = { type: string; text: string };
export type Release = (typeof allChangelogs)[number];
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
const knownSectionTitles: Record<string, string> = {
new: "Features",
improved: "Improvements",
fixed: "Fixes",
breaking: "Breaking Changes",
};
export function getSectionTitle({ type }: { type: string }): string {
return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
);
}
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
if (!acc[change.type]) acc[change.type] = [];
acc[change.type].push(change);
return acc;
}, {});
const customTypes = Object.keys(grouped).filter(
(type) => !knownSectionOrder.includes(type),
);
const orderedTypes = [
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
...customTypes,
];
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
import { allChangelogs } from "content-collections";
export type Change = { type: string; text: string };
export type Release = (typeof allChangelogs)[number];
type ChangeSectionConfig = {
title: string;
order: number;
collapsible?: boolean;
};
const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
new: { title: "Features", order: 0 },
improved: { title: "Improvements", order: 1 },
fixed: { title: "Fixes", order: 2 },
breaking: { title: "Breaking Changes", order: 3 },
technical: {
title: "Technical details",
order: 4,
collapsible: true,
},
};
function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
return (
knownSectionConfigs[type] ?? {
title: type.charAt(0).toUpperCase() + type.slice(1),
order: Number.MAX_SAFE_INTEGER,
}
);
}
export function getSectionTitle({ type }: { type: string }): string {
return getSectionConfig({ type }).title;
}
export function isSectionCollapsible({ type }: { type: string }): boolean {
return getSectionConfig({ type }).collapsible ?? false;
}
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
const typeEncounterOrder = new Map<string, number>();
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
if (!typeEncounterOrder.has(change.type)) {
typeEncounterOrder.set(change.type, typeEncounterOrder.size);
}
if (!acc[change.type]) acc[change.type] = [];
acc[change.type].push(change);
return acc;
}, {});
const orderedTypes = Object.keys(grouped).sort((left, right) => {
const orderDifference =
getSectionConfig({ type: left }).order -
getSectionConfig({ type: right }).order;
if (orderDifference !== 0) {
return orderDifference;
}
return (
(typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) -
(typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER)
);
});
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}