Add a date-independence test (freezes Date at two different days via a preload). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
234 lines
11 KiB
JavaScript
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);
|
|
}
|
|
}
|