// The docs: every relative link reaches a file, and every `#anchor` a // heading in it, as GitHub and the site slug them; and every Mermaid block // draws, with Pele as the pages do. Pele comes from web/node_modules, so // `npm ci` in web/ first. // // node harness/docs.mjs // // Prints each problem as `file:line: what`, failing if any. import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const pele = join(root, 'web/node_modules/pele/package.json') if (!existsSync(pele)) { console.log('harness/docs.mjs: no web/node_modules/pele; run npm ci in web/ first') process.exit(1) } const { render } = await import( pathToFileURL(join(dirname(pele), JSON.parse(readFileSync(pele, 'utf8')).exports['.'].import)) ) const files = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(d, e.name)) : e.name.endsWith('.md') ? [join(d, e.name)] : [], ) const docs = ['README.md', 'AGENTS.md', 'deploy/README.md'] .map((f) => join(root, f)) .concat(files(join(root, 'docs'))) // A fence's lines, and the rest, with the line each starts on. const split = (text) => { const prose = [] const fences = [] let fence = null text.split('\n').forEach((line, i) => { if (line.startsWith('```')) { if (fence) (fences.push(fence), (fence = null)) else fence = { lang: line.slice(3).trim(), line: i + 1, body: [] } } else if (fence) fence.body.push(line) else prose.push([i + 1, line]) }) return { prose, fences } } const slugged = new Map() const slugs = (file) => { if (!slugged.has(file)) { const seen = new Map() const out = new Set() for (const [, line] of split(readFileSync(file, 'utf8')).prose) { const m = line.match(/^#{1,6}\s+(.*)/) if (!m) continue const slug = m[1] .trim() .toLowerCase() .replace(/[^\p{L}\p{N}_\- ]/gu, '') .replaceAll(' ', '-') const n = seen.get(slug) ?? 0 seen.set(slug, n + 1) out.add(n ? `${slug}-${n}` : slug) } slugged.set(file, out) } return slugged.get(file) } const problems = [] let diagrams = 0 for (const file of docs) { const name = relative(root, file) const { prose, fences } = split(readFileSync(file, 'utf8')) for (const [n, line] of prose) { for (const [, url] of line.replace(/`[^`]*`/g, '').matchAll(/\]\(([^)\s]+)\)/g)) { if (/^[a-z]+:/.test(url)) continue const [path, anchor] = url.split('#') const target = path ? resolve(dirname(file), path) : file if (!existsSync(target)) problems.push(`${name}:${n}: no ${path}`) else if (anchor && target.endsWith('.md') && !slugs(target).has(anchor)) problems.push(`${name}:${n}: no heading #${anchor} in ${relative(root, target)}`) } } for (const fence of fences.filter((f) => f.lang === 'mermaid')) { diagrams++ try { render(fence.body.join('\n')) } catch (e) { problems.push(`${name}:${fence.line}: diagram does not draw: ${e.message}`) } } } for (const p of problems) console.log(p) console.log(`${docs.length} pages, ${diagrams} diagrams, ${problems.length} problems`) process.exit(problems.length ? 1 : 0)