Newer
Older
gnexus-ui-kit / scripts / generate-catalog.mjs
#!/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);