From de240eee757731ad9c0572cf64cd8ff7f018ca52 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:52:49 -0800 Subject: [PATCH 1/2] 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 --- craft/scripts/gen-sitesmith-catalog.mjs | 225 ++++++++++++++++++ craft/scripts/sitesmith-catalog/notes.mjs | 64 +++++ .../sitesmith-catalog/runtime-entry.ts | 58 +++++ craft/src/sitesmith/catalog.drift.test.ts | 67 ++++++ 4 files changed, 414 insertions(+) create mode 100644 craft/scripts/gen-sitesmith-catalog.mjs create mode 100644 craft/scripts/sitesmith-catalog/notes.mjs create mode 100644 craft/scripts/sitesmith-catalog/runtime-entry.ts create mode 100644 craft/src/sitesmith/catalog.drift.test.ts diff --git a/craft/scripts/gen-sitesmith-catalog.mjs b/craft/scripts/gen-sitesmith-catalog.mjs new file mode 100644 index 0000000..b2b90cb --- /dev/null +++ b/craft/scripts/gen-sitesmith-catalog.mjs @@ -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" 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- | fa-regular fa- | fa-brands fa-', 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); + } +} diff --git a/craft/scripts/sitesmith-catalog/notes.mjs b/craft/scripts/sitesmith-catalog/notes.mjs new file mode 100644 index 0000000..4a0ef13 --- /dev/null +++ b/craft/scripts/sitesmith-catalog/notes.mjs @@ -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-" or bare "fa-"), 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/" 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-).', + '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.', + }, +}; diff --git a/craft/scripts/sitesmith-catalog/runtime-entry.ts b/craft/scripts/sitesmith-catalog/runtime-entry.ts new file mode 100644 index 0000000..35a734c --- /dev/null +++ b/craft/scripts/sitesmith-catalog/runtime-entry.ts @@ -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; 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 = {}; + 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)), + })), + }; +} diff --git a/craft/src/sitesmith/catalog.drift.test.ts b/craft/src/sitesmith/catalog.drift.test.ts new file mode 100644 index 0000000..b4dc4cf --- /dev/null +++ b/craft/src/sitesmith/catalog.drift.test.ts @@ -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 /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); +}); From d83919b89a3d2000c5312e0e0d9a0f14d44ddf80 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:01:04 -0800 Subject: [PATCH 2/2] feat(sitesmith): renderer for Sitesmith responses (repo exporter + Firefox shots); catalog tips for ColumnLayout stacking, Navbar, Footer Co-Authored-By: Claude Sonnet 5.5 --- craft/scripts/render-sitesmith-response.mjs | 72 +++++++++++++++++++ craft/scripts/sitesmith-catalog/notes.mjs | 6 +- .../scripts/sitesmith-catalog/render-entry.ts | 34 +++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 craft/scripts/render-sitesmith-response.mjs create mode 100644 craft/scripts/sitesmith-catalog/render-entry.ts diff --git a/craft/scripts/render-sitesmith-response.mjs b/craft/scripts/render-sitesmith-response.mjs new file mode 100644 index 0000000..40c58e9 --- /dev/null +++ b/craft/scripts/render-sitesmith-response.mjs @@ -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(); +} diff --git a/craft/scripts/sitesmith-catalog/notes.mjs b/craft/scripts/sitesmith-catalog/notes.mjs index 4a0ef13..b35798f 100644 --- a/craft/scripts/sitesmith-catalog/notes.mjs +++ b/craft/scripts/sitesmith-catalog/notes.mjs @@ -12,14 +12,14 @@ export const NOTES = { 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.' }, + 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: 'Emit in the top-level "footer" slot for scope=site.' }, + 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.' }, @@ -32,7 +32,7 @@ export const NOTES = { 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".' }, + 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.' }, diff --git a/craft/scripts/sitesmith-catalog/render-entry.ts b/craft/scripts/sitesmith-catalog/render-entry.ts new file mode 100644 index 0000000..e71fb45 --- /dev/null +++ b/craft/scripts/sitesmith-catalog/render-entry.ts @@ -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 = ''; + +export function renderResponse(resp: any, designOverrides: Record = {}, 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('', () => `${FA_LINK}\n`); + return html; +}