mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
e086451615
commit
8c6e61c82a
10 files changed
+194
-25
No files matched your search
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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: "全部版本",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}.
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user