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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react"; import { CheckIcon, ClipboardIcon } from "lucide-react";
import { getSectionTitle, groupAndOrderChanges } from "../utils"; import {
import type { Change } from "../utils"; getSectionTitle,
import { cn } from "@/utils/ui"; groupAndOrderChanges,
import { Button } from "@/components/ui/button"; isSectionCollapsible,
} from "../utils";
function buildMarkdown({ import type { Change } from "../utils";
description, import { cn } from "@/utils/ui";
changes, import { Button } from "@/components/ui/button";
}: {
description?: string; function buildMarkdown({
changes: Change[]; description,
}): string { changes,
const lines: string[] = []; }: {
description?: string;
if (description) { changes: Change[];
lines.push(description, ""); }): string {
} const lines: string[] = [];
const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); if (description) {
lines.push(description, "");
for (const type of orderedTypes) { }
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) { const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
lines.push(`- ${change.text}`);
} for (const type of orderedTypes) {
lines.push(""); if (isSectionCollapsible({ type })) {
} lines.push(
"<details>",
return lines.join("\n").trimEnd(); `<summary>${getSectionTitle({ type })}</summary>`,
} "",
);
export function CopyMarkdownButton({ for (const change of grouped[type]) {
description, lines.push(`- ${change.text}`);
changes, }
}: { lines.push("", "</details>", "");
description?: string; continue;
changes: Change[]; }
}) {
const [copied, setCopied] = useState(false); lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
const handleCopy = async () => { lines.push(`- ${change.text}`);
const markdown = buildMarkdown({ description, changes }); }
await navigator.clipboard.writeText(markdown); lines.push("");
setCopied(true); }
setTimeout(() => setCopied(false), 2000);
}; return lines.join("\n").trimEnd();
}
return (
<Button export function CopyMarkdownButton({
size="sm" description,
variant="text" changes,
onClick={handleCopy} }: {
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")} description?: string;
title="Copy as markdown" changes: Change[];
> }) {
{copied ? ( const [copied, setCopied] = useState(false);
<CheckIcon className="size-4" />
) : ( const handleCopy = async () => {
<ClipboardIcon className="size-4" /> const markdown = buildMarkdown({ description, changes });
)} await navigator.clipboard.writeText(markdown);
{copied ? "Copied!" : "Copy markdown"} setCopied(true);
</Button> 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 type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { getSectionTitle, groupAndOrderChanges } from "../utils"; import {
import type { Release } from "../utils"; getSectionTitle,
groupAndOrderChanges,
export function ReleaseArticle({ isSectionCollapsible,
variant, type Change,
isLatest, type Release,
children, } from "../utils";
}: { import { ArrowRightIcon } from "@hugeicons/core-free-icons";
variant: "list" | "detail"; import { HugeiconsIcon } from "@hugeicons/react";
isLatest?: boolean;
children: ReactNode; export function ReleaseArticle({
}) { variant,
if (variant === "list") { isLatest,
return ( children,
<article className="relative sm:pl-10"> }: {
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block"> variant: "list" | "detail";
<div isLatest?: boolean;
className={cn( children: ReactNode;
"size-[11px] rounded-full border-[1.5px]", }) {
isLatest if (variant === "list") {
? "border-foreground bg-foreground" return (
: "border-muted-foreground/30 bg-background", <article className="relative sm:pl-10">
)} <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
/> <div
</div> className={cn(
<div className="flex flex-col gap-5">{children}</div> "size-[11px] rounded-full border-[1.5px]",
</article> isLatest
); ? "border-foreground bg-foreground"
} : "border-muted-foreground/30 bg-background",
)}
return <article className="flex flex-col gap-8">{children}</article>; />
} </div>
<div className="flex flex-col gap-5">{children}</div>
export function ReleaseMeta({ release }: { release: Release }) { </article>
return ( );
<span className="text-sm font-medium tracking-widest text-muted-foreground"> }
{release.version} {release.date}
</span> return <article className="flex flex-col gap-8">{children}</article>;
); }
}
export function ReleaseMeta({ release }: { release: Release }) {
const titleSizes: Record<"h1" | "h2", string> = { return (
h1: "text-4xl", <span className="text-sm font-medium tracking-widest text-muted-foreground">
h2: "text-2xl", {release.version} {release.date}
}; </span>
);
export function ReleaseTitle({ }
as: As,
href, const titleSizes: Record<"h1" | "h2", string> = {
children, h1: "text-4xl",
}: { h2: "text-2xl",
as: "h1" | "h2"; };
href?: string;
children: ReactNode; export function ReleaseTitle({
}) { as: As,
return ( href,
<As className={cn("font-bold tracking-tight", titleSizes[As])}> children,
{href ? ( }: {
<Link href={href} className="hover:underline underline-offset-4"> as: "h1" | "h2";
{children} href?: string;
</Link> children: ReactNode;
) : ( }) {
children return (
)} <As className={cn("font-bold tracking-tight", titleSizes[As])}>
</As> {href ? (
); <Link href={href} className="hover:underline underline-offset-4">
} {children}
</Link>
export function ReleaseDescription({ children }: { children: ReactNode }) { ) : (
return ( children
<p className="text-base text-foreground leading-relaxed max-w-xl"> )}
{children} </As>
</p> );
); }
}
export function ReleaseDescription({ children }: { children: ReactNode }) {
export function ReleaseChanges({ release }: { release: Release }) { return (
const { grouped, orderedTypes } = groupAndOrderChanges({ <p className="text-base text-foreground leading-relaxed max-w-xl">
changes: release.changes, {children}
}); </p>
);
return ( }
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => ( export function ReleaseChanges({ release }: { release: Release }) {
<div key={type} className="flex flex-col gap-1.5"> const { grouped, orderedTypes } = groupAndOrderChanges({
<h3 className="text-base font-semibold text-foreground"> changes: release.changes,
{getSectionTitle({ type })}: });
</h3>
<ul className="list-disc pl-5 space-y-1.5"> return (
{grouped[type].map((change) => ( <div className="flex flex-col gap-4">
<li {orderedTypes.map((type) => (
key={change.text} <ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
className="text-base text-foreground leading-relaxed" ))}
> </div>
{change.text} );
</li> }
))}
</ul> function ReleaseChangeSection({
</div> type,
))} changes,
</div> }: {
); 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"; import { allChangelogs } from "content-collections";
export type Change = { type: string; text: string }; export type Change = { type: string; text: string };
export type Release = (typeof allChangelogs)[number]; export type Release = (typeof allChangelogs)[number];
const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; type ChangeSectionConfig = {
title: string;
const knownSectionTitles: Record<string, string> = { order: number;
new: "Features", collapsible?: boolean;
improved: "Improvements", };
fixed: "Fixes",
breaking: "Breaking Changes", const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
}; new: { title: "Features", order: 0 },
improved: { title: "Improvements", order: 1 },
export function getSectionTitle({ type }: { type: string }): string { fixed: { title: "Fixes", order: 2 },
return ( breaking: { title: "Breaking Changes", order: 3 },
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) technical: {
); title: "Technical details",
} order: 4,
collapsible: true,
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); function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
return acc; return (
}, {}); knownSectionConfigs[type] ?? {
title: type.charAt(0).toUpperCase() + type.slice(1),
const customTypes = Object.keys(grouped).filter( order: Number.MAX_SAFE_INTEGER,
(type) => !knownSectionOrder.includes(type), }
); );
const orderedTypes = [ }
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
...customTypes, export function getSectionTitle({ type }: { type: string }): string {
]; return getSectionConfig({ type }).title;
}
return { grouped, orderedTypes };
} export function isSectionCollapsible({ type }: { type: string }): boolean {
return getSectionConfig({ type }).collapsible ?? false;
function isPublishedRelease({ published }: Release) { }
return published !== false;
} export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
const typeEncounterOrder = new Map<string, number>();
export function getSortedReleases() { const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
return allChangelogs if (!typeEncounterOrder.has(change.type)) {
.filter(isPublishedRelease) typeEncounterOrder.set(change.type, typeEncounterOrder.size);
.sort((a, b) => }
b.version.localeCompare(a.version, undefined, { numeric: true }), if (!acc[change.type]) acc[change.type] = [];
); acc[change.type].push(change);
} return acc;
}, {});
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version); 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);
}