feat(web): give every release its own release-notes page (CW-7497)

codewhale.net/<locale>/changelog/<version> shows every entry of one
released version, section by section with each section's entry count,
from the same CHANGELOG.md derivation as /changelog. The post-update
notice in the TUI and CLI links here, so the address has to keep working
after a release falls off the list page (which shows the newest six
releases and twelve entries per section): all 46 released versions are
derived into lib/release-notes.generated.ts, untracked like
changelog.generated.ts (~730 KB, imported only by the new route). Pages
render on demand and are cached (generateStaticParams returns []), the
same pattern as /ratatui/[component]. A v-prefixed version redirects to
the bare one; an unknown version is a 404. English and Chinese copy;
other locales fall back to English chrome and canonicalize to /en.

/changelog now links each released version's "Full notes" and its
"12 of 49 entries shown" cap to that page instead of the 800 KB
CHANGELOG.md on GitHub; Unreleased still links GitHub.

Not deployed.

Checks run on this source:
- npx tsc --noEmit: 0 errors
- npx eslint on the changed files: 0 problems
- npx vitest run lib/changelog.test.ts lib/i18n/dictionaries.test.ts:
  2 files, 26 passed, 0 failed
- npx next build: compiled; /[locale]/changelog/[version] listed as an
  on-demand static route
- next dev in the in-app browser: /en and /zh/changelog/0.10.2 render all
  seven sections with every entry; /en/changelog/v0.10.2 redirects,
  /en/changelog/9.9.9 is 404, /en/changelog/0.8.40 is 200; the zh page
  has no horizontal scroll at 375 px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-21 10:59:08 -07:00
1 parent e086451615
commit 8c6e61c82a
10 files changed
+194 -25

No files matched your search

