#!/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); });