Files
suaveplan/scripts/generate-packages-root-readme.ts
2026-05-27 23:47:53 +01:00

297 lines
8.7 KiB
TypeScript

#!/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
* `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->` 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();