#!/usr/bin/env node
// Regenerates the Component Catalog table in CLAUDE.md from
// docs/catalog.json (the single source of truth) and cross-checks every
// listed prop/slot against the actual props and slot usages declared in
// src/vue/components/*.js.
//
// node scripts/generate-catalog.mjs regenerate + validate
// node scripts/generate-catalog.mjs --check fail if CLAUDE.md is stale
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = join(root, "docs/catalog.json");
const claudePath = join(root, "CLAUDE.md");
const START = "<!-- BEGIN GENERATED: component-catalog (source: docs/catalog.json, regenerate with npm run gen:catalog) -->";
const END = "<!-- END GENERATED: component-catalog -->";
const rows = JSON.parse(readFileSync(catalogPath, "utf8"));
const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// --- collect declared props and slot usages per component -----------------
const componentDir = join(root, "src/vue/components");
const declared = {};
for(const file of readdirSync(componentDir).filter(name => name.endsWith(".js"))) {
const source = readFileSync(join(componentDir, file), "utf8");
const name = file.replace(/\.js$/, "");
const props = new Set();
const propsBlock = source.match(/props:\s*\{([\s\S]*?)\n\t\}/);
if(propsBlock) {
for(const match of propsBlock[1].matchAll(/^\s*([a-zA-Z]+):/gm)) {
props.add(match[1]);
}
}
const slots = new Set(
[...source.matchAll(/slots\.([a-zA-Z]+)/g)].map(match => match[1])
);
declared[name] = { props, slots };
}
// --- validate the catalog rows against the declarations -------------------
let warnings = 0;
const warn = message => {
warnings++;
console.warn(`[catalog] ${message}`);
};
const checkToken = (component, token, kind) => {
const names = component.split("/").map(name => name.trim());
const known = names.some(name => {
const entry = declared[name];
if(!entry) {
warn(`component ${name} (row "${component}") has no source file`);
return true; // already reported, don't double-report the token
}
return kind === "slot" ? entry.slots.has(token) || entry.props.has(token) : entry.props.has(token) || entry.slots.has(token);
});
if(!known) {
warn(`"${component}" does not declare ${kind} "${token}"`);
}
};
for(const row of rows) {
if(row.propsNote) {
continue; // free-form note, nothing to validate
}
for(const item of row.props) {
// "items (with `to`)" -> prop "items"; "title (slot)" -> slot "title"
const slotSuffix = item.endsWith("(slot)");
const raw = item.replace(/\s*\(slot\)$/, "").replace(/`/g, "").trim();
// drop an explanatory parenthetical: "items (with to)" -> "items"
const head = raw.includes(" (") ? raw.slice(0, raw.indexOf(" (")) : raw;
let kind = slotSuffix ? "slot" : "prop";
let token = head;
if(token === "v-model") {
token = "modelValue";
} else if(token.startsWith("v-model:")) {
token = token.slice("v-model:".length);
}
// template-side kebab-case ("total-pages") = camelCase prop ("totalPages")
token = token.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
if(!/^[a-zA-Z][a-zA-Z0-9]*$/.test(token)) {
warn(`uncheckable entry "${item}" on "${row.component}"`);
continue;
}
checkToken(row.component, token, kind);
}
}
// --- render the table ------------------------------------------------------
const renderProps = row => {
if(row.propsNote) {
return row.propsNote;
}
if(!row.props.length) {
return "—";
}
return row.props
.map(item => {
// items carrying their own backticks are rendered verbatim
if(item.includes("`")) {
return item;
}
const slotSuffix = item.endsWith("(slot)");
const name = item.replace(/\s*\(slot\)$/, "").trim();
return `\`${name}\`${slotSuffix ? " (slot)" : ""}`;
})
.join(", ");
};
const renderComponent = name => name.replace(/([A-Za-z][A-Za-z0-9]*)/g, "`$1`");
const table = [
"| Need | Component | Props you will use |",
"|------|-----------|-------------------|",
...rows.map(row => `| ${row.need} | ${renderComponent(row.component)} | ${renderProps(row)} |`)
].join("\n");
const claude = readFileSync(claudePath, "utf8");
const pattern = new RegExp(`${escapeRegExp(START)}[\\s\\S]*${escapeRegExp(END)}`);
if(!pattern.test(claude)) {
console.error(`[catalog] markers not found in CLAUDE.md; wrap the table with:\n${START}\n...\n${END}`);
process.exit(1);
}
const updated = claude.replace(pattern, `${START}\n\n${table}\n\n${END}`);
if(process.argv.includes("--check")) {
if(updated !== claude) {
console.error("[catalog] CLAUDE.md is stale — run `npm run gen:catalog`");
process.exit(1);
}
console.log("[catalog] CLAUDE.md is up to date");
} else {
writeFileSync(claudePath, updated);
console.log(`[catalog] CLAUDE.md updated (${rows.length} rows)`);
}
if(warnings > 0) {
console.warn(`[catalog] ${warnings} validation warning(s)`);
}
process.exit(0);