Merge feat/sitesmith-catalog into release/sitesmith-colors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 10:55:05 -08:00
commit 9f48b8b134
6 files changed
+520

No files matched your search

+225
View File
@@ -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,72 @@
#!/usr/bin/env node
/**
* Render a Sitesmith JSON response (a recorded fixture, or a live one saved to a file) through the
* repo's own exporter into HTML, then take Firefox screenshots so a human can compare runs.
*
* node scripts/render-sitesmith-response.mjs --response resp.json --out outdir [--design design.json]
* [--page 0] [--assets dir] [--width 1280] [--mobile 390] [--no-shots]
*
* --design JSON of SiteDesign overrides (primaryColor, headingFont, ...), i.e. the values the
* SITE DESIGN prompt block carried. Defaults to the editor's default design.
* --assets directory to expose as ./assets/ next to the HTML (symlinked) so relUrl images load.
* Screenshots: Playwright module and Firefox binary come from $SITESMITH_PLAYWRIGHT / $SITESMITH_FIREFOX
* (defaults below). Only type=replace responses render (patches need a canvas).
*/
import { build } from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const craftRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const opt = (name, def) => { const i = args.indexOf('--' + name); return i >= 0 ? args[i + 1] : def; };
const flag = (name) => args.includes('--' + name);
const respPath = opt('response'), outDir = opt('out');
if (!respPath || !outDir) { console.error('usage: render-sitesmith-response.mjs --response resp.json --out dir [--design design.json] [--assets dir]'); process.exit(2); }
const PLAYWRIGHT = process.env.SITESMITH_PLAYWRIGHT || '/tmp/claude-1000/-workspace/edbe144d-d5fe-40a1-88a3-fb8ed9709df9/scratchpad/repro/node_modules/playwright/index.mjs';
const FIREFOX = process.env.SITESMITH_FIREFOX || '/home/claude/.cache/ms-playwright/firefox-1538/firefox/firefox';
const bundle = await build({
entryPoints: [path.join(craftRoot, 'scripts/sitesmith-catalog/render-entry.ts')],
bundle: true, write: false, format: 'cjs', platform: 'node', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"' },
});
const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'ssrender-'));
fs.writeFileSync(path.join(tmp, 'r.cjs'), bundle.outputFiles[0].text);
const { renderResponse } = createRequire(import.meta.url)(path.join(tmp, 'r.cjs'));
fs.rmSync(tmp, { recursive: true, force: true });
const resp = JSON.parse(fs.readFileSync(respPath, 'utf8'));
const design = opt('design') ? JSON.parse(fs.readFileSync(opt('design'), 'utf8')) : {};
const html = renderResponse(resp, design, Number(opt('page', 0)));
fs.mkdirSync(outDir, { recursive: true });
const htmlPath = path.join(outDir, 'page.html');
fs.writeFileSync(htmlPath, html);
if (opt('assets')) {
const link = path.join(outDir, 'assets');
fs.rmSync(link, { recursive: true, force: true });
fs.symlinkSync(path.resolve(opt('assets')), link);
}
console.log(`html: ${htmlPath} (${html.length} bytes)`);
if (!flag('no-shots')) {
const { firefox } = await import(pathToFileURL(PLAYWRIGHT).href);
const browser = await firefox.launch({ executablePath: FIREFOX, headless: true });
for (const [label, width, height] of [['desktop', Number(opt('width', 1280)), 900], ['mobile', Number(opt('mobile', 390)), 844]]) {
const page = await browser.newPage({ viewport: { width, height } });
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => errors.push(String(e)));
await page.evaluate(() => document.fonts && document.fonts.ready);
// reveal entrance animations deterministically for the capture
await page.addStyleTag({ content: '[data-animation]{opacity:1!important;animation:none!important}' });
const shot = path.join(outDir, `${label}.png`);
await page.screenshot({ path: shot, fullPage: true });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
console.log(`${label}: ${shot} horizontal-overflow-px=${overflow}${errors.length ? ' errors=' + JSON.stringify(errors) : ''}`);
await page.close();
}
await browser.close();
}
+64
View File
@@ -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: 'ALWAYS set style display:"flex" and flexDirection:"row": published pages stack columns on phones ONLY via that inline pair (measured; without it columns stay side by side, squeezed). columns follows the child count; split must be an allowed string; style.alignItems:"center" centres 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: 'Colour comes from style.color (default is dark text: set a light color on a dark footer). 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: 'Set showMobileMenu:true or the links overflow on phones. 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,34 @@
// Bundled by scripts/render-sitesmith-response.mjs (esbuild, platform node).
// Turns a Sitesmith JSON response into the HTML the REPO EXPORTER produces:
// the same sanitize -> flatten -> exportBodyHtml path the editor uses.
import { sanitizeAiTree, flattenTreeForCraft } from '../../src/utils/craft-tree';
import { exportBodyHtml, exportComposedPreviewHtml } from '../../src/utils/html-export';
import { DEFAULT_SITE_DESIGN } from '../../src/state/SiteDesignContext';
function stateFor(tree: any): string {
const clean = sanitizeAiTree(tree, new Set(), { n: 0 });
if (!clean) throw new Error('response tree root is not a known component');
const flat = flattenTreeForCraft(clean);
const root = {
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, displayName: 'Container',
custom: {}, hidden: false, parent: null, nodes: [flat.rootNodeId], linkedNodes: {},
};
flat.nodes[flat.rootNodeId].parent = 'ROOT';
return JSON.stringify({ ROOT: root, ...flat.nodes });
}
/** Mirrors SiteBuilderHead.php sb_font_awesome_link(): the backend adds this to published pages. */
const FA_LINK = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">';
export function renderResponse(resp: any, designOverrides: Record<string, any> = {}, pageIndex = 0) {
if (resp?.type !== 'replace') throw new Error(`only type=replace responses can be rendered (got ${resp?.type})`);
const design = { ...DEFAULT_SITE_DESIGN, ...designOverrides };
const page = resp.pages[pageIndex];
const pageState = stateFor(page.tree);
const hdr = resp.header ? exportBodyHtml(stateFor(resp.header.tree), 'header').html : '';
const ftr = resp.footer ? exportBodyHtml(stateFor(resp.footer.tree), 'footer').html : '';
const body = exportBodyHtml(pageState).html;
let html = exportComposedPreviewHtml(pageState, hdr + body + ftr, { title: page.name, includeFonts: true, design } as any);
if (/class="[^"]*\bfa-/.test(html) && !html.includes('font-awesome')) html = html.replace('</head>', () => `${FA_LINK}\n</head>`);
return html;
}
@@ -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)),
})),
};
}
+67
View File
@@ -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);
});