Files
site-builder/craft/src/utils/html-export.head-parity.test.ts
T
shadowdaoandClaude Opus 5.5 bb4fb7548b Mobile guard: real-browser overflow suite, card-grid + HtmlBlock mobile hooks, wide custom HTML publish warning
- scripts/e2e-mobile-guard.mjs (npm run test:browser:mobile-guard): Chromium + Firefox at 360/390/768/1280 over
  exporter-built pages (navbar, columns, FeaturesGrid/Testimonials/NumberCounter/Gallery/Pricing/Reviews, big
  image, long word/URL, wide customer HtmlBlocks). Asserts body.scrollWidth and every visible box/text right edge
  (html,body{overflow-x:clip} hides overflow from documentElement.scrollWidth), stacking on phones, columns kept
  on tablet, navbar wrap, intentional overflow-x:auto still scrolls, and 1280 box + pixel identity with the hooks
  removed (optional cross-commit baseline).
- OLS home overflow was FeaturesGrid (inline repeat(3,1fr)), not an HtmlBlock. FeaturesGrid, Testimonials and
  NumberCounter grids carry data-wsb-grid; the <=767px head block makes them one column.
- HtmlBlock output is exported inside <div data-wsb-html style="display:contents"> (applied after attr
  injection, toHtml().html unchanged); on phones it becomes a max-width:100% sideways-scrolling box, media
  shrink, long words break. body{overflow-wrap:break-word} on phones. Navbar link wrap now up to 991px
  (8 links + long logo overflowed at 768).
- Head parity pins updated (mirror in WHP generateCompiledHTML); editor.css mirrors for the mobile frame.
- publish-checks: warn when an HtmlBlock's inline/<style> CSS sets widths, flex sizes or grids over 390px or a
  3+ column grid with no phone @media rule; says what is not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:58:39 -08:00

284 lines
40 KiB
TypeScript

