fix(export): Preview head parity with the published head

Frontend half of the backend-followups head parity work:
- CSS reset gains html,body{overflow-x:clip} (no phantom h-scroll, sticky
  navbar still works)
- buildFontsLink adds per-element preset fonts found in the page HTML
  (fontScanHtml option, defaults to the body); own-key preset lookup so
  names like 'constructor' behave like the backend's isset
- blank/whitespace design tokens fall back to the defaults
- description/ogTitle/twitterCard are trimmed
- history snapshot preview scans header+body+footer and trims metaTitle

Re-baselines the two TOKEN_VARS-move document hashes (the clip rule is
proven to be the only change) and pins the four complete heads from WHP
scripts/test-site-builder-head-parity.php.

Refs #54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:41:36 -07:00
1 parent 8227d768e6
commit db54341006
5 files changed
+177 -14

No files matched your search

+12
View File
@@ -113,4 +113,16 @@ describe('buildSnapshotPreviewHtml', () => {
test('throws for a missing page index', () => {
expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow();
});
test('head parity: fonts are scanned from header+body+footer, and metaTitle is trimmed', () => {
const footer = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: { fontFamily: 'Merriweather, serif' } }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null },
});
const html = buildSnapshotPreviewHtml(
{ pages_craft_state: [{ id: 'a', name: 'Page A', slug: 'index', craftState: state('Body'), seo: { metaTitle: ' ' } }], footer_craft_state: footer },
0,
'https://panel.example',
);
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
expect(html).toContain('<title>Page A</title>');
});
});
+3 -1
View File
@@ -143,7 +143,7 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
const seo = page.seo;
let html = exportToHtml(state, {
title: seo?.metaTitle || page.name || 'Preview',
title: seo?.metaTitle?.trim() || page.name || 'Preview',
includeFonts: true,
headCode: fullDesign.headCode,
description: seo?.metaDescription,
@@ -153,6 +153,8 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
noindex: seo?.noindex,
favicon: fullDesign.favicon,
design: fullDesign,
// Fonts: scan header+body+footer like the published head does (head parity).
fontScanHtml: composedBody,
}).html;
// Function replacer: user content may contain `$&` / `$1`.
@@ -0,0 +1,114 @@
/**
* Head parity, frontend half (backend-followups / integration-followups item 14).
*
* The PINNED_HEAD strings below are copied VERBATIM from WHP
* scripts/test-site-builder-head-parity.php ($FE_HEAD_*), which asserts that the
* backend generateCompiledHTML() emits exactly these bytes for the same inputs.
* This test asserts that the frontend Preview head (exportToHtml/wrapInDocument)
* emits the SAME bytes, so Preview head === Published head is pinned from both
* sides. Never hand-edit these strings: if wrapInDocument changes, regenerate them
* here AND in the WHP script together.
*/
import { describe, expect, test } from 'vitest';
import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from './html-export';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
const PINNED_HEAD: Record<string, string> = {
A_defaults: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Joe's Pizza &amp; Grill</title>\n <meta property=\"og:title\" content=\"Joe&#39;s Pizza &amp; Grill\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@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}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}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)}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}}</style>\n</head>",
B_full: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta name=\"robots\" content=\"noindex, nofollow\">\n <title>About &quot;Us&quot;</title>\n <meta name=\"description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:title\" content=\"About Joe&#39;s\">\n <meta property=\"og:description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:image\" content=\"https://cdn.example.com/og.png\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">\n <link rel=\"canonical\" href=\"https://example.com/about\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Roboto:wght@300;400;500;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@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}}@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}:root{--wsb-primary:#ff0066;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:12px;--wsb-heading-font:Playfair Display, serif;--wsb-body-font:Roboto, sans-serif;--wsb-button-font:Roboto, sans-serif;--wsb-button-radius:999px}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)}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}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-X\"></script>\n<meta name=\"google-site-verification\" content=\"abc123\">\n</head>",
C_unknown_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Contact</title>\n <meta name=\"description\" content=\"Get in touch\">\n <meta property=\"og:title\" content=\"Contact\">\n <meta property=\"og:description\" content=\"Get in touch\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"canonical\" href=\"https://example.com/contact\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <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\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@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}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Comic Sans MS, cursive;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}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)}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}}</style>\n</head>",
D_body_font_and_blank_tokens: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Services</title>\n <meta property=\"og:title\" content=\"Services\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/services\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@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}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#111111;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}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)}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}}</style>\n</head>",
};
const PINNED_BODY: Record<string, string> = {
A_defaults: "<div></div>",
B_full: "<div data-animation=\"fade-in\" data-animation-delay=\"0.3\"></div>",
C_unknown_font: "<div></div>",
D_body_font_and_blank_tokens: "<div style=\"font-family:Montserrat, sans-serif\"></div>",
};
const root = (props: Record<string, unknown>) => JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {}, ...props },
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
},
});
const U = 'https://example.com/';
const merged = (partial: Partial<SiteDesign>): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...partial });
// Same inputs as the WHP script's $cases. `title` is the RESOLVED title
// (seo.metaTitle || page title), as the Preview callers pass it.
const CASES: Record<string, { state: string; opts: ExportOptions }> = {
A_defaults: {
state: root({}),
opts: { title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U },
},
B_full: {
state: root({ animation: 'fade-in', animationDelay: '0.3' }),
opts: {
title: 'About "Us"',
includeFonts: true,
headCode: '<script async src="https://www.googletagmanager.com/gtag/js?id=G-X"></script>\n<meta name="google-site-verification" content="abc123">',
description: "We're <the> best & \"only\" bakery",
ogTitle: "About Joe's",
ogImage: 'https://cdn.example.com/og.png',
twitterCard: 'summary',
noindex: true,
favicon: 'https://cdn.example.com/favicon.ico',
canonicalUrl: U + 'about',
design: merged({
headingFont: 'Playfair Display, serif', bodyFont: 'Roboto, sans-serif', buttonFont: 'Roboto, sans-serif',
primaryColor: '#ff0066', borderRadius: '12px', buttonRadius: '999px',
}),
},
},
C_unknown_font: {
state: root({}),
opts: {
title: 'Contact', includeFonts: true, description: 'Get in touch', canonicalUrl: U + 'contact',
design: merged({ headingFont: 'Comic Sans MS, cursive' }),
},
},
D_body_font_and_blank_tokens: {
state: root({ style: { fontFamily: 'Montserrat, sans-serif' } }),
opts: {
title: 'Services', includeFonts: true, description: ' ', ogTitle: ' ', canonicalUrl: U + 'services',
design: merged({ accentColor: '', textColor: ' #111111 ' }),
},
},
};
const headOf = (doc: string) => doc.slice(0, doc.indexOf('</head>') + '</head>'.length);
describe('Preview head === pinned published head (WHP test-site-builder-head-parity.php)', () => {
for (const [name, { state, opts }] of Object.entries(CASES)) {
test(`${name}: body input matches the backend case`, () => {
expect(exportBodyHtml(state).html).toBe(PINNED_BODY[name]);
});
test(`${name}: COMPLETE head is byte-identical`, () => {
expect(headOf(exportToHtml(state, opts).html)).toBe(PINNED_HEAD[name]);
});
}
});
describe('buildFontsLink per-element presets (item 14/15)', () => {
const inter = merged({});
test('a Montserrat element under an all-Inter design adds Montserrat to the subset', () => {
const link = buildFontsLink(inter, '<p style="font-family:Montserrat, sans-serif">x</p>');
expect(link).toContain('css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap');
});
test('fontScanHtml (composed header+body+footer) is what gets scanned, not just the body', () => {
const state = root({});
const footer = '<footer style="font-family:Merriweather, serif"></footer>';
const bodyOnly = exportToHtml(state, { title: 'T', design: inter }).html;
expect(bodyOnly).not.toContain('family=Merriweather');
const composed = exportToHtml(state, { title: 'T', design: inter, fontScanHtml: '<div></div>' + footer }).html;
expect(composed).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
});
test('an inherited Object.prototype name (constructor) is NOT a preset -> full link, like PHP isset', () => {
const link = buildFontsLink(merged({ headingFont: 'constructor, serif' }));
expect(link).toContain('family=Source+Code+Pro'); // the full 8-font link
expect(link).not.toContain('undefined');
});
});
+18 -4
View File
@@ -579,11 +579,22 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
expect(html.split(from).length).toBe(2);
return html.replace(from, '@media(max-width:768px){[style*=');
};
// Head parity (backend-followups) added html,body{overflow-x:clip} to the reset.
const undoClip = (html: string) => {
const clip = 'html,body{overflow-x:clip}';
expect(html.split(clip).length).toBe(2);
return html.replace(clip, '');
};
test('default design document hash matches the pre-move baseline', async () => {
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
expect(html.length).toBe(2164);
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
// Re-baselined for the head-parity reset rule html,body{overflow-x:clip}.
expect(html.length).toBe(2190);
expect(await sha(html)).toBe('d180b901f8e06ea7d23e195f24cc7f37658ba3cc31d16ecb941a88bbe3ad917b');
// ...and that rule is the ONLY change: removing it gives the pre-move bytes.
const pre = undoClip(html);
expect(pre.length).toBe(2164);
expect(await sha(pre)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
});
test('custom + hostile design document hash matches the pre-move baseline', async () => {
@@ -594,8 +605,11 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
accentColor: 'red}</style><script>',
};
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
expect(html.length).toBe(2452);
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
expect(html.length).toBe(2478);
expect(await sha(html)).toBe('cb23ffb02d0e85085f31dce530a9c2bfc2328c63d9a8b9f67737d1b5624dae5d');
const pre = undoClip(html);
expect(pre.length).toBe(2452);
expect(await sha(pre)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
});
test('default :root token block is the exact pre-move string', () => {
+30 -9
View File
@@ -27,6 +27,10 @@ export interface ExportOptions {
/** Absolute page URL for `<link rel="canonical">` (#54): `https://<fqdn>/` for the
* landing page, `https://<fqdn>/<slug>` otherwise (no `.html`). Omitted => no tag. */
canonicalUrl?: string;
/** HTML scanned for per-element preset fonts (TextStylePanel fontFamily); defaults to
* the body. Preview passes the composed header+body+footer so it matches the
* published head, which scans all three (head parity). */
fontScanHtml?: 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. */
@@ -190,7 +194,7 @@ function renderNode(
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 = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}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;
@@ -198,6 +202,10 @@ const CSS_RESET_PRETTY = `*, *::before, *::after {
padding: 0;
}
html, body {
overflow-x: clip;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
@@ -250,14 +258,22 @@ function extractFontName(value: string | undefined): string {
* 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 {
export function buildFontsLink(design?: SiteDesign, scanHtml = ''): 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;
// Own-key check (not truthiness) so a name like `constructor` is "not a preset",
// exactly like the backend's isset(SB_GOOGLE_FONT_PARAMS[$f]). (Object.hasOwn
// semantics; spelled via hasOwnProperty.call because tsconfig lib is ES2020.)
if (families.some((f) => !Object.prototype.hasOwnProperty.call(GOOGLE_FONT_PARAMS, f))) return GOOGLE_FONTS_LINK;
// Per-element presets (TextStylePanel fontFamily) the page actually uses, in
// preset order -- mirrors sb_fonts_link() in WHP's SiteBuilderHead.php.
for (const f of Object.keys(GOOGLE_FONT_PARAMS)) {
if (!families.includes(f) && scanHtml.includes(f)) families.push(f);
}
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]);
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">`;
@@ -286,7 +302,12 @@ const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,
* element itself.
*/
export function buildTokenCss(design: SiteDesign): string {
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
// A blank/whitespace token falls back to its default: an empty custom property
// would invalidate every var() that reads it (mirrors the backend sb_token_css).
const vars = TOKEN_VARS.map(([varName, key]) => {
const raw = String(design[key] ?? '').trim();
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
}).join(';');
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
}
@@ -312,12 +333,12 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
lines.push(`<title>${escapeHtml(title)}</title>`);
const description = options.description || '';
const description = (options.description || '').trim();
if (description) {
lines.push(`<meta name="description" content="${escapeAttr(description)}">`);
}
const ogTitle = options.ogTitle || title;
const ogTitle = (options.ogTitle || '').trim() || title;
lines.push(`<meta property="og:title" content="${escapeAttr(ogTitle)}">`);
if (description) {
@@ -332,7 +353,7 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
lines.push('<meta property="og:type" content="website">');
if (ogImage || description) {
const card = options.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
const card = (options.twitterCard || '').trim() || (ogImage ? 'summary_large_image' : 'summary');
lines.push(`<meta name="twitter:card" content="${escapeAttr(card)}">`);
}
@@ -428,7 +449,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
// 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 fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, options.fontScanHtml ?? bodyHtml)}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : '';
const seoMeta = buildSeoMeta(options, title);
const tokenCss = buildTokenCss(design);