feat(sitesmith): catalog generator from real component/section/template definitions + drift test
craft/scripts/gen-sitesmith-catalog.mjs emits a compact JSON catalog (props with TS types/enums/defaults, token-aware colour props, animation + icon support, child rules, section recipes, templates, palettes) for the WHP panel prompt. src/sitesmith/catalog.drift.test.ts regenerates and compares to the committed whp file so drift fails. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab7a10073b
commit
de240eee75
4 files changed
+414
No files matched your search
@@ -0,0 +1,225 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
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 && !(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Curated, human-written facts the editor's code cannot express: a one-line
|
||||
* purpose per component, and short usage tips where the real behaviour is
|
||||
* surprising. Everything else in the catalog (props, types, enums, defaults,
|
||||
* animation, icon support, child rules) is derived from the code. Adding a
|
||||
* component to the resolver without an entry here fails the generator.
|
||||
*/
|
||||
export const NOTES = {
|
||||
Accordion: { purpose: 'Expand/collapse FAQ list (items: title + content, isOpen)' },
|
||||
BackgroundSection: { purpose: 'Full-width section over a bg image/colour with a dark overlay; children sit inside', tips: 'Text over bgImage needs overlayColor+overlayOpacity (>=0.45) for contrast.' },
|
||||
BookingEmbed: { purpose: 'Embedded booking widget (calendly|square|custom url)', tips: 'Only if the user supplied a booking URL.' },
|
||||
ButtonLink: { purpose: 'Link styled as a button; look comes from style (backgroundColor, color, padding, borderRadius, fontWeight)', tips: 'No variant prop: set style yourself, e.g. primary = {backgroundColor:"var(--wsb-primary)",color:"var(--wsb-bg)",padding:"14px 28px",borderRadius:"var(--wsb-button-radius)",fontWeight:"600"}; secondary = transparent bg + border.' },
|
||||
CTASection: { purpose: 'Centered call-to-action band on a gradient (prop gradient = CSS gradient string); ctas[] buttons', tips: 'Use ctas[], not buttonText/buttonHref.' },
|
||||
CallToAction: { purpose: 'Call-to-action band on colour/gradient/image (bgType+bgValue, overlay); ctas[] buttons', tips: 'Use ctas[], not buttonText/secondaryButton*.' },
|
||||
ColumnLayout: { purpose: 'Row of columns; each direct child becomes one column and stacks on mobile', tips: 'columns is set from the child count; split must be an allowed string. Put style.alignItems:"center" to centre columns vertically.' },
|
||||
ContactForm: { purpose: 'Complete contact form with fields[], submit button, spam captcha option (no children)', tips: 'Delivery is configured by the owner (recipientEmail / webhook): never invent an address; leave recipientEmail empty.' },
|
||||
Container: { purpose: 'Generic block (flow layout). Children stack vertically; use style for padding/background/maxWidth/flex', tips: 'tag is one of div|section|article|header|footer|main. The page root is a Container.' },
|
||||
ContentSlider: { purpose: 'Auto-playing slider of content/image slides', tips: 'Use sparingly; a static hero is almost always better.' },
|
||||
Countdown: { purpose: 'Countdown to targetDate (YYYY-MM-DD)', tips: 'Only for a real, user-given date.' },
|
||||
Divider: { purpose: 'Horizontal rule' },
|
||||
FeaturesGrid: { purpose: 'Responsive grid of cards {icon,title,description,image?,buttonText?,buttonUrl?}', tips: 'icon is PLAIN TEXT (a single glyph/emoji), NOT Font Awesome: never put fa-* there. For Font Awesome icon cards build ColumnLayout > Container > Icon + Heading + TextBlock instead. An image per feature (image,imageAlt) replaces the icon.' },
|
||||
Footer: { purpose: 'Simple footer text line (copyright). For richer footers compose Container/ColumnLayout/SocialLinks/Menu', tips: 'Emit in the top-level "footer" slot for scope=site.' },
|
||||
FormButton: { purpose: 'Submit button inside a FormContainer' },
|
||||
FormContainer: { purpose: 'Wrapper for hand-built forms (InputField/TextareaField/FormButton children)', tips: 'Prefer ContactForm.' },
|
||||
Gallery: { purpose: 'Image grid (images[] {src,alt,caption?}), columns, optional lightbox', tips: 'Use the user\'s uploaded images only; no placeholders in galleries.' },
|
||||
Heading: { purpose: 'Heading h1-h6; size/weight/colour/letterSpacing via style', tips: 'One h1 per page. Set fontFamily:"var(--wsb-heading-font)" and a size from the type scale; leave html empty.' },
|
||||
HeroSimple: { purpose: 'Pre-built hero: heading, subtitle, ctas[], bg colour/gradient/image/video with overlay, minHeight', tips: 'With bgType=image set overlayColor+overlayOpacity (>=0.45) so light text passes contrast. No entrance animation support.' },
|
||||
HtmlBlock: { purpose: 'Sanitised raw HTML. Last resort only', tips: 'Scripts/handlers are stripped.' },
|
||||
Icon: { purpose: 'One Font Awesome icon (icon="fa-solid fa-<name>" or bare "fa-<name>"), optional circle/rounded background', tips: 'The ONLY real-icon component. size/color/bgColor/bgShape/bgSize; link optional.' },
|
||||
ImageBlock: { purpose: 'Image. Crop/size/radius via style (width, maxWidth, aspectRatio, objectFit, borderRadius)', tips: 'Always set alt. Use style.aspectRatio with objectFit:"cover" (e.g. "4 / 3", "16 / 9", "1 / 1") and a consistent borderRadius. src: "assets/<file>" for uploaded images.' },
|
||||
InputField: { purpose: 'Single-line form input inside FormContainer' },
|
||||
Logo: { purpose: 'Text or image logo linking home' },
|
||||
MapEmbed: { purpose: 'Embedded map for an address', tips: 'Only with a real address from the user.' },
|
||||
Menu: { purpose: 'Link list (horizontal/vertical) with optional CTA-styled link' },
|
||||
Navbar: { purpose: 'Top navigation bar: logo (text|image), links[] {text,href,isCta?}, sticky option', tips: 'Emit in the top-level "header" slot. In-page links use href "#anchor"; other pages "/page-slug".' },
|
||||
NumberCounter: { purpose: 'Row of animated stats (counters[] {number,suffix,label})', tips: 'Only real figures supplied by the user.' },
|
||||
OpeningHours: { purpose: 'Opening hours table (rows[] {day,hours,closed})', tips: 'Only real hours supplied by the user.' },
|
||||
PricingTable: { purpose: 'Pricing cards (plans[] {name,price,period,features[],buttonText,buttonHref,isFeatured})', tips: 'Only real prices supplied by the user; otherwise use a "Request a quote" CTA.' },
|
||||
ReviewsStrip: { purpose: 'Strip of customer reviews (reviews[] {name,rating,text,source,url}) with link to all reviews', tips: 'Only real reviews supplied by the user.' },
|
||||
SearchBar: { purpose: 'Search form' },
|
||||
Section: { purpose: 'Full-width section band with a centred inner column (innerMaxWidth); optional top/bottom divider shapes', tips: 'Backgrounds and vertical padding go in style (backgroundColor, paddingTop/paddingBottom).' },
|
||||
SocialLinks: { purpose: 'Row of social icons (links[] {platform,url})', tips: 'Only profiles the user gave.' },
|
||||
Spacer: { purpose: 'Vertical gap' },
|
||||
StarRating: { purpose: 'Star rating glyphs (rating 0-5)', tips: 'Only real ratings.' },
|
||||
StickyCallBar: { purpose: 'Mobile-only sticky call/directions bar', tips: 'Only with a real phone number.' },
|
||||
SubscribeForm: { purpose: 'Email newsletter signup' },
|
||||
Tabs: { purpose: 'Tabbed content (tabs[] {label,content})' },
|
||||
Testimonials: { purpose: 'Testimonial cards (testimonials[] {quote,name,title,rating}) in a grid or single layout', tips: 'Only real quotes supplied by the user.' },
|
||||
TextBlock: { purpose: 'Paragraph text; size/colour/lineHeight/maxWidth via style', tips: 'Set fontFamily:"var(--wsb-body-font)", lineHeight 1.6-1.75, maxWidth about 65ch for long copy; leave html empty.' },
|
||||
TextareaField: { purpose: 'Multi-line form input inside FormContainer' },
|
||||
VideoBlock: { purpose: 'Video (file/youtube/vimeo) or background video', tips: 'Only with a URL the user gave.' },
|
||||
};
|
||||
|
||||
export const GLOBAL_NOTES = {
|
||||
common: {
|
||||
rules: [
|
||||
'Every prop below that is a colour accepts a hex, rgb()/hsl(), a gradient (where noted) or a design token var(--wsb-<name>).',
|
||||
'Entrance animation (animation, animationDelay) exists only on components marked animation:true; HeroSimple, Divider, Spacer, HtmlBlock do not support it.',
|
||||
],
|
||||
},
|
||||
icons: {
|
||||
components: 'Icon renders Font Awesome. FeaturesGrid.icon is plain text, not Font Awesome.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// Bundled by scripts/gen-sitesmith-catalog.mjs (esbuild, platform node) and run
|
||||
// in Node. Collects everything that only exists at RUNTIME in the editor's real
|
||||
// definitions: component craft defaults/rules, token vocabulary, palettes,
|
||||
// section families and templates. The TypeScript prop TYPES come from the
|
||||
// compiler API in the .mjs (types are erased at runtime).
|
||||
import { componentResolver } from '../../src/components/resolver';
|
||||
import { TOKEN_VARS, COLOR_TOKENS } from '../../src/utils/tokens';
|
||||
import { PALETTES } from '../../src/constants/palettes';
|
||||
import { BUILTIN_SECTIONS } from '../../src/sections/families';
|
||||
import { SECTION_FAMILIES } from '../../src/sections/catalog';
|
||||
import { allTemplates } from '../../src/templates/definitions';
|
||||
import { CANVAS_TYPES, SHELL_INNER } from '../../src/utils/craft-tree';
|
||||
import { DEFAULT_SITE_DESIGN } from '../../src/state/SiteDesignContext';
|
||||
|
||||
type Tpl = { type: string; props: Record<string, any>; children?: Tpl[] };
|
||||
|
||||
/** "Section[Heading,TextBlock,ButtonLink]": the structural skeleton of a tree. */
|
||||
function recipe(n: Tpl): string {
|
||||
const kids = (n.children ?? []).map(recipe);
|
||||
// collapse runs of identical siblings: Container*3
|
||||
const out: string[] = [];
|
||||
for (const k of kids) {
|
||||
const last = out[out.length - 1];
|
||||
const m = last && last.match(/^(.*)\*(\d+)$/);
|
||||
if (last === k) out[out.length - 1] = `${k}*2`;
|
||||
else if (m && m[1] === k) out[out.length - 1] = `${k}*${Number(m[2]) + 1}`;
|
||||
else out.push(k);
|
||||
}
|
||||
return out.length ? `${n.type}[${out.join(',')}]` : n.type;
|
||||
}
|
||||
|
||||
export function collect() {
|
||||
const components: Record<string, any> = {};
|
||||
for (const [name, comp] of Object.entries(componentResolver) as [string, any][]) {
|
||||
components[name] = {
|
||||
defaults: comp.craft?.props ?? {},
|
||||
displayName: comp.craft?.displayName ?? name,
|
||||
isCanvas: CANVAS_TYPES.has(name),
|
||||
shellInner: SHELL_INNER[name] ?? null,
|
||||
canMoveInFalse: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
components,
|
||||
tokenVars: TOKEN_VARS.map(([v, k]) => ({ var: v, designKey: k, default: (DEFAULT_SITE_DESIGN as any)[k] })),
|
||||
colorTokens: COLOR_TOKENS.map((t) => t.key),
|
||||
palettes: PALETTES.map((p) => ({ id: p.id, name: p.name, ...p.design, fonts: p.fonts ?? null })),
|
||||
families: SECTION_FAMILIES.filter((f) => f.id !== 'custom'),
|
||||
sections: BUILTIN_SECTIONS.map((s) => ({ id: s.id, name: s.name, family: s.family, keywords: s.keywords, recipe: recipe(s.tree as Tpl) })),
|
||||
templates: allTemplates.map((t) => ({
|
||||
id: t.id, name: t.name, category: t.category, description: t.description, multiPage: t.isMultiPage,
|
||||
design: t.design,
|
||||
pages: t.pages.map((p) => ({ name: p.name, slug: p.slug, recipe: p.content.components.map((c) => recipe(c as Tpl)) })),
|
||||
header: t.header.components.map((c) => recipe(c as Tpl)),
|
||||
footer: t.footer.components.map((c) => recipe(c as Tpl)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Drift guard for the Sitesmith catalog. The panel ships a generated file
|
||||
* (whp: web-files/libs/sitesmith-catalog.generated.json) built from THIS
|
||||
* editor's real component / section / template definitions. This test
|
||||
* regenerates the catalog in a child process and compares it byte-for-byte to
|
||||
* the committed file, so adding or changing a component, prop, enum, section
|
||||
* family or template without regenerating FAILS here.
|
||||
*
|
||||
* Where the committed file lives:
|
||||
* $SITESMITH_CATALOG_FILE (absolute path), else
|
||||
* ../../whp/web-files/libs/sitesmith-catalog.generated.json relative to craft/
|
||||
*
|
||||
* Regenerate after an editor change:
|
||||
* node scripts/gen-sitesmith-catalog.mjs --out <path-to-whp>/web-files/libs/sitesmith-catalog.generated.json
|
||||
* then commit the whp file alongside the site-builder change.
|
||||
*/
|
||||
import { describe, test, expect } from 'vitest';
|
||||
// No @types/node in this package (same trick as ColumnLayout.stack-parity.test.tsx).
|
||||
const proc = (globalThis as any).process;
|
||||
const fs = proc.getBuiltinModule('node:fs');
|
||||
const path = proc.getBuiltinModule('node:path');
|
||||
const { execFileSync } = proc.getBuiltinModule('node:child_process');
|
||||
|
||||
const craftRoot: string = path.resolve(decodeURIComponent(new URL('.', import.meta.url).pathname), '../..');
|
||||
const committedPath: string = proc.env.SITESMITH_CATALOG_FILE
|
||||
?? path.resolve(craftRoot, '../../whp/web-files/libs/sitesmith-catalog.generated.json');
|
||||
|
||||
function regenerate(): string {
|
||||
return execFileSync(proc.execPath, ['scripts/gen-sitesmith-catalog.mjs', '--stdout'], {
|
||||
cwd: craftRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
describe('sitesmith catalog', () => {
|
||||
test('committed generated catalog matches the editor definitions (no drift)', () => {
|
||||
if (!fs.existsSync(committedPath)) {
|
||||
throw new Error(`Committed catalog not found at ${committedPath}. Set SITESMITH_CATALOG_FILE to the whp worktree path, or generate it with: node scripts/gen-sitesmith-catalog.mjs --out ${committedPath}`);
|
||||
}
|
||||
const fresh = regenerate();
|
||||
const committed = fs.readFileSync(committedPath, 'utf8');
|
||||
if (fresh !== committed) {
|
||||
const a = JSON.parse(fresh), b = JSON.parse(committed);
|
||||
const diff = Object.keys(a.components).filter((k) => JSON.stringify(a.components[k]) !== JSON.stringify(b.components?.[k]));
|
||||
throw new Error(`Sitesmith catalog drift. Regenerate with: node scripts/gen-sitesmith-catalog.mjs --out ${committedPath}\nChanged/new components: ${diff.join(', ') || '(none; sections/templates/tokens/palettes differ)'}`);
|
||||
}
|
||||
expect(fresh).toBe(committed);
|
||||
}, 130_000);
|
||||
|
||||
test('catalog covers every resolver component, with derived facts', () => {
|
||||
const cat = JSON.parse(regenerate());
|
||||
const resolver = fs.readFileSync(path.join(craftRoot, 'src/components/resolver.ts'), 'utf8');
|
||||
const names = [...resolver.matchAll(/^ ([A-Z]\w+),$/gm)].map((m: RegExpMatchArray) => m[1]);
|
||||
expect(Object.keys(cat.components).sort()).toEqual(names.sort());
|
||||
expect(cat.components.Icon.icon).toBe('fontawesome');
|
||||
expect(cat.components.FeaturesGrid.icon).toBe('text'); // plain-text icon, not Font Awesome
|
||||
expect(cat.components.ColumnLayout.children).toBe('columns');
|
||||
expect(cat.components.ColumnLayout.props.split.enum).toContain('50-50');
|
||||
expect(cat.components.HeroSimple.animation).toBeUndefined();
|
||||
expect(cat.components.Heading.animation).toBe(true);
|
||||
expect(cat.components.HeroSimple.props.bgGradientFrom).toMatchObject({ color: true, tokenDefault: true });
|
||||
expect(cat.common.props.animation).toContain('fade-in');
|
||||
expect(cat.tokens.colorTokens).toContain('var(--wsb-primary)');
|
||||
expect(cat.sections.length).toBeGreaterThan(40);
|
||||
expect(cat.templates.length).toBe(16);
|
||||
}, 130_000);
|
||||
});
|
||||
Reference in new issue
Block a user