Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/readme/check.ts

Raw
#!/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 (
		!/^<img src="assets\/banner\.svg" alt="[^"]+" height="70">$/u.test(
			lines[0] ?? "",
		)
	)
		fail(
			'line 1 must be the banner: <img src="assets/banner.svg" alt="<name>" height="70">',
		);
	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));