2026-04-05 18:31:16 -07:00
import { componentResolver } from '../components/resolver' ;
import { cssPropsToString } from './style-helpers' ;
2026-07-14 07:14:56 -07:00
import { escapeHtml , escapeAttr , cssValue , safeImageUrl } from './escape' ;
2026-07-12 17:44:59 -07:00
import { sanitizeContainerTag } from '../components/layout/Container' ;
2026-07-14 07:14:56 -07:00
import { SiteDesign , DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext' ;
2026-04-05 18:31:16 -07:00
export interface ExportOptions {
title? : string ;
includeFonts? : boolean ;
minifyCss? : boolean ;
headCode? : string ;
2026-07-14 07:14:56 -07:00
/** 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 <meta name="robots" content="noindex, nofollow">. */
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 ;
2026-04-05 18:31:16 -07:00
}
interface ResolverMap {
[ key : string ] : any ;
}
const resolver : ResolverMap = componentResolver ;
/**
* Build data attribute string for responsive visibility and animations.
*/
function buildDataAttrs ( props : Record < string , any >) : 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' ) {
2026-07-12 17:27:22 -07:00
attrs += ` data-animation=" ${ escapeAttr ( String ( props . animation )) } "` ;
2026-04-05 18:31:16 -07:00
if ( props . animationDelay && props . animationDelay !== '0' ) {
2026-07-12 17:27:22 -07:00
attrs += ` data-animation-delay=" ${ escapeAttr ( String ( props . animationDelay )) } "` ;
2026-04-05 18:31:16 -07:00
}
}
return attrs ;
}
/**
* Inject data attributes into the first HTML opening tag of a rendered string.
*/
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 ;
return html . slice ( 0 , idx ) + attrs + html . slice ( idx );
}
/**
* Recursively render a Craft.js node tree to HTML.
*/
function renderNode ( nodes : Record < string , any >, 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 < string , string > = 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 );
2026-07-12 14:34:53 -07:00
// 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.
2026-04-05 18:31:16 -07:00
const component = resolver [ typeName ];
if ( component && typeof component . toHtml === 'function' ) {
2026-07-12 14:34:53 -07:00
const result = component . toHtml ( props , allChildrenHtml , nodeId );
2026-04-05 18:31:16 -07:00
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 );
2026-07-12 17:44:59 -07:00
// `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 );
2026-04-05 18:31:16 -07:00
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 = `<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">` ;
2026-07-14 07:14:56 -07:00
// 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 < string , string > = {
'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 `<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2? ${ params . join ( '&' ) } &display=swap" rel="stylesheet">` ;
}
// ---------- 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 `<style>`
* element itself.
*/
export function buildTokenCss ( design : SiteDesign ) : string {
const vars = TOKEN_VARS . map (([ varName , key ]) => ` ${ varName } : ${ cssValue ( design [ key ] as string ) } ` ). join ( ';' );
return `:root{ ${ vars } } ${ TOKEN_BASE_CSS }${ A11Y_PERF_CSS } ` ;
}
// ---------- <head> SEO/meta block (contract §4) ----------
/**
* Builds the ordered SEO/meta tag block from `<meta name="robots">` (only
* when noindex) through the favicon `<link>` -- everything between `<title>`
* and the Google Fonts link in the contract §4 order. `title` must already
* be the FULLY RESOLVED title (i.e. `seo.metaTitle || page.name`) -- the
* og:title fallback chain in the contract (`ogTitle || metaTitle || TITLE`)
* collapses to `ogTitle || title` once `title` itself already folds in the
* metaTitle fallback, so no separate metaTitle parameter is needed here.
* Each optional tag is emitted ONLY when its source value is non-empty --
* no empty `content=""` tags ever ship.
*/
export function buildSeoMeta ( options : ExportOptions , title : string ) : string {
const lines : string [] = [];
if ( options . noindex ) {
lines . push ( '<meta name="robots" content="noindex, nofollow">' );
}
lines . push ( `<title> ${ escapeHtml ( title ) } </title>` );
const description = options . description || '' ;
if ( description ) {
lines . push ( `<meta name="description" content=" ${ escapeAttr ( description ) } ">` );
}
const ogTitle = options . ogTitle || title ;
lines . push ( `<meta property="og:title" content=" ${ escapeAttr ( ogTitle ) } ">` );
if ( description ) {
lines . push ( `<meta property="og:description" content=" ${ escapeAttr ( description ) } ">` );
}
const ogImage = options . ogImage ? safeImageUrl ( options . ogImage ) : '' ;
if ( ogImage ) {
lines . push ( `<meta property="og:image" content=" ${ escapeAttr ( ogImage ) } ">` );
}
lines . push ( '<meta property="og:type" content="website">' );
if ( ogImage || description ) {
const card = options . twitterCard || ( ogImage ? 'summary_large_image' : 'summary' );
lines . push ( `<meta name="twitter:card" content=" ${ escapeAttr ( card ) } ">` );
}
const favicon = options . favicon ? safeImageUrl ( options . favicon ) : '' ;
if ( favicon ) {
lines . push ( `<link rel="icon" href=" ${ escapeAttr ( favicon ) } ">` );
}
return lines . join ( '\n ' );
}
2026-04-05 18:31:16 -07:00
const RESPONSIVE_CSS = `
@media (max-width: 768px) {
[style*="display: flex"][style*="flex-direction: row"],
[style*="display:flex"][style*="flex-direction:row"] {
flex-direction: column !important;
}
}` ;
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}` ;
const VISIBILITY_CSS = `
@media (min-width: 992px) { [data-hide-desktop] { display: none !important; } }
@media (min-width: 768px) and (max-width: 991px) { [data-hide-tablet] { display: none !important; } }
@media (max-width: 767px) { [data-hide-mobile] { display: none !important; } }` ;
const VISIBILITY_CSS_MINIFIED = `@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}` ;
const ANIMATION_CSS = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
[data-animation] { opacity: 0; }
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
[data-animation="fade-in"].animated { animation-name: fadeIn; }
[data-animation="slide-up"].animated { animation-name: slideUp; }
[data-animation="slide-left"].animated { animation-name: slideLeft; }
[data-animation="slide-right"].animated { animation-name: slideRight; }
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
[data-animation="bounce"].animated { animation-name: bounce; }` ;
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}` ;
const ANIMATION_SCRIPT = `<script>
document.querySelectorAll('[data-animation]').forEach(function(el) {
var delay = el.getAttribute('data-animation-delay');
if (delay) el.style.animationDelay = delay;
new IntersectionObserver(function(entries) {
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
}, { threshold: 0.1 }).observe(el);
});
</script>` ;
function wrapInDocument ( bodyHtml : string , options : ExportOptions ) : string {
const title = options . title || 'Untitled Page' ;
const minify = options . minifyCss !== false ;
const reset = minify ? CSS_RESET : CSS_RESET_PRETTY ;
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS ;
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS ;
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS ;
2026-07-14 07:14:56 -07:00
// §7 back-compat: even a caller that doesn't pass `design` (legacy call
// sites, e.g. tests not yet updated) still gets the token/base/a11y CSS --
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
// correct stand-in rather than skipping the block entirely.
const design = options . design || DEFAULT_SITE_DESIGN ;
const fonts = options . includeFonts !== false ? ` \ n ${ buildFontsLink ( design ) } ` : '' ;
2026-04-05 18:31:16 -07:00
const headCode = options . headCode ? ` \ n ${ options . headCode } ` : '' ;
2026-07-14 07:14:56 -07:00
const seoMeta = buildSeoMeta ( options , title );
const tokenCss = buildTokenCss ( design );
2026-04-05 18:31:16 -07:00
// Only include animation CSS + script if body contains data-animation
const hasAnimations = bodyHtml . includes ( 'data-animation' );
const animationBlock = hasAnimations ? animation : '' ;
const animationScript = hasAnimations ? ` \ n ${ ANIMATION_SCRIPT } ` : '' ;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2026-07-14 07:14:56 -07:00
${ seoMeta }${ fonts }
<style> ${ reset }${ responsive }${ visibility }${ animationBlock }${ tokenCss } </style> ${ headCode }
2026-04-05 18:31:16 -07:00
</head>
<body>
${ bodyHtml }${ animationScript }
</body>
</html>` ;
}
/**
* Export serialized Craft.js state to standalone HTML.
*/
/**
* Export as a full HTML document (for preview).
*/
export function exportToHtml (
serializedState : string ,
options : ExportOptions = {},
) : { html : string ; css : string } {
try {
const nodes = JSON . parse ( serializedState );
const { html : bodyHtml } = renderNode ( nodes , 'ROOT' );
const fullHtml = wrapInDocument ( bodyHtml , options );
return { html : fullHtml , css : '' };
} catch ( e ) {
console . error ( 'Export to HTML failed:' , e );
return {
html : wrapInDocument ( '<p>Export failed. Please try again.</p>' , options ),
css : '' ,
};
}
}
/**
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
*/
export function exportBodyHtml (
serializedState : string ,
) : { html : string ; css : string } {
try {
const nodes = JSON . parse ( serializedState );
const { html } = renderNode ( nodes , 'ROOT' );
return { html , css : '' };
} catch ( e ) {
console . error ( 'Body export failed:' , e );
return { html : '' , css : '' };
}
}