Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
460 lines
22 KiB
TypeScript
460 lines
22 KiB
TypeScript
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 <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;
|
|
}
|
|
|
|
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') {
|
|
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. `<img src="x" />`) the first `>` is
|
|
* preceded by a `/` -- naively inserting before the `>` produces the
|
|
* malformed `<img ... / data-animation="...">` (attrs land AFTER the
|
|
* self-close slash, outside the tag). Detect that trailing `/` and insert
|
|
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
|
|
* 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 (`<img ... />`): inserting before `>` would land
|
|
// the attrs after the `/`, outside the tag (`<img ... / data-x="y">`).
|
|
// 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<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);
|
|
|
|
// 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 = `<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">`;
|
|
|
|
// 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 ');
|
|
}
|
|
|
|
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(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; 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(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;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 = /^-?[0-9.]+$/.test(delay) ? delay + 's' : delay;
|
|
new IntersectionObserver(function(entries) {
|
|
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
|
}, { threshold: 0.1 }).observe(el);
|
|
});
|
|
</script>`;
|
|
|
|
// No-JS safety net (contract "No-JS safety"): un-hides animated elements
|
|
// when JS is disabled, so `[data-animation]{opacity:0}` never permanently
|
|
// hides content that the reveal script would otherwise never run for.
|
|
const ANIMATION_NOSCRIPT = `<noscript><style>[data-animation]{opacity:1}</style></noscript>`;
|
|
|
|
/**
|
|
* Returns the reveal `<script>` (byte-identical to the shared contract, and
|
|
* to the backend's `generateCompiledHTML` emission) when `bodyHtml` contains
|
|
* an animated element, else `''`. Single source of the script string so
|
|
* every caller (wrapInDocument's in-body emission, and TopBar's Preview
|
|
* body-replacement) stays in sync.
|
|
*/
|
|
export function buildAnimationScript(bodyHtml: string): string {
|
|
return bodyHtml.includes('data-animation') ? 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;
|
|
// §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)}` : '';
|
|
const headCode = options.headCode ? `\n ${options.headCode}` : '';
|
|
const seoMeta = buildSeoMeta(options, title);
|
|
const tokenCss = buildTokenCss(design);
|
|
|
|
// Only include animation CSS + noscript fallback + script if body contains
|
|
// data-animation (contract gate). `buildAnimationScript` is the single
|
|
// source of the reveal-script string -- TopBar's Preview body-replacement
|
|
// uses the same helper so the two emissions never drift apart.
|
|
const hasAnimations = bodyHtml.includes('data-animation');
|
|
const animationBlock = hasAnimations ? animation : '';
|
|
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
|
|
const revealScript = buildAnimationScript(bodyHtml);
|
|
const animationScript = revealScript ? `\n${revealScript}` : '';
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
${seoMeta}${fonts}
|
|
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
|
|
</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: '' };
|
|
}
|
|
}
|