Skip to content

Commit 6cd4a0c

Browse files
committed
Split the changelog into one page per release
reference/changelog.md becomes a release index at its original URL, with each release at reference/changelog/vX.Y.Z.html: title from the release heading, subsections promoted one level, a 'Released <date> · View on GitHub' line, and the leftover MyST anchor targets ((v1-31-1)=) dropped. Fumadocs resolves an extensionless meta.json item to a folder before a page, so the sidebar entry now references reference/changelog.md explicitly — a single Changelog link, with the 37 release pages routable but out of the sidebar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Thc5nWNbxfQ67pVDcNSkxC
1 parent 5dc3019 commit 6cd4a0c

3 files changed

Lines changed: 89 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ The ingest step (`scripts/ingest.mjs`) writes `content/docs/`: it derives
5959
each page's frontmatter `title` from its `#` heading and strips it, copies
6060
the body verbatim, and converts `toc.yaml` into a Fumadocs `meta.json`
6161
(sections become sidebar separators; `unlisted` pages get routes but stay out
62-
of the sidebar). Anything unexpected — an unknown `toc.yaml` field, a page
62+
of the sidebar). The one page-level transform: `reference/changelog.md` is
63+
split into one page per release (`/en/latest/reference/changelog/v1.31.1.html`)
64+
plus a release index at the original URL. Anything unexpected — an unknown `toc.yaml` field, a page
6365
without a title — fails the build loudly: that's the contract-drift alarm.
6466
`content/` is generated output; never commit or hand-edit it.
6567

app/(docs)/[[...slug]]/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
2929

3030
const MDX = page.data.body;
3131
const markdownUrl = getPageMarkdownUrl(page).url;
32+
// Per-release changelog pages are split out of reference/changelog.md by
33+
// the ingest step; their upstream source is that one file.
34+
const sourcePath = page.path.startsWith('reference/changelog/')
35+
? 'reference/changelog.md'
36+
: page.path;
3237

3338
return (
3439
<DocsPage toc={page.data.toc} full={page.data.full}>
@@ -38,7 +43,7 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
3843
<MarkdownCopyButton markdownUrl={markdownUrl} />
3944
<ViewOptionsPopover
4045
markdownUrl={markdownUrl}
41-
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/docs/${page.path}`}
46+
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/docs/${sourcePath}`}
4247
/>
4348
</div>
4449
<DocsBody>

scripts/ingest.mjs

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,65 @@ function extractTitle(rel, src) {
115115
return { title, body: src.slice(match[0].length).replace(/^\r?\n/, '') };
116116
}
117117

