Initial Commit
This commit is contained in:
296
scripts/generate-packages-root-readme.ts
Normal file
296
scripts/generate-packages-root-readme.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user