import { componentResolver } from '../components/resolver'; import { cssPropsToString } from './style-helpers'; import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape'; import { sanitizeContainerTag } from '../components/layout/Container'; import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; export interface ExportOptions { title?: string; includeFonts?: boolean; minifyCss?: boolean; headCode?: string; /** PageSeo.metaDescription -- also feeds og:description when set. */ description?: string; /** PageSeo.ogTitle -- falls back to `title` (which itself already folds in metaTitle) when empty. */ ogTitle?: string; /** PageSeo.ogImage -- absolute or site-relative image URL. */ ogImage?: string; /** PageSeo.twitterCard -- default 'summary_large_image' when ogImage set, else 'summary'. */ twitterCard?: 'summary' | 'summary_large_image'; /** PageSeo.noindex -- emits . */ noindex?: boolean; /** SiteDesign.favicon -- site-wide, one per site. */ favicon?: string; /** Full site design tokens -- drives the :root{} CSS-variable block (contract §2). * Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy * callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */ design?: SiteDesign; } interface ResolverMap { [key: string]: any; } const resolver: ResolverMap = componentResolver; /** * Build data attribute string for responsive visibility and animations. */ function buildDataAttrs(props: Record): string { let attrs = ''; if (props.hideOnDesktop) attrs += ' data-hide-desktop'; if (props.hideOnTablet) attrs += ' data-hide-tablet'; if (props.hideOnMobile) attrs += ' data-hide-mobile'; if (props.animation && props.animation !== 'none') { attrs += ` data-animation="${escapeAttr(String(props.animation))}"`; if (props.animationDelay && props.animationDelay !== '0') { attrs += ` data-animation-delay="${escapeAttr(String(props.animationDelay))}"`; } } return attrs; } /** * Inject data attributes into the first HTML opening tag of a rendered string. * * For a void/self-closing tag (e.g. ``) the first `>` is * preceded by a `/` -- naively inserting before the `>` produces the * malformed `` (attrs land AFTER the * self-close slash, outside the tag). Detect that trailing `/` and insert * the attrs before it instead, yielding well-formed ``. * Non-void tags (no trailing `/`) are unaffected. */ function injectAttrs(html: string, attrs: string): string { if (!attrs) return html; // Find the first > of the opening tag and inject before it const idx = html.indexOf('>'); if (idx === -1) return html; if (idx > 0 && html[idx - 1] === '/') { // Void/self-closing tag (``): inserting before `>` would land // the attrs after the `/`, outside the tag (``). // Insert before the `/` instead -- also trim any whitespace directly // preceding it so we don't end up with a double space, since `attrs` // already carries its own leading space(s). let contentEnd = idx - 1; while (contentEnd > 0 && /\s/.test(html[contentEnd - 1])) contentEnd--; return html.slice(0, contentEnd) + attrs + html.slice(idx - 1); } return html.slice(0, idx) + attrs + html.slice(idx); } /** * Recursively render a Craft.js node tree to HTML. */ function renderNode(nodes: Record, nodeId: string): { html: string } { const node = nodes[nodeId]; if (!node) return { html: '' }; const typeName = node.type?.resolvedName || node.type; const props = node.props || {}; // Collect children HTML const childNodeIds: string[] = node.nodes || []; const linkedNodes: Record = node.linkedNodes || {}; // Render direct child nodes let childrenHtml = childNodeIds .map((childId: string) => renderNode(nodes, childId).html) .join(''); // Render linked nodes (e.g., Section's inner container) const linkedHtml = Object.values(linkedNodes) .map((linkedId: string) => renderNode(nodes, linkedId).html) .join(''); // For linked nodes, the component's toHtml should handle them via childrenHtml // We prioritize linked nodes if direct children are empty const allChildrenHtml = childrenHtml + linkedHtml; // Build data attributes for responsive visibility and animations const dataAttrs = buildDataAttrs(props); // Look up component in resolver and call toHtml. `nodeId` is the Craft.js // node id for this node -- unique per node in the tree and stable across // repeated exports of the same saved page. It's passed as a 3rd argument // so components can derive deterministic AND unique scope ids for their // exported element ids / aria-wiring / inline-script function names // (see `scopeId` in utils/escape.ts) instead of Math.random() (unique but // non-deterministic) or a content hash (deterministic but collides when // two instances share default/identical content). Components that don't // need a scope id simply ignore the extra argument -- backward compatible // with existing 2-arg `toHtml(props, childrenHtml)` implementations/tests. const component = resolver[typeName]; if (component && typeof component.toHtml === 'function') { const result = component.toHtml(props, allChildrenHtml, nodeId); const html = result.html || ''; return { html: injectAttrs(html, dataAttrs) }; } // Fallback: wrap children in a div with inline styles if (typeName === 'Container' || typeName === 'div') { const styleStr = cssPropsToString(props.style); // `props.tag` reaches this fallback the same way it reaches // `Container.toHtml` -- via AI `update_props` or deserialized saved // state, neither type-checked at runtime -- so it must be allowlisted // before hitting the `<${tag}` template position below. const tag = sanitizeContainerTag(props.tag); return { html: `<${tag}${dataAttrs}${styleStr ? ` style="${styleStr}"` : ''}>${allChildrenHtml}`, }; } // For unrecognized types, just return children return { html: allChildrenHtml }; } const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`; const CSS_RESET_PRETTY = `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1f2937; -webkit-font-smoothing: antialiased; } img { max-width: 100%; height: auto; display: block; } a { color: inherit; }`; const GOOGLE_FONTS_LINK = ` `; // Google Fonts family= params for each of the 8 presets in constants/presets.ts // FONT_FAMILIES, keyed by the CSS font-family name (the part before the first // comma in e.g. 'Playfair Display, serif'). Used to build a best-effort // SUBSET fonts link (perf: §3) when every design font resolves to a known // preset; otherwise buildFontsLink() falls back to the full GOOGLE_FONTS_LINK // above (which already covers all 8 and already has &display=swap) so a // component using a preset outside the design's 3 font fields never loses // its font. const GOOGLE_FONT_PARAMS: Record = { 'Inter': 'family=Inter:wght@300;400;500;600;700', 'Roboto': 'family=Roboto:wght@300;400;500;700', 'Open Sans': 'family=Open+Sans:wght@300;400;600;700', 'Poppins': 'family=Poppins:wght@300;400;500;600;700', 'Montserrat': 'family=Montserrat:wght@300;400;500;600;700', 'Playfair Display': 'family=Playfair+Display:wght@400;600;700', 'Merriweather': 'family=Merriweather:wght@300;400;700', 'Source Code Pro': 'family=Source+Code+Pro:wght@400;500;600', }; /** Extracts the CSS font-family name from a value like 'Inter, sans-serif' -> 'Inter'. */ function extractFontName(value: string | undefined): string { return (value || '').split(',')[0].trim(); } /** * Best-effort Google Fonts link (contract §3): tries to build a SUBSET link * containing only the design's heading/body/button fonts (+ display=swap). * Falls back to the full multi-font GOOGLE_FONTS_LINK -- which still has * display=swap -- whenever any of those three fonts isn't one of the 8 known * presets, so a font a component actually uses is never silently dropped. */ export function buildFontsLink(design?: SiteDesign): string { if (!design) return GOOGLE_FONTS_LINK; const families = [design.headingFont, design.bodyFont, design.buttonFont] .map(extractFontName) .filter((f, i, arr) => f !== '' && arr.indexOf(f) === i); if (families.length === 0) return GOOGLE_FONTS_LINK; const params = families.map((f) => GOOGLE_FONT_PARAMS[f]).filter((p): p is string => !!p); if (params.length !== families.length) return GOOGLE_FONTS_LINK; return ` `; } // ---------- Design-token CSS-variable block (contract §2) ---------- // Ordered [cssVarName, SiteDesign key] pairs -- exact order/names from the // PKG-H shared contract §2. Both the frontend (here) and the backend // (site-builder.php) emit this same 16-line block for the same data so // editor Preview === published output. const TOKEN_VARS: Array<[string, keyof SiteDesign]> = [ ['--wsb-primary', 'primaryColor'], ['--wsb-secondary', 'secondaryColor'], ['--wsb-accent', 'accentColor'], ['--wsb-link', 'linkColor'], ['--wsb-success', 'successColor'], ['--wsb-warning', 'warningColor'], ['--wsb-error', 'errorColor'], ['--wsb-bg', 'backgroundColor'], ['--wsb-text', 'textColor'], ['--wsb-muted', 'mutedTextColor'], ['--wsb-border', 'borderColor'], ['--wsb-radius', 'borderRadius'], ['--wsb-heading-font', 'headingFont'], ['--wsb-body-font', 'bodyFont'], ['--wsb-button-font', 'buttonFont'], ['--wsb-button-radius', 'buttonRadius'], ]; // Fixed token-base CSS (contract §2) -- makes the tokens actually take effect // globally; component inline styles still override via specificity. const TOKEN_BASE_CSS = `body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}`; // Fixed a11y/perf CSS (contract §3), emitted for every published/preview page. const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}`; /** * Builds the `:root{...}` design-token block + the fixed token-base CSS + * the fixed a11y/perf CSS (contract §2/§3). Every token value is sanitized * through `cssValue()` before interpolation -- it sits raw inside a * `:root{ }` CSS-element context (not a string-quoted context), so a * malicious value could otherwise break out of the block / the ``; /** * Returns the reveal `