#!/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:
',
);
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));