#!/usr/bin/env bun /** * generate-subcategory-readmes — for every `packages///` * folder, write a `README.md` containing: * * 1. The contents of `packages///_intro.md` verbatim, * if present. * 2. An auto-generated table of packages in that subcategory, each row * linking to the package's own README and showing the first-paragraph * description of that README as the second column. * * Re-runs are idempotent. The auto section is enclosed in * ``…`` markers; the intro * file (when present) is dropped in above. * * Usage: * bun scripts/generate-subcategory-readmes.ts # rewrite * bun scripts/generate-subcategory-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 SubcategoryPackage { name: string; description: string; relPath: string; } 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 ): SubcategoryPackage | 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): SubcategoryPackage[] { const out: SubcategoryPackage[] = []; 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 renderAutoBlock( subcategoryName: string, packages: readonly SubcategoryPackage[] ): string { const rows = packages .map((p) => { const link = `[\`@suaveplan/${p.name}\`](./${p.relPath}/README.md)`; return `| ${link} | ${escapeCell(p.description || "—")} |`; }) .join("\n"); const lines = [ `## Packages in \`${subcategoryName}\``, "", `${packages.length} package${packages.length === 1 ? "" : "s"}.`, "", "| Package | Description |", "|---|---|", rows, ]; return lines.join("\n"); } function buildReadme( subcategoryName: string, intro: string | undefined, packages: readonly SubcategoryPackage[] ): string { const introBody = intro?.trim() ? intro.trim() : `Auto-generated index of the \`@suaveplan/*\` packages under this directory. Drop a hand-authored \`_intro.md\` here to replace this default header.`; const header = [`# \`${subcategoryName}\``, "", introBody].join("\n"); const auto = renderAutoBlock(subcategoryName, packages); 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); let subcategoryEntries: Dirent[]; try { subcategoryEntries = readdirSync(categoryDir, { withFileTypes: true }); } catch { continue; } for (const sub of subcategoryEntries) { if (!sub.isDirectory()) continue; if (sub.name.startsWith(".")) continue; const subcategoryDir = join(categoryDir, sub.name); const packages = collectSubcategoryPackages(subcategoryDir); if (packages.length === 0) { empty++; continue; } const introPath = join(subcategoryDir, "_intro.md"); const intro = tryRead(introPath); const next = buildReadme(basename(subcategoryDir), intro, packages); const readmePath = join(subcategoryDir, "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();