Files
site-builder/craft/scripts/gen-sitesmith-catalog.mjs
T
shadowdaoandClaude Sonnet 5.5 01a78c9b3d catalog: omit the wall-clock-derived Countdown.targetDate default so the generated catalog (and drift test) no longer flip daily
Add a date-independence test (freezes Date at two different days via a preload).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 16:28:48 -08:00

234 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* Sitesmith catalog generator.
*
* Reads the editor's REAL definitions (component resolver + craft defaults, TS
* prop types, section families, templates, palettes, design tokens, Font
* Awesome helper, animation keyframes) and emits ONE compact, deterministic
* JSON file the WHP panel's Sitesmith prompt builder loads.
*
* node scripts/gen-sitesmith-catalog.mjs --out ../../whp/web-files/libs/sitesmith-catalog.generated.json
* node scripts/gen-sitesmith-catalog.mjs --stdout # print (used by the drift test)
*
* The drift test (src/sitesmith-catalog.drift.test.ts) regenerates and compares
* to the committed file (path: $SITESMITH_CATALOG_FILE, default
* ../../whp/web-files/libs/sitesmith-catalog.generated.json relative to craft/),
* so a component/prop/section/template change that is not regenerated fails.
*
* Curated, human-written facts (one-line purpose, usage tips) live in
* scripts/sitesmith-catalog/notes.mjs; a component with no note fails the build.
*/
import { build } from 'esbuild';
import ts from 'typescript';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { NOTES, GLOBAL_NOTES } from './sitesmith-catalog/notes.mjs';
const craftRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const rel = (...p) => path.join(craftRoot, ...p);
/** Props every component shares; reported once in `common`, not per component. */
const COMMON_PROPS = new Set(['style', 'animation', 'animationDelay', 'hideOnDesktop', 'hideOnTablet', 'hideOnMobile', 'aiName', 'node_id']);
async function loadRuntime() {
const outfile = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'sscat-')), 'runtime.cjs');
const res = await build({
entryPoints: [rel('scripts/sitesmith-catalog/runtime-entry.ts')],
bundle: true, write: false, format: 'cjs', platform: 'node', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"' },
});
fs.writeFileSync(outfile, res.outputFiles[0].text);
const data = createRequire(import.meta.url)(outfile).collect();
fs.rmSync(path.dirname(outfile), { recursive: true, force: true });
return data;
}
function componentFiles() {
const resolver = fs.readFileSync(rel('src/components/resolver.ts'), 'utf8');
const map = {};
for (const m of resolver.matchAll(/import \{ (\w+) \} from '\.\/([\w/]+)';/g)) map[m[1]] = rel('src/components', m[2] + '.tsx');
return map;
}
function typeInfo(checker, sym, decl) {
const t = checker.getTypeOfSymbolAtLocation(sym, decl);
const nn = checker.getNonNullableType(t);
let text = checker.typeToString(nn, undefined, ts.TypeFormatFlags.NoTruncation);
const parts = nn.isUnion() ? nn.types : [nn];
const lits = parts.filter((p) => p.isStringLiteral()).map((p) => p.value);
const out = {};
if (lits.length && lits.length === parts.length) { out.type = 'enum'; out.enum = lits.sort(); return out; }
if (lits.length && parts.every((p) => p.isStringLiteral() || (p.flags & ts.TypeFlags.String))) { out.type = 'string'; out.suggested = lits.sort(); return out; }
if (nn.flags & ts.TypeFlags.String) out.type = 'string';
else if (nn.flags & ts.TypeFlags.Number) out.type = 'number';
else if (nn.flags & ts.TypeFlags.BooleanLike || text === 'boolean') out.type = 'boolean';
else if (checker.isArrayType(nn)) {
out.type = 'array';
const el = checker.getTypeArguments(nn)[0];
const props = el ? checker.getPropertiesOfType(el) : [];
if (props.length) {
out.item = {};
for (const p of props) {
const pt = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(p, decl));
const pu = pt.isUnion() ? pt.types : [pt];
const pl = pu.filter((x) => x.isStringLiteral()).map((x) => x.value);
let k = checker.typeToString(pt, undefined, ts.TypeFormatFlags.NoTruncation);
if (pl.length && pl.length === pu.length) k = pl.sort().join('|');
if (p.flags & ts.SymbolFlags.Optional) k += '?';
out.item[p.name] = k;
}
} else out.item = checker.typeToString(el, undefined, ts.TypeFormatFlags.NoTruncation);
} else if (nn.flags & ts.TypeFlags.Object) out.type = 'object';
else out.type = text.length > 60 ? 'any' : text;
return out;
}
function extractProps(program, file, name) {
const checker = program.getTypeChecker();
const sf = program.getSourceFile(file);
if (!sf) throw new Error(`catalog: cannot read ${file}`);
let propsType = null; let decl0 = null;
sf.forEachChild((n) => {
if (!ts.isVariableStatement(n)) return;
for (const d of n.declarationList.declarations) {
if (d.name.getText() !== name || !d.type || !ts.isTypeReferenceNode(d.type) || !d.type.typeArguments?.length) continue;
propsType = checker.getTypeFromTypeNode(d.type.typeArguments[0]); decl0 = d;
}
});
if (!propsType) throw new Error(`catalog: ${name}: expected "export const ${name}: UserComponent<Props>" in ${file}`);
const out = {};
for (const sym of checker.getPropertiesOfType(propsType)) {
const optional = !!(sym.flags & ts.SymbolFlags.Optional);
out[sym.name] = { ...typeInfo(checker, sym, decl0), optional };
}
return out;
}
/**
* Defaults computed from the wall clock when the editor module loads (Countdown.targetDate
* = today + 30 days). Emitting them would make the generated catalog (and the drift test)
* change every day and would teach the model a stale "default" date, so they are omitted
* from the catalog entirely.
*/
const VOLATILE_DEFAULTS = new Set(['Countdown.targetDate']);
const COLOR_RE = /^(color|bg|bgValue|gradient)$|(colou?r|bg|background|gradientfrom|gradientto)$/i;
const isTokenRef = (v) => typeof v === 'string' && /^var\(--wsb-[a-z-]+\)$/.test(v);
/** Trim a default so the file stays compact: long strings clipped, arrays cut to 1 item. */
function slimDefault(v) {
if (Array.isArray(v)) return v.slice(0, 1).map(slimDefault);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v).sort()) o[k] = slimDefault(v[k]);
return o;
}
if (typeof v === 'string' && v.length > 90) return v.slice(0, 87) + '...';
return v;
}
function sortKeys(o) {
if (Array.isArray(o)) return o.map(sortKeys);
if (o && typeof o === 'object') return Object.fromEntries(Object.keys(o).sort().map((k) => [k, sortKeys(o[k])]));
return o;
}
export async function buildCatalog() {
const rt = await loadRuntime();
const files = componentFiles();
const cfg = ts.readConfigFile(rel('tsconfig.json'), ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(cfg.config, ts.sys, craftRoot);
const program = ts.createProgram(Object.values(files), { ...parsed.options, noEmit: true });
const names = Object.keys(rt.components);
for (const n of names) if (!NOTES[n]) throw new Error(`catalog: component "${n}" has no entry in scripts/sitesmith-catalog/notes.mjs (add a one-line purpose)`);
for (const n of Object.keys(NOTES)) if (!rt.components[n]) throw new Error(`catalog: notes.mjs mentions "${n}" which is not in the editor's component resolver`);
const components = {};
for (const name of names.sort()) {
const src = fs.readFileSync(files[name], 'utf8');
const all = extractProps(program, files[name], name);
const defaults = rt.components[name].defaults;
const props = {};
for (const [p, info] of Object.entries(all)) {
if (COMMON_PROPS.has(p) || p === 'children') continue;
const e = { ...info };
delete e.optional;
if (p in defaults && !VOLATILE_DEFAULTS.has(`${name}.${p}`) && !(typeof defaults[p] === 'string' && defaults[p].startsWith('data:'))) e.default = slimDefault(defaults[p]);
if (info.type !== 'boolean' && info.type !== 'number' && COLOR_RE.test(p)) {
e.color = true;
if (isTokenRef(defaults[p])) e.tokenDefault = true;
}
props[p] = e;
}
const note = NOTES[name];
const kind = rt.components[name].isCanvas || rt.components[name].shellInner ? "any" : name === "ColumnLayout" ? "columns" : "none";
// icon support is read from the component SOURCE: faClass() => Font Awesome class, else plain text/emoji.
let icon = null;
if (/\bicon\b/.test(Object.keys(all).join(' ')) || /icon/i.test(JSON.stringify(Object.values(all).map((a) => a.item || '')))) {
icon = /faClass\(/.test(src) ? 'fontawesome' : 'text';
}
components[name] = {
category: path.basename(path.dirname(files[name])),
purpose: note.purpose,
children: kind,
...(note.tips ? { tips: note.tips } : {}),
props,
...(all.animation ? { animation: true } : {}),
...(icon ? { icon } : {}),
};
}
const htmlExport = fs.readFileSync(rel('src/utils/html-export.ts'), 'utf8');
const animations = [...new Set([...htmlExport.matchAll(/\[data-animation="([\w-]+)"\]\.animated/g)].map((m) => m[1]))].sort();
const faSrc = fs.readFileSync(rel('src/utils/fa-icon.ts'), 'utf8');
const brands = [...(faSrc.match(/const BRANDS = new Set\(\[([\s\S]*?)\]\)/)?.[1] ?? '').matchAll(/'([\w-]+)'/g)].map((m) => m[1]).sort();
const catalog = {
generatedBy: 'craft/scripts/gen-sitesmith-catalog.mjs (do not edit; regenerate)',
version: 1,
common: {
props: { style: 'object of CSS properties (camelCase); never an array', animation: animations, animationDelay: 'seconds as a bare string, e.g. "0.2"', hideOnDesktop: 'boolean', hideOnTablet: 'boolean', hideOnMobile: 'boolean' },
...GLOBAL_NOTES.common,
},
tokens: {
vars: rt.tokenVars,
colorTokens: rt.colorTokens.map((k) => `var(--wsb-${k})`),
},
icons: { library: 'Font Awesome Free 7.3.1', classForm: 'fa-solid fa-<name> | fa-regular fa-<name> | fa-brands fa-<name>', brands, ...GLOBAL_NOTES.icons },
palettes: rt.palettes,
sectionFamilies: rt.families,
sections: rt.sections,
templates: rt.templates,
components,
};
return sortKeysKeepArrays(catalog);
}
/** Deterministic key order for objects; array order is preserved (it is meaningful). */
function sortKeysKeepArrays(o) {
if (Array.isArray(o)) return o.map(sortKeysKeepArrays);
if (o && typeof o === 'object') return Object.fromEntries(Object.keys(o).sort().map((k) => [k, sortKeysKeepArrays(o[k])]));
return o;
}
export function serialize(catalog) {
return JSON.stringify(catalog, null, 1) + '\n';
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const catalog = await buildCatalog();
const text = serialize(catalog);
const oi = args.indexOf('--out');
if (oi >= 0) {
const out = path.resolve(process.cwd(), args[oi + 1]);
fs.writeFileSync(out, text);
console.error(`wrote ${out} (${text.length} bytes, ${Object.keys(catalog.components).length} components, ${catalog.sections.length} sections, ${catalog.templates.length} templates)`);
} else {
process.stdout.write(text);
}
}