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}${tag}>`,
};
}
// 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 `