Files
suaveplan/scripts/generate-category-readmes.ts
2026-05-27 23:47:53 +01:00

257 lines
7.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* generate-category-readmes — for every `packages/<category>/` folder,
* write a `README.md` containing:
*
* 1. The contents of `packages/<category>/_intro.md` verbatim, if present.
* 2. An auto-generated section grouped by subcategory. Each subcategory
* shows its `_intro.md` summary (if present) followed by a table of
* packages in that subcategory.
*
* Re-runs are idempotent. The auto section is enclosed in
* `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->` markers; the intro
* file (when present) is dropped in above.
*
* Usage:
* bun scripts/generate-category-readmes.ts # rewrite
* bun scripts/generate-category-readmes.ts --check # exit 1 on drift
*/
import { type Dirent, readdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
firstBodyParagraph,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface CategoryPackage {
name: string;
description: string;
relPath: string;
}
interface Subcategory {
name: string;
intro: string;
packages: CategoryPackage[];
}
function describePackageReadme(readmePath: string, fallback: string): string {
const content = tryRead(readmePath) ?? "";
if (!content) return fallback;
return firstBodyParagraph(content, 160) || fallback;
}
function escapeCell(s: string): string {
return s.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function readSuaveplanPackage(
pkgDir: string,
relPath: string
): CategoryPackage | null {
const pkgRaw = tryRead(join(pkgDir, "package.json"));
if (!pkgRaw) return null;
let pkg: { name?: string; description?: string };
try {
pkg = JSON.parse(pkgRaw) as { name?: string; description?: string };
} catch {
return null;
}
if (!pkg.name?.startsWith(PKG_PREFIX)) return null;
const shortName = pkg.name.slice(PKG_PREFIX.length);
const description = describePackageReadme(
join(pkgDir, "README.md"),
(pkg.description ?? "").trim()
);
return { name: shortName, description, relPath };
}
function collectSubcategoryPackages(subcategoryDir: string): CategoryPackage[] {
const out: CategoryPackage[] = [];
let entries: Dirent[];
try {
entries = readdirSync(subcategoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pkgDir = join(subcategoryDir, entry.name);
const record = readSuaveplanPackage(pkgDir, entry.name);
if (record !== null) out.push(record);
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function collectSubcategories(categoryDir: string): Subcategory[] {
const out: Subcategory[] = [];
let entries: Dirent[];
try {
entries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const subDir = join(categoryDir, entry.name);
const packages = collectSubcategoryPackages(subDir);
if (packages.length === 0) continue;
const introRaw = tryRead(join(subDir, "_intro.md"));
const intro = introRaw?.trim() ?? "";
out.push({ name: entry.name, intro, packages });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function renderPackageTable(packages: readonly CategoryPackage[]): string {
const rows = packages
.map((p) => {
const link = `[\`@suaveplan/${p.name}\`](./${p.relPath}/README.md)`;
return `| ${link} | ${escapeCell(p.description || "—")} |`;
})
.join("\n");
return [
"| Package | Description |",
"|---|---|",
rows,
].join("\n");
}
function renderAutoBlock(
subcategories: readonly Subcategory[]
): string {
const sections = subcategories
.map((sub) => {
const lines: string[] = [];
lines.push(`### \`${sub.name}\``);
lines.push("");
if (sub.intro) {
lines.push(sub.intro);
lines.push("");
}
lines.push(renderPackageTable(sub.packages));
return lines.join("\n");
})
.join("\n\n");
const totalPackages = subcategories.reduce(
(sum, sub) => sum + sub.packages.length,
0
);
const lines = [
`## Subcategories`,
"",
`${subcategories.length} subcategor${subcategories.length === 1 ? "y" : "ies"}, ${totalPackages} package${totalPackages === 1 ? "" : "s"}.`,
"",
sections,
];
return lines.join("\n");
}
function buildReadme(
categoryName: string,
intro: string | undefined,
subcategories: readonly Subcategory[]
): string {
const header = intro?.trim()
? intro.trim()
: [
`# \`${categoryName}\``,
"",
`Auto-generated index of the \`@suaveplan/*\` packages under \`packages/${categoryName}/\`. Drop a hand-authored \`_intro.md\` in this directory to replace this default header.`,
].join("\n");
const auto = renderAutoBlock(subcategories);
return `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): void {
let options: CliOptions;
try {
options = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
if (!isDirectorySafe(PACKAGES_ROOT)) {
console.error(`Packages root not found: ${PACKAGES_ROOT}`);
process.exit(2);
}
let categoryEntries: Dirent[];
try {
categoryEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
let updated = 0;
let unchanged = 0;
let empty = 0;
for (const cat of categoryEntries) {
if (!cat.isDirectory()) continue;
if (cat.name.startsWith(".")) continue;
const categoryDir = join(PACKAGES_ROOT, cat.name);
const subcategories = collectSubcategories(categoryDir);
if (subcategories.length === 0) {
empty++;
continue;
}
const introPath = join(categoryDir, "_intro.md");
const intro = tryRead(introPath);
const next = buildReadme(basename(categoryDir), intro, subcategories);
const readmePath = join(categoryDir, "README.md");
const current = tryRead(readmePath) ?? "";
if (current === next) {
unchanged++;
continue;
}
if (!options.check) writeFileSync(readmePath, next);
updated++;
}
console.log("");
console.log(`✏️ Updated: ${updated}`);
console.log(`⏭️ Unchanged: ${unchanged}`);
if (empty > 0) console.log(`⚠️ Empty: ${empty}`);
if (options.check && updated > 0) {
console.error("");
console.error("Drift detected — run without --check to rewrite.");
process.exit(1);
}
process.exit(0);
}
main();