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