clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/184-task-notice-migration
lofi / tools / node_docs.ts
node_docs.ts 5.7K
1/**2 * Assembles the `/node/docs` section into `website/node-docs-gen/` from a3 * lofi-node checkout, per `website/node-docs-manifest.json` (every `source`4 * is `lofi-node:`-prefixed). The docs live with the node so its CI gates5 * them; the site pins the checkout for reproducible builds.6 *7 * - Site-voice pages (`docs/site/*.md` there) render as-is: links between8 * manifest pages stay site-internal, links to anything else in the9 * checkout are rewritten to the pinned ref on GitHub.10 * - Contract pages (`"contract": true`, e.g. the app-ticket format) render11 * verbatim with a provenance header and every relative link rewritten to12 * GitHub — the file itself is the normative artifact.13 *14 * The checkout location comes from `LOFI_NODE_DIR` (default `../lofi-node`);15 * the ref used in provenance links comes from `LOFI_NODE_REF` (default: the16 * checkout's current commit). Run via `deno task site:node`.17 */1819const OUT_DIR = "website/node-docs-gen";2021type ManifestItem = { id: string; label: string; source: string; contract?: boolean };22type ManifestSection = { label: string | null; items: ManifestItem[] };23type Manifest = { checkoutRepo: string; sections: ManifestSection[] };2425const manifest = JSON.parse(26 await Deno.readTextFile("website/node-docs-manifest.json"),27) as Manifest;2829const checkoutDir = Deno.env.get("LOFI_NODE_DIR") ?? "../lofi-node";3031async function checkoutRef(): Promise<string> {32 const configured = Deno.env.get("LOFI_NODE_REF");33 if (configured) return configured;34 try {35 const result = await new Deno.Command("git", {36 args: ["-C", checkoutDir, "rev-parse", "HEAD"],37 stdout: "piped",38 stderr: "null",39 }).output();40 const ref = new TextDecoder().decode(result.stdout).trim();41 if (ref) return ref;42 } catch {43 // Fall through to the branch default below.44 }45 return "main";46}4748const ref = await checkoutRef();4950// Resolves a relative link target against its page's checkout directory,51// dropping any fragment; used to decide whether the target is a sibling52// manifest page (stays site-internal) or checkout material (goes to GitHub).53function resolveCheckoutPath(sourceDir: string, target: string): string {54 const withoutFragment = target.split("#")[0];55 const segments = `${sourceDir}/${withoutFragment}`.split("/");56 const resolved: string[] = [];57 for (const segment of segments) {58 if (segment === "" || segment === ".") continue;59 if (segment === "..") resolved.pop();60 else resolved.push(segment);61 }62 return resolved.join("/");63}6465/**66 * Rewrites a page's relative links to the pinned checkout on GitHub. For67 * site-voice pages, links whose target is another manifest page are left68 * alone — Docusaurus resolves them inside the generated section, keeping69 * navigation on-site.70 */71function rewriteCheckoutLinks(72 markdown: string,73 sourceDir: string,74 siteInternalTargets: ReadonlySet<string>,75): string {76 return markdown.replace(77 /\]\((?!https?:|\/|#)([^)]+)\)/g,78 (match: string, target: string) =>79 siteInternalTargets.has(resolveCheckoutPath(sourceDir, target))80 ? match81 : `](https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourceDir}/${target})`,82 );83}8485function provenanceHeader(sourcePath: string): string {86 const url = `https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourcePath}`;87 return `:::info[Contract page]\nRendered from [\`${sourcePath}\`](${url}) in the ` +88 `\`${manifest.checkoutRepo}\` repository at the site's pinned ref — that file is the ` +89 `source of truth, gated by lofi-node's own CI.\n:::\n\n`;90}9192// Docusaurus derives ids from file paths; the manifest is authoritative, so a93// page that exists on disk but not in the manifest (or vice versa) is drift.94const manifestItems = manifest.sections.flatMap((section) => section.items);9596await Deno.remove(OUT_DIR, { recursive: true }).catch(() => undefined);97await Deno.mkdir(OUT_DIR, { recursive: true });9899// Non-contract pages whose sibling links must survive as site navigation.100const siteInternalTargets = new Set(101 manifestItems102 .filter((item) => !item.contract)103 .map((item) => item.source.slice("lofi-node:".length)),104);105106let assembled = 0;107for (const item of manifestItems) {108 if (!item.source.startsWith("lofi-node:")) {109 console.error(`Manifest source ${item.source} (id "${item.id}") is not lofi-node:-prefixed.`);110 Deno.exit(1);111 }112 const relative = item.source.slice("lofi-node:".length);113 const path = `${checkoutDir}/${relative}`;114 let markdown: string;115 try {116 markdown = await Deno.readTextFile(path);117 } catch {118 console.error(119 `Missing lofi-node checkout file ${path}. Set LOFI_NODE_DIR to a checkout of ` +120 `${manifest.checkoutRepo} (default ../lofi-node).`,121 );122 Deno.exit(1);123 }124 const sourceDir = relative.split("/").slice(0, -1).join("/");125 markdown = item.contract126 ? provenanceHeader(relative) + rewriteCheckoutLinks(markdown, sourceDir, new Set())127 : rewriteCheckoutLinks(markdown, sourceDir, siteInternalTargets);128 await Deno.writeTextFile(`${OUT_DIR}/${item.id}.md`, markdown);129 assembled++;130}131132// Site-voice pages in the checkout but not in the manifest would silently133// miss the sidebar and the llms corpus — treat them as drift.134for await (const entry of Deno.readDir(`${checkoutDir}/docs/site`)) {135 if (!entry.isFile || !entry.name.endsWith(".md")) continue;136 const source = `lofi-node:docs/site/${entry.name}`;137 if (!manifestItems.some((item) => item.source === source)) {138 console.error(139 `checkout docs/site/${entry.name} is not listed in website/node-docs-manifest.json.`,140 );141 Deno.exit(1);142 }143}144145console.log(146 `Assembled ${assembled} /node/docs pages into ${OUT_DIR}/ (lofi-node checkout: ${checkoutDir} @ ${147 ref.slice(0, 12)148 }).`,149);