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

@@ -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');
});
});