#!/usr/bin/env bun /** * generate-packages-root-readme — writes `./packages/README.md` with a full * drill-down of every category, subcategory, and `@suaveplan/*` package. * * 1. The contents of `packages/_intro.md` verbatim, if present. * 2. An auto-generated hierarchy: * ## Categories * ### `category` * (category _intro.md) * #### `subcategory` * (subcategory _intro.md) * | Package | Description | * ... * * Re-runs are idempotent. The auto section is enclosed in * ``…`` markers. * * Usage: * bun scripts/generate-packages-root-readme.ts # rewrite * bun scripts/generate-packages-root-readme.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 RootPackage { name: string; description: string; relPath: string; } interface Subcategory { name: string; intro: string; packages: RootPackage[]; } interface Category { name: string; intro: string; subcategories: Subcategory[]; } 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 ): RootPackage | 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, categoryName: string, subcategoryName: string ): RootPackage[] { const out: RootPackage[] = []; 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, `${categoryName}/${subcategoryName}/${entry.name}` ); if (record !== null) out.push(record); } out.sort((a, b) => a.name.localeCompare(b.name)); return out; } function collectCategories(): Category[] { const categories: Category[] = []; let catEntries: Dirent[]; try { catEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true }); } catch { return categories; } for (const cat of catEntries) { if (!cat.isDirectory()) continue; if (cat.name.startsWith(".")) continue; const categoryDir = join(PACKAGES_ROOT, cat.name); const catIntroRaw = tryRead(join(categoryDir, "_intro.md")); const catIntro = catIntroRaw?.trim() ?? ""; const subcategories: Subcategory[] = []; let subEntries: Dirent[]; try { subEntries = readdirSync(categoryDir, { withFileTypes: true }); } catch { continue; } for (const sub of subEntries) { if (!sub.isDirectory()) continue; if (sub.name.startsWith(".")) continue; const subDir = join(categoryDir, sub.name); const packages = collectSubcategoryPackages( subDir, cat.name, sub.name ); if (packages.length === 0) continue; const subIntroRaw = tryRead(join(subDir, "_intro.md")); const subIntro = subIntroRaw?.trim() ?? ""; subcategories.push({ name: sub.name, intro: subIntro, packages, }); } if (subcategories.length === 0) continue; subcategories.sort((a, b) => a.name.localeCompare(b.name)); categories.push({ name: cat.name, intro: catIntro, subcategories }); } categories.sort((a, b) => a.name.localeCompare(b.name)); return categories; } function renderPackageTable(packages: readonly RootPackage[]): 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 stripLeadingH1(content: string): string { return content.replace(/^#\s+.*$/m, "").trim(); } function renderAutoBlock(categories: readonly Category[]): string { const totalPackages = categories.reduce( (sum, cat) => sum + cat.subcategories.reduce((s, sub) => s + sub.packages.length, 0), 0 ); const catSections = categories .map((cat) => { const lines: string[] = []; lines.push(`### \`${cat.name}\``); lines.push(""); if (cat.intro) { lines.push(stripLeadingH1(cat.intro)); lines.push(""); } for (const sub of cat.subcategories) { lines.push(`#### \`${sub.name}\``); lines.push(""); if (sub.intro) { lines.push(stripLeadingH1(sub.intro)); lines.push(""); } lines.push(renderPackageTable(sub.packages)); lines.push(""); } return lines.join("\n").trimEnd(); }) .join("\n\n"); return [ `## Categories`, "", `${categories.length} categor${categories.length === 1 ? "y" : "ies"}, ${totalPackages} package${totalPackages === 1 ? "" : "s"}.`, "", catSections, ].join("\n"); } function buildReadme( intro: string | undefined, categories: readonly Category[] ): string { const header = intro?.trim() ? intro.trim() : [ `# \`packages\``, "", `Auto-generated index of every \`@suaveplan/*\` package in the monorepo. Drop a hand-authored \`_intro.md\` in this directory to replace this default header.`, ].join("\n"); const auto = renderAutoBlock(categories); 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); } const categories = collectCategories(); if (categories.length === 0) { console.error("No categories found with packages."); process.exit(2); } const introPath = join(PACKAGES_ROOT, "_intro.md"); const intro = tryRead(introPath); const next = buildReadme(intro, categories); const readmePath = join(PACKAGES_ROOT, "README.md"); const current = tryRead(readmePath) ?? ""; if (current === next) { console.log(""); console.log(`⏭️ Unchanged: 1`); if (options.check) process.exit(0); return; } if (!options.check) writeFileSync(readmePath, next); console.log(""); console.log(`✏️ Updated: 1`); console.log(`⏭️ Unchanged: 0`); if (options.check) { console.error(""); console.error("Drift detected — run without --check to rewrite."); process.exit(1); } process.exit(0); } main();