+1
View File
@@ -18,4 +18,5 @@ tsconfig.tsbuildinfo
# base merged to a stale file and reddened main (2026-09-26). `npm run
# prebuild`, `npm run dev` and the vitest global setup write them.
lib/changelog.generated.ts
lib/release-notes.generated.ts
lib/install-guide.generated.ts
+4 -3
View File
@@ -6,9 +6,10 @@ site.
- Repository facts are generated by `npm run prebuild` into
`lib/facts.generated.ts`. Change their source and regenerate; never hand-edit
the generated file.
- `lib/changelog.generated.ts` and `lib/install-guide.generated.ts` are not
tracked. `npm run prebuild`, `npm run dev` and the vitest global setup derive
them from `CHANGELOG.md` and `docs/INSTALL.md`; never commit them.
- `lib/changelog.generated.ts`, `lib/release-notes.generated.ts` and
`lib/install-guide.generated.ts` are not tracked. `npm run prebuild`,
`npm run dev` and the vitest global setup derive them from `CHANGELOG.md`
and `docs/INSTALL.md`; never commit them.
- Shared public copy lives in `lib/content/`. Keep product vocabulary aligned
with `docs/public-surface-facts.json` and `docs/MODES.md`; extend locale data,
not individual pages.
@@ -0,0 +1,97 @@
import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation";
import { Icon } from "@/components/icon";
import { PageHeader } from "@/components/page-header";
import { RELEASE_NOTES } from "@/lib/release-notes.generated";
import { changelogAnchor } from "@/scripts/changelog-lib.mjs";
import { fill, getChangelog, getChrome } from "@/lib/i18n/dictionaries";
import { REPO_URL } from "@/lib/i18n/links";
import { buildPageMetadata } from "@/lib/page-meta";
type PageParams = Promise<{ locale: string; version: string }>;
export function generateStaticParams() {
return [];
}
function findRelease(version: string) {
return RELEASE_NOTES.find((release) => release.version === version);
}
export async function generateMetadata({ params }: { params: PageParams }) {
const { locale, version } = await params;
const release = findRelease(version);
if (!release) notFound();
const t = getChangelog(locale);
return buildPageMetadata({
path: `/changelog/${release.version}`,
locale,
title: fill(t.versionMetaTitle, { version: release.version }),
description: fill(t.versionMetaDescription, { version: release.version }),
});
}
export default async function ReleaseNotesPage({ params }: { params: PageParams }) {
const { locale, version } = await params;
const release = findRelease(version);
if (!release) {
const bare = version.replace(/^v/i, "");
if (bare !== version && findRelease(bare)) permanentRedirect(`/${locale}/changelog/${bare}`);
notFound();
}
const t = getChangelog(locale);
const chrome = getChrome(locale);
const date = release.date
? new Date(release.date).toLocaleDateString(chrome.dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
})
: null;
const notesUrl = `${REPO_URL}/blob/main/CHANGELOG.md#${changelogAnchor(release)}`;
return (
<>
<PageHeader
kicker={t.versionKicker}
title={`Codewhale ${release.version}`}
lede={date ? fill(t.versionLead, { date }) : t.versionLeadUndated}
pose="write"
actions={
<>
<Link href={`/${locale}/changelog`} className="btn btn-secondary">
{t.allReleasesLink}
</Link>
<a href={notesUrl} target="_blank" rel="noreferrer" className="section-link">
{t.fullNotes.replace(/\s*↗\s*$/u, "")}
<Icon name="external" className="icon" />
</a>
</>
}
/>
<div className="page-body">
<div className="page-body-narrow changelog-list">
<article id={`v${release.version}`} className="changelog-release">
<div className="changelog-sections">
{release.sections.map((section, i) => (
<section key={`${section.heading}-${i}`}>
<h3>
{section.heading}
<span className="tabular">{section.itemCount}</span>
</h3>
<ul>
{section.items.map((item, j) => (
<li key={j}>{item}</li>
))}
</ul>
</section>
))}
</div>
</article>
</div>
</div>
</>
);
}
+19 -8
View File
@@ -158,6 +158,8 @@ function Release({
// Every entry here is clipped or capped for the web; this is the in-page
// path to the unabridged notes for exactly this version.
const notesUrl = `${REPO_URL}/blob/main/CHANGELOG.md#${changelogAnchor(release)}`;
const versionUrl = release.unreleased ? null : `/${locale}/changelog/${release.version}`;
const notesLabel = fill(t.releaseNotesLink, { version: heading });
return (
<article id={anchor} className="changelog-release scroll-mt-32">
@@ -179,9 +181,13 @@ function Release({
{ext(t.compareLink)}
</a>
)}
<a href={notesUrl} target="_blank" rel="noreferrer">
{ext(fill(t.releaseNotesLink, { version: heading }))}
</a>
{versionUrl ? (
<Link href={versionUrl}>{notesLabel.replace(/\s*↗\s*$/u, "")}</Link>
) : (
<a href={notesUrl} target="_blank" rel="noreferrer">
{ext(notesLabel)}
</a>
)}
</div>
</div>
{(release.unreleased || candidate) && <p className="changelog-unreleased-note">{t.unreleasedNote}</p>}
@@ -190,11 +196,16 @@ function Release({
<section key={`${section.heading}-${i}`}>
<h3>
{section.heading}
{section.itemCount > section.items.length && (
<a href={notesUrl} target="_blank" rel="noreferrer">
{fill(t.moreEntries, { shown: section.items.length, total: section.itemCount })}
</a>
)}
{section.itemCount > section.items.length &&
(versionUrl ? (
<Link href={versionUrl}>
{fill(t.moreEntries, { shown: section.items.length, total: section.itemCount })}
</Link>
) : (
<a href={notesUrl} target="_blank" rel="noreferrer">
{fill(t.moreEntries, { shown: section.items.length, total: section.itemCount })}
</a>
))}
</h3>
<ul>
{section.items.map((item, j) => (
@@ -30,4 +30,11 @@ export const changelog: ChangelogDict = {
emptyTitle: "No release notes were derived",
emptyBody:
"The build did not find a parsable CHANGELOG.md. The GitHub release list is still the record.",
versionMetaTitle: "Codewhale {version} release notes",
versionMetaDescription:
"Every change in Codewhale {version}, section by section, drawn from CHANGELOG.md in the repository.",
versionKicker: "Release notes",
versionLead: "Released {date}. Every entry for this version, drawn from the repository's CHANGELOG.md.",
versionLeadUndated: "Every entry for this version, drawn from the repository's CHANGELOG.md.",
allReleasesLink: "All releases",
};
+9 -1
View File
@@ -440,10 +440,18 @@ export interface ChangelogDict {
/** "{shown} of {total} entries" */
moreEntries: string;
fullNotes: string;
/** "Full notes for {version}" — per-release deep link into CHANGELOG.md. */
/** "Full notes for {version}" — links /changelog/<version>, or CHANGELOG.md for Unreleased. */
releaseNotesLink: string;
emptyTitle: string;
emptyBody: string;
/** `/changelog/[version]` — every entry for one release. */
versionMetaTitle: string;
versionMetaDescription: string;
versionKicker: string;
/** "Released {date}. …" */
versionLead: string;
versionLeadUndated: string;
allReleasesLink: string;
}
export interface LegalTermsDict {
@@ -27,4 +27,10 @@ export const changelog: ChangelogDict = {
releaseNotesLink: "{version} 完整说明 ↗",
emptyTitle: "没有解析出发布说明",
emptyBody: "构建时没有找到可解析的 CHANGELOG.md。GitHub 的发布列表仍然是权威记录。",
versionMetaTitle: "Codewhale {version} 发布说明",
versionMetaDescription: "Codewhale {version} 的全部改动,按章节列出,取自仓库中的 CHANGELOG.md。",
versionKicker: "发布说明",
versionLead: "发布于 {date}。以下是该版本的全部条目,取自仓库中的 CHANGELOG.md,条目原文为英文。",
versionLeadUndated: "以下是该版本的全部条目,取自仓库中的 CHANGELOG.md,条目原文为英文。",
allReleasesLink: "全部版本",
};
+5
View File
@@ -35,3 +35,8 @@ export function changelogAnchor(release: {
unreleased: boolean;
}): string;
export function renderChangelogModule(parsed: ParsedChangelog, sourcePath?: string): string;
export function parseReleaseNotes(markdown: string): ParsedChangelogRelease[];
export function renderReleaseNotesModule(
releases: ParsedChangelogRelease[],
sourcePath?: string,
): string;
+19
View File
@@ -143,6 +143,25 @@ export function changelogAnchor(release) {
.replace(/ /g, "-");
}
export function parseReleaseNotes(markdown) {
return parseChangelog(markdown, {
limit: Infinity,
itemsPerSection: Infinity,
itemChars: Infinity,
}).releases.filter((release) => !release.unreleased);
}
export function renderReleaseNotesModule(releases, sourcePath = "CHANGELOG.md") {
return `// AUTO-GENERATED by web/scripts/derive-changelog.mjs at prebuild from ${sourcePath}.
// DO NOT EDIT — re-run \`npm run prebuild\` (or just \`npm run build\`) after changing the changelog.
// Not tracked in git: every released version with every entry, for /changelog/<version>.
import type { ChangelogRelease } from "./changelog.generated";
export const RELEASE_NOTES: ChangelogRelease[] = ${JSON.stringify(releases)};
`;
}
/** Render the generated TypeScript module from a parse result. */
export function renderChangelogModule(parsed, sourcePath = "CHANGELOG.md") {
return `// AUTO-GENERATED by web/scripts/derive-changelog.mjs at prebuild from ${sourcePath}.
+27 -13
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
/**
* derive-changelog.mjs — emit `web/lib/changelog.generated.ts` from the
* repository CHANGELOG.md so the /changelog route and the docs release-truth
* band render the real release record, never a hand-typed copy.
* derive-changelog.mjs — emit `web/lib/changelog.generated.ts` and
* `web/lib/release-notes.generated.ts` from the repository CHANGELOG.md so
* /changelog, /changelog/<version> and the docs release-truth band render the
* real release record, never a hand-typed copy.
*
* Runs at `npm run prebuild`, `npm run dev` and in the vitest global setup.
* The output is NOT tracked (web/.gitignore): a committed copy raced between
@@ -11,26 +12,39 @@
* went red (twice on 2026-09-26). Deriving at build time removes the race.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { basename, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseChangelog, renderChangelogModule } from "./changelog-lib.mjs";
import {
parseChangelog,
parseReleaseNotes,
renderChangelogModule,
renderReleaseNotesModule,
} from "./changelog-lib.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..", "..");
const SOURCE = resolve(REPO_ROOT, "CHANGELOG.md");
const TARGET = resolve(__dirname, "..", "lib", "changelog.generated.ts");
const NOTES_TARGET = resolve(__dirname, "..", "lib", "release-notes.generated.ts");
if (!existsSync(SOURCE)) {
console.error(`[derive-changelog] CHANGELOG.md not found at ${SOURCE}`);
process.exit(1);
}
const parsed = parseChangelog(readFileSync(SOURCE, "utf-8"));
const next = renderChangelogModule(parsed);
const current = existsSync(TARGET) ? readFileSync(TARGET, "utf-8") : null;
if (current !== next) {
writeFileSync(TARGET, next);
console.log(`[derive-changelog] wrote ${parsed.releases.length} releases → lib/changelog.generated.ts`);
} else {
console.log(`[derive-changelog] lib/changelog.generated.ts is current (${parsed.releases.length} releases)`);
const markdown = readFileSync(SOURCE, "utf-8");
const parsed = parseChangelog(markdown);
const notes = parseReleaseNotes(markdown);
for (const [target, next, count] of [
[TARGET, renderChangelogModule(parsed), parsed.releases.length],
[NOTES_TARGET, renderReleaseNotesModule(notes), notes.length],
]) {
const name = `lib/${basename(target)}`;
const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
if (current !== next) {
writeFileSync(target, next);
console.log(`[derive-changelog] wrote ${count} releases → ${name}`);
} else {
console.log(`[derive-changelog] ${name} is current (${count} releases)`);
}
}