/**
* 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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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>",
E_icons: "<!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>Icons</title>\n <meta property=\"og:title\" content=\"Icons\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/icons\">\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 <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css\" integrity=\"sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css\" integrity=\"sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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>",
};
PINNED_HEAD.E_brand_swatches = "<!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>Brand</title>\n <meta property=\"og:title\" content=\"Brand\">\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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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;--wsb-brand-1:#FFcc00;--wsb-brand-3:#0A7}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>";
PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults;
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>",
E_icons: "<i class=\"fa fa-star\"></i><i class=\"fa-solid fa-star\"></i>",
E_brand_swatches: "<div></div>",
F_swatches_no_brand: "<div></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 ' }),
},
},
E_icons: {
state: root({}),
opts: { title: 'Icons', includeFonts: true, canonicalUrl: U + 'icons', fontScanHtml: '<i class="fa fa-star"></i><i class="fa-solid fa-star"></i>' },
},
// Brand colours (design.swatches): same inputs as the WHP script's E / F cases.
E_brand_swatches: {
state: root({}),
opts: {
title: 'Brand', includeFonts: true, canonicalUrl: U,
design: merged({ swatches: [
{ id: 'a', label: 'Sea', hex: '#0A7', brand: true, slot: 3 },
{ id: 'b', label: 'Sun', hex: '#FFcc00', brand: true, slot: 1 },
{ id: 'c', label: 'Grey', hex: '#888888', brand: false },
{ id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 },
] }),
},
},
F_swatches_no_brand: {
state: root({}),
opts: {
title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U,
design: merged({ swatches: [
{ id: 'c', label: 'Grey', hex: '#888888', brand: false },
{ id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 },
{ id: 'e', label: 'Far', hex: '#123456', brand: true, slot: 7 },
] }),
},
},
};
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`, () => {
// E_icons feeds the icon markup through fontScanHtml (the composed page), as the PHP case passes it as the body.
if (name === 'E_icons') return expect(opts.fontScanHtml).toBe(PINNED_BODY[name]);
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');
});
});
/**
* Minor finding: Preview gated the animation CSS + noscript on the PAGE BODY
* only, while generateCompiledHTML gates on the composed header+body+footer
* ($hasAnim = strpos($html,'data-animation') over $composedBody). An animated
* element only in the header/footer therefore had no keyframes/noscript in
* Preview but did once published.
*/
describe('composed preview: animation head gate covers header+body+footer', () => {
const ANIM_CSS_MARK = '[data-animation]{opacity:0}';
const NOSCRIPT = '<noscript><style>[data-animation]{opacity:1}</style></noscript>';
const plainBody = root({});
const animatedHeader = '<header data-animation="fade-in">H</header>';
test('animation only in the header -> head has the animation CSS + noscript, one reveal script', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const bodyHtml = exportBodyHtml(plainBody).html;
const html = exportComposedPreviewHtml(plainBody, animatedHeader + bodyHtml, { title: 'T' });
const head = html.slice(0, html.indexOf('</head>'));
expect(head).toContain(ANIM_CSS_MARK);
expect(head).toContain(NOSCRIPT);
expect(html.split("document.querySelectorAll('[data-animation]')").length - 1).toBe(1);
expect(html).toContain(animatedHeader);
});
test('no animation anywhere -> no animation CSS/noscript/script', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(plainBody, '<header>H</header>' + exportBodyHtml(plainBody).html, { title: 'T' });
expect(html).not.toContain(ANIM_CSS_MARK);
expect(html).not.toContain(NOSCRIPT);
expect(html).not.toContain("querySelectorAll('[data-animation]')");
});
});
/**
* Backend final-fix wave: generateCompiledHTML now (a) scans headCode for preset
* fonts and (b) gates the animation CSS on the composed header+body+footer.
* These two heads were printed by the BACKEND's generateCompiledHTML() (read-only
* sb-backend worktree) for the inputs below and are pinned verbatim; the Preview
* (exportToHtml / exportComposedPreviewHtml) must emit the same bytes.
* E: title 'Notes', body '<div></div>', headCode '<style>.x{font-family:Merriweather,serif}</style>',
* default design, canonical https://example.com/notes
* F: title 'Home', composed '<header data-animation="fade-in">H</header><div></div>' (animation
* only in the header), default design, canonical https://example.com/
*/
const BACKEND_PINNED_HEAD: Record<string, string> = {
E_headcode_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>Notes</title>\n <meta property=\"og:title\" content=\"Notes\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/notes\">\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=Merriweather:wght@300;400;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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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 <style>.x{font-family:Merriweather,serif}</style>\n</head>",
F_header_animation: "<!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>Home</title>\n <meta property=\"og:title\" content=\"Home\">\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}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@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:#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 <noscript><style>[data-animation]{opacity:1}</style></noscript>\n</head>",
};
describe('Preview head === backend head for the head-code font scan and header/footer animation gate', () => {
test('E: a preset font named only in head code is in the Preview fonts link', () => {
const html = exportToHtml(root({}), {
title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes',
headCode: '<style>.x{font-family:Merriweather,serif}</style>',
}).html;
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font);
});
test('E (composed path, as TopBar Preview uses it) gives the same head', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(root({}), '<div></div>', {
title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes',
headCode: '<style>.x{font-family:Merriweather,serif}</style>',
});
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font);
});
test('without head code naming it, Merriweather is NOT linked (the scan is what adds it)', () => {
const html = exportToHtml(root({}), { title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes' }).html;
expect(headOf(html)).not.toContain('family=Merriweather');
});
test('F: animation only in the header -> identical head (keyframes + noscript)', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(root({}), '<header data-animation="fade-in">H</header><div></div>', {
title: 'Home', includeFonts: true, canonicalUrl: U,
});
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.F_header_animation);
});
});
// Font Awesome links: same decisions as the backend's sb_font_awesome_link() (scripts/test-site-builder-head-parity.php "FA:" block).
describe('Font Awesome head links (Preview === Published)', () => {
const FA = 'font-awesome/7.3.1/css/all.min.css';
const SHIM = 'font-awesome/7.3.1/css/v4-shims.min.css';
const count = (s: string, needle: string) => s.split(needle).length - 1;
const head = (body: string, headCode = '') => headOf(exportToHtml(root({}), { title: 'T', includeFonts: true, fontScanHtml: body, headCode }).html);
test('no icon => no link', () => { expect(head('<p>x</p>')).not.toContain('font-awesome'); });
test('v7 markup => all.min.css once, no shims', () => {
const h = head('<i class="fa-brands fa-facebook"></i><i class="fa-solid fa-star"></i>');
expect([count(h, FA), count(h, SHIM)]).toEqual([1, 0]);
});
test('legacy `fa fa-star` => all.min.css + v4-shims once each', () => {
const h = head('<i class="fa fa-facebook"></i><i class="fa fa-star-o"></i>');
expect([count(h, FA), count(h, SHIM)]).toEqual([1, 1]);
});
test('order: fonts link, then Font Awesome, then <style>', () => {
expect(head('<i class="fa-solid fa-star"></i>')).toMatch(/fonts\.googleapis\.com\/css2[^\n]*\n <link rel="stylesheet" href="https:\/\/cdnjs[^\n]*\n <style>/);
});
test('single-quoted class, fa-* only token and fas shorthand count as icons', () => {
expect(count(head("<i class='fa-star'></i>"), FA)).toBe(1);
expect(count(head('<i class="fas fa-star"></i>'), FA)).toBe(1);
});
test('"fast"/"fa_x"/"my-fa-thing"/"data-fa" are not icons', () => {
expect(head('<p class="fast fa_x my-fa-thing">x</p><b data-fa="1">y</b>')).not.toContain('font-awesome');
});
test('page or head code that already links Font Awesome is left alone', () => {
expect(head('<link rel="stylesheet" href="https://x/font-awesome.css"><i class="fa fa-star"></i>')).not.toContain('cdnjs.cloudflare.com');
expect(head('<i class="fa fa-star"></i>', '<link rel="stylesheet" href="//use.fontawesome.com/x.css">')).not.toContain('cdnjs.cloudflare.com');
});
});
describe('mobile stacking + navbar wrap head rules', () => {
test('RESPONSIVE_CSS_MINIFIED stacks ColumnLayout (cols_ wrapper) inside the 767px query and wraps Navbar links up to tablet (991px)', async () => {
const { RESPONSIVE_CSS_MINIFIED } = await import('./html-export');
expect(RESPONSIVE_CSS_MINIFIED).toContain('[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}');
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}');
expect(RESPONSIVE_CSS_MINIFIED.match(/@media/g)).toHaveLength(2);
});
test('mobile guard: card-grid hook, long-word wrap and the HtmlBlock net sit inside the 767px query only', async () => {
const { RESPONSIVE_CSS_MINIFIED } = await import('./html-export');
const mobile = /^@media\(max-width:767px\)\{(.*)\}@media\(max-width:991px\)/.exec(RESPONSIVE_CSS_MINIFIED)![1];
expect(mobile).toContain('[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}');
expect(mobile).toContain('body{overflow-wrap:break-word}');
expect(mobile).toContain('[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}');
expect(RESPONSIVE_CSS_MINIFIED.split('data-wsb-').length - 1).toBe(mobile.split('data-wsb-').length - 1);
});
});