118+
// The changelog is one long page upstream; render it as one URL per release
119+
// (reference/changelog/v1.31.1.html) with reference/changelog.html as the
120+
// release index. Sections look like:
121+
//
122+
// ## [1.31.1](https://github.com/sqlc-dev/sqlc/releases/tag/v1.31.1)
123+
// Released 2026-04-22
124+
// ### Bug Fixes
125+
// - ...
126+
//
127+
// Anything else is contract drift and fails the build.
128+
function splitChangelog(rel, body) {
129+
// Leftover MyST anchor targets ("(v1-31-1)=") from the old toolchain
130+
// render as literal text; drop them.
131+
const lines = body.split('\n').filter((l) => !/^\([a-z0-9._-]+\)=\s*$/i.test(l));
132+
133+
const sections = [];
134+
let intro = [];
135+
let current = null;
136+
for (const line of lines) {
137+
if (line.startsWith('## ')) {
138+
const m = /^## \[v?([^\]]+)\]\((https:\/\/github\.com\/[^)]+)\)\s*$/.exec(line);
139+
if (!m) fail(`${rel}: unrecognized release heading: ${line}`);
140+
current = { version: `v${m[1]}`, url: m[2], date: null, body: [] };
141+
sections.push(current);
142+
} else if (current === null) {
143+
intro.push(line);
144+
} else {
145+
const released = /^Released (\d{4}-\d{2}-\d{2})\s*$/.exec(line);
146+
if (released && current.date === null) {
147+
current.date = released[1];
148+
current.body.push(`Released ${released[1]} · [View on GitHub](${current.url})`);
149+
} else {
150+
// Promote subsections: the release heading became the page title.
151+
current.body.push(line.startsWith('### ') ? line.slice(1) : line);
152+
}
153+
}
154+
}
155+
if (sections.length === 0) fail(`${rel}: no release sections found`);
156+
157+
const pages = [];
158+
for (const s of sections) {
159+
if (s.date === null) fail(`${rel}: release ${s.version} has no "Released YYYY-MM-DD" line`);
160+
pages.push({
161+
rel: `reference/changelog/${s.version}.md`,
162+
title: s.version,
163+
body: s.body.join('\n').trim() + '\n',
164+
});
165+
}
166+
167+
const index = [
168+
...intro.join('\n').trim().split('\n'),
169+
'',
170+
...sections.map((s) => `- [${s.version}](changelog/${s.version}.md) — released ${s.date}`),
171+
'',
172+
];
173+
pages.push({ rel, title: 'Changelog', body: index.join('\n') });
174+
return pages;
175+
}
176+
118177
function main() {
119178
const args = parseArgs(process.argv.slice(2));
120179
const srcDir = args.src ? path.resolve(args.src) : cloneDocs(args.ref);
@@ -142,26 +201,42 @@ function main() {
142201
fs.rmSync(OUT_DIR, { recursive: true, force: true });
143202
fs.mkdirSync(OUT_DIR, { recursive: true });
144203

145-
for (const rel of files) {
146-
const src = fs.readFileSync(path.join(srcDir, rel.split('/').join(path.sep)), 'utf8');
147-
const { title, body } = extractTitle(rel, src);
204+
let written = 0;
205+
const writePage = (rel, title, body) => {
148206
const dest = path.join(OUT_DIR, rel.split('/').join(path.sep));
149207
fs.mkdirSync(path.dirname(dest), { recursive: true });
150208
// JSON string literals are valid YAML, so this quoting is always safe.
151209
fs.writeFileSync(dest, `---\ntitle: ${JSON.stringify(title)}\n---\n\n${body}`);
210+
written++;
211+
};
212+
213+
for (const rel of files) {
214+
const src = fs.readFileSync(path.join(srcDir, rel.split('/').join(path.sep)), 'utf8');
215+
const { title, body } = extractTitle(rel, src);
216+
if (rel === 'reference/changelog.md') {
217+
for (const page of splitChangelog(rel, body)) writePage(page.rel, page.title, page.body);
218+
} else {
219+
writePage(rel, title, body);
220+
}
152221
}
153222

154223
// toc.yaml sections map 1:1 to sidebar separators; unlisted pages get
155224
// routes but stay out of the sidebar by not appearing in `pages`.
156-
const routeOf = (page) => page.replace(/\.md$/, '');
225+
// Fumadocs resolves an extensionless meta.json item to a folder first, so
226+
// a page shadowed by a same-named folder (reference/changelog next to the
227+
// split-out reference/changelog/ releases) needs its explicit .md path.
228+
const routeOf = (page) => {
229+
const route = page.replace(/\.md$/, '');
230+
return fs.existsSync(path.join(OUT_DIR, route.split('/').join(path.sep))) ? page : route;
231+
};
157232
const pages = [routeOf(toc.index)];
158233
for (const section of toc.sections) {
159234
pages.push(`---${section.title}---`);
160235
pages.push(...section.pages.map(routeOf));
161236
}
162237
fs.writeFileSync(path.join(OUT_DIR, 'meta.json'), JSON.stringify({ pages }, null, 2) + '\n');
163238

164-
console.log(`ingest: wrote ${files.length} pages (${toc.unlisted.length} unlisted) from ${srcDir}`);
239+
console.log(`ingest: wrote ${written} pages (${toc.unlisted.length} unlisted) from ${srcDir}`);
165240
}
166241

167242
main();

0 commit comments

Comments
 (0)