scripts/generate-docs.ts
Linked repository file from this build.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { runInNewContext } from "node:vm";
import {
compileArticle,
docHref,
type DocArticle,
type DocLink,
} from "../src/features/docs/compile";
const wiki = "docs/codebase";
/**
* Read the PAGES array from the wiki's own navigation script. The array is
* evaluated in an empty VM context, so a reformatted entry cannot be skipped silently.
*/
function readWikiPages(script: string) {
const literal = script.match(/const PAGES = (\[[\s\S]*?\n\]);/)?.[1];
if (!literal) throw new Error("docs.js has no PAGES array.");
const pages: unknown = runInNewContext(literal, {});
if (!Array.isArray(pages) || !pages.length) throw new Error("The wiki navigation is empty.");
return pages.map((page) => {
if (typeof page?.group !== "string" || typeof page?.href !== "string")
throw new Error(`Invalid wiki navigation entry: ${JSON.stringify(page)}`);
return { group: page.group as string, file: page.href as string };
});
}
const entries = readWikiPages(await readFile(`${wiki}/js/docs.js`, "utf8"));
const known = new Set(entries.map(({ file }) => file));
const articles: Record<string, DocArticle> = {};
const navigation: DocLink[] = [];
const sourcePaths = new Set<string>();
for (const { file, group } of entries) {
const compiled = compileArticle(file, await readFile(`${wiki}/${file}`, "utf8"), known);
const href = docHref(file);
articles[href] = compiled.article;
navigation.push({
group,
href,
title: compiled.article.title,
description: compiled.description,
topics: compiled.article.headings.map(({ title }) => title).join(" "),
});
for (const source of compiled.sources) sourcePaths.add(source);
}
const sources: Record<string, string> = {};
for (const source of [...sourcePaths].sort()) sources[source] = await readFile(source, "utf8");
// Verify local article fragments too: a typo must fail the build, not the reader's navigation.
for (const [current, article] of Object.entries(articles)) {
for (const [, link] of article.html.matchAll(/href="([^"]+)"/g)) {
if (!link!.startsWith("#") && !link!.startsWith("/docs")) continue;
const [href, hash] = link!.split("#");
if (href!.startsWith("/docs/source/")) continue;
const target = articles[href || current];
if (!target) throw new Error(`Missing article ${href}`);
if (hash && !target.html.includes(`id="${hash}"`))
throw new Error(`Missing section ${link} in ${current}`);
}
}
const out = "src/features/docs/generated";
await mkdir(out, { recursive: true });
for (const [name, data] of Object.entries({ articles, navigation, sources })) {
await writeFile(`${out}/${name}.json`, JSON.stringify(data));
}
console.log(
`Documentation: ${navigation.length} articles, ${sourcePaths.size} linked source files.`,
);