#!/usr/bin/env bun /** * generate-category-readmes — for every `packages//` folder, * write a `README.md` containing: * * 1. The contents of `packages//_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 * ``…`` 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();