feat(PBI-58): codegen script for in-app manual TOC

scripts/build-manual.mjs walks docs/manual/, parses YAML front-matter,
strips it from the body, and emits lib/manual.generated.ts with a typed
ManualEntry[] containing slug, title, description, filePath, and the
embedded markdown body. Pure Node 20, mirrors generate-docs-index.mjs.

Inlining the markdown at build time keeps runtime serverless functions
free of filesystem reads, which avoids whole-project NFT tracing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-07 17:43:37 +02:00
parent 5025b78a81
commit 948f75d087
2 changed files with 1024 additions and 0 deletions

159
scripts/build-manual.mjs Normal file
View file

@ -0,0 +1,159 @@
#!/usr/bin/env node
// Generate lib/manual.generated.ts — a typed TOC of the docs/manual/ chapters.
// Walks docs/manual/, parses front-matter, extracts title and description, and
// emits a single TS file consumed by the in-app /manual route.
//
// Usage: `npm run manual:build` (also chained into `prebuild`).
//
// Pure Node 20 — no external deps. Mirrors scripts/generate-docs-index.mjs.
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join, relative, basename, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = join(SCRIPT_DIR, '..');
const MANUAL_DIR = join(REPO_ROOT, 'docs', 'manual');
const OUT_PATH = join(REPO_ROOT, 'lib', 'manual.generated.ts');
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
files.push(...(await walk(full)));
} else if (e.isFile() && e.name.endsWith('.md')) {
files.push(full);
}
}
return files;
}
function parseFrontMatter(content) {
if (!content.startsWith('---\n')) return { data: {}, body: content };
const end = content.indexOf('\n---\n', 4);
if (end === -1) return { data: {}, body: content };
const block = content.slice(4, end);
const data = {};
for (const raw of block.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const m = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*$/);
if (!m) continue;
let val = m[2];
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
data[m[1]] = val;
}
return { data, body: content.slice(end + 5) };
}
function extractFirstH1(body) {
const m = body.match(/^#\s+(.+?)\s*$/m);
return m ? m[1] : null;
}
function extractFirstParagraph(body) {
// Skip leading H1, then take the first non-heading, non-blank block.
const lines = body.split('\n');
let i = 0;
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++;
const para = [];
while (i < lines.length && lines[i].trim() !== '') {
if (lines[i].startsWith('>') || lines[i].startsWith('|') || lines[i].startsWith('```')) break;
para.push(lines[i]);
i++;
}
return para.join(' ').replace(/\s+/g, ' ').trim();
}
// docs/manual/01-overview.md → ['01-overview']
// docs/manual/index.md → []
function fileToSlug(rel) {
const stripped = rel.replace(/^docs\/manual\//, '').replace(/\.md$/, '');
if (stripped === 'index') return [];
return stripped.split('/');
}
function escapeTs(s) {
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function escapeBacktick(s) {
return String(s).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
}
function stripFrontMatter(content) {
if (!content.startsWith('---\n')) return content;
const end = content.indexOf('\n---\n', 4);
if (end === -1) return content;
return content.slice(end + 5).replace(/^\s*\n/, '');
}
async function main() {
const files = (await walk(MANUAL_DIR)).sort();
const entries = [];
for (const full of files) {
const rel = relative(REPO_ROOT, full).split(sep).join('/');
const content = await readFile(full, 'utf8');
const { data, body } = parseFrontMatter(content);
const slug = fileToSlug(rel);
const title = data.title || extractFirstH1(body) || basename(full, '.md');
const description = extractFirstParagraph(body) || '';
const markdown = stripFrontMatter(content);
entries.push({
slug,
title,
description,
filePath: rel,
markdown,
});
}
// Sort: index first, then by filename so numeric prefixes drive order.
entries.sort((a, b) => {
if (a.slug.length === 0) return -1;
if (b.slug.length === 0) return 1;
return a.filePath.localeCompare(b.filePath);
});
const lines = [];
lines.push('// AUTO-GENERATED by scripts/build-manual.mjs. Do not edit by hand.');
lines.push('// Run `npm run manual:build` to regenerate.');
lines.push('');
lines.push('export type ManualEntry = {');
lines.push(' slug: readonly string[]');
lines.push(' title: string');
lines.push(' description: string');
lines.push(' filePath: string');
lines.push(' markdown: string');
lines.push('}');
lines.push('');
lines.push('export const MANUAL_TOC: readonly ManualEntry[] = [');
for (const e of entries) {
const slugLit = '[' + e.slug.map((s) => `'${escapeTs(s)}'`).join(', ') + '] as const';
lines.push(' {');
lines.push(` slug: ${slugLit},`);
lines.push(` title: '${escapeTs(e.title)}',`);
lines.push(` description: '${escapeTs(e.description)}',`);
lines.push(` filePath: '${escapeTs(e.filePath)}',`);
lines.push(` markdown: \`${escapeBacktick(e.markdown)}\`,`);
lines.push(' },');
}
lines.push('] as const;');
lines.push('');
await writeFile(OUT_PATH, lines.join('\n'), 'utf8');
console.log(`Wrote ${relative(REPO_ROOT, OUT_PATH)} (${entries.length} chapters)`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});