#!/usr/bin/env node /** * README gate for every extension. * * node scripts/readme/check.ts verify * node scripts/readme/check.ts --write rewrite the generated config block * * Structural rules only; prose quality stays with the author and the * pi-ext-readme skill. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { ROOT } from "../lib/common.ts"; import { CONFIG_END, CONFIG_START, extensionDirs, extractSurface, injectSurface, renderSurface, } from "./config.ts"; interface Problem { extension: string; message: string; } function hasDeveloperDebugInfo(text: string): boolean { return ( /^#{1,6}\s+Debug(?:ging)?\b/imu.test(text) || /\]\([^)]*DEBUG\.md(?:#[^)]*)?\)/u.test(text) ); } export function checkExtension( name: string, write: boolean, root: string = ROOT, ): Problem[] { const problems: Problem[] = []; const dir = join(root, "extensions", name); const readmePath = join(dir, "README.md"); const fail = (message: string) => problems.push({ extension: name, message }); if (!existsSync(readmePath)) return [{ extension: name, message: "README.md missing" }]; let readme = readFileSync(readmePath, "utf8"); const lines = readme.split("\n"); if (hasDeveloperDebugInfo(readme)) fail( "debug documentation belongs in extensions/DEBUG.md, not a user README", ); // Banner first, then one blank line, then the H1. if ( !/^[^$/u.test( lines[0] ?? "", ) ) fail( 'line 1 must be the banner: <name>', ); if (lines[1] !== "" || !/^# \S/u.test(lines[2] ?? "")) fail("banner must be followed by one blank line and the H1"); if (!existsSync(join(dir, "assets", "banner.svg"))) fail("assets/banner.svg missing (run mise run //:banners)"); // Demo embed if and only if a scene exists. const hasScene = existsSync(join(dir, "demo", "scene.json")); const embedsDemo = /^!\[[^\n]*\]\(assets\/demo\.gif\)$/mu.test(readme); if (hasScene && !embedsDemo) fail("demo/scene.json exists but README does not embed assets/demo.gif"); if (!hasScene && embedsDemo) fail("README embeds assets/demo.gif without demo/scene.json"); if (hasScene && !existsSync(join(dir, "assets", "demo.gif"))) fail("assets/demo.gif missing (run mise run //:demo " + name + ")"); // Generated configuration block. const surface = extractSurface(dir); for (const diagnostic of surface.diagnostics) fail(diagnostic); const block = renderSurface(surface); const starts = readme.split(CONFIG_START).length - 1; const ends = readme.split(CONFIG_END).length - 1; const injected = injectSurface(readme, block); if (starts !== 1 || ends !== 1) { fail( `config block markers must occur exactly once; found ${starts} start and ${ends} end markers`, ); } else if (injected === undefined) { fail( `config block markers missing; add ${CONFIG_START} and ${CONFIG_END} where the reference belongs`, ); } else if (injected !== readme) { if (write) { writeFileSync(readmePath, injected); readme = injected; console.log(`updated ${relative(root, readmePath)} config block`); } else fail("config block out of date (run mise run //:readme-config)"); } // Changelog present and linked. const changelog = join(dir, "CHANGELOG.md"); if (existsSync(changelog)) { if ( !/^## (Unreleased|\d+\.\d+\.\d+)/mu.test(readFileSync(changelog, "utf8")) ) fail("CHANGELOG.md has no `## Unreleased` or version heading"); if (!/\]\(CHANGELOG\.md\)|\]\(\.\/CHANGELOG\.md\)/u.test(readme)) fail("README does not link CHANGELOG.md"); } // Relative links and images resolve. for (const match of readme.matchAll( /\]\(((?!https?:|mailto:|#)[^)\s]+)\)/gu, )) { const target = (match[1] ?? "").split("#")[0]; if (!target) continue; if (!existsSync(resolve(dirname(readmePath), target))) fail(`broken relative link: ${target}`); } return problems; } function main(argv: string[]): number { const write = argv.includes("--write"); const names = argv.filter((a) => !a.startsWith("--")); const available = extensionDirs(ROOT); const targets = names.length ? names : available; const unknown = targets.filter((name) => !available.includes(name)); if (unknown.length) { console.error(`unknown extension(s): ${unknown.sort().join(", ")}`); return 1; } const problems = targets.flatMap((name) => checkExtension(name, write)); const rootReadme = join(ROOT, "README.md"); if ( !names.length && existsSync(rootReadme) && hasDeveloperDebugInfo(readFileSync(rootReadme, "utf8")) ) problems.push({ extension: "(root)", message: "debug documentation belongs in extensions/DEBUG.md, not a user README", }); if (problems.length) { console.error(`README problems (${problems.length}):`); for (const p of problems) console.error(` ${p.extension}: ${p.message}`); return 1; } console.log(`OK readmes ${targets.length}`); return 0; } if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href ) process.exitCode = main(process.argv.slice(2));