import { componentResolver } from '../components/resolver'; import { cssPropsToString } from './style-helpers'; import { escapeHtml } from './escape'; export interface ExportOptions { title?: string; includeFonts?: boolean; minifyCss?: boolean; headCode?: string; } 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="${props.animation}"`; if (props.animationDelay && props.animationDelay !== '0') { attrs += ` data-animation-delay="${props.animationDelay}"`; } } 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, 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); const tag = props.tag || 'div'; 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 = ` `; 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 = ``; 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; const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : ''; const headCode = options.headCode ? `\n ${options.headCode}` : ''; // 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 ` ${escapeHtml(title)}${fonts} ${headCode} ${bodyHtml}${animationScript} `; } /** * 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('

Export failed. Please try again.

', 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: '' }; } }