Merge fix/mobile-parity into release/sitesmith-colors

This commit is contained in:
shadowdao committed 2026-11-01 10:54:16 -08:00
commit f6b90d393f
14 files changed
+371 -19

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
"test:unit": "vitest run",
"test:browser:bg": "node scripts/e2e-background.mjs",
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
"test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
+73
View File
@@ -0,0 +1,73 @@
// Bundled by scripts/e2e-mobile-parity.mjs. Builds the documents the real-browser mobile test measures.
// Uses only exports that exist before and after the mobile-parity fix so the SAME harness can record a baseline.
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const LONG = 'Honest prices, straight answers and work that is finished when we say it will be, every time.';
/** ColumnLayout with `n` Container columns each holding one TextBlock. */
function addColumns(nodes: Record<string, any>, id: string, props: Record<string, unknown>, n: number) {
const linked: Record<string, string> = {};
for (let i = 0; i < n; i++) {
const c = `${id}-c${i}`;
const t = `${id}-t${i}`;
nodes[c] = node('Container', { tag: 'div', style: { padding: '8px' } }, [t], { parent: id });
nodes[t] = node('TextBlock', { text: `${LONG} (${id} col ${i + 1})`, style: { fontSize: '16px' } }, [], { parent: c });
linked[`col-${i}`] = c;
}
nodes[id] = node('ColumnLayout', { columns: n, gap: '16px', anchorId: id, ...props }, [], { parent: 'ROOT', linkedNodes: linked });
}
export const NAV_LINKS = ['Home', 'About us', 'Services', 'Our work', 'Pricing', 'Blog', 'Contact'].map((text, i, a) => ({
text, href: '#' + text.toLowerCase().replace(/\s/g, '-'), ...(i === a.length - 1 ? { isCta: true } : {}),
}));
export function syntheticState(navShowMobileMenu: boolean | undefined): string {
const nodes: Record<string, any> = {};
const kids: string[] = ['nav'];
const navProps: Record<string, unknown> = { logoText: 'Acme Plumbing & Heating', links: NAV_LINKS, padding: '16px 24px' };
if (navShowMobileMenu !== undefined) navProps.showMobileMenu = navShowMobileMenu;
nodes.nav = node('Navbar', navProps, [], { parent: 'ROOT' });
const specs: Array<[string, Record<string, unknown>, number]> = [
['c2', { split: '50-50' }, 2],
['centered', { split: '50-50', gap: '48px', style: { alignItems: 'center' } }, 2],
['c3', { split: '33-33-33' }, 3],
['c4', { split: '25-25-25-25' }, 4],
['c6', { split: '16-16-16-16-16-16' }, 6],
['sideL', { split: '30-70' }, 2],
['sideR', { split: '70-30' }, 2],
['optout', { split: '50-50', styleMobile: { flexDirection: 'row' } }, 2],
];
for (const [id, p, n] of specs) { addColumns(nodes, id, p, n); kids.push(id); }
// An existing author-built flex row (already stacks via the global rule) must be unaffected.
nodes.row = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['rt1', 'rt2'], { parent: 'ROOT' });
nodes.rt1 = node('TextBlock', { text: 'Row item one', style: {} }, [], { parent: 'row' });
nodes.rt2 = node('TextBlock', { text: 'Row item two', style: {} }, [], { parent: 'row' });
kids.push('row');
nodes.ROOT = node('Container', { tag: 'div', style: {} }, kids);
return JSON.stringify(nodes);
}
export function renderSynthetic(navShowMobileMenu: boolean | undefined): string {
const state = syntheticState(navShowMobileMenu);
const body = exportBodyHtml(state).html;
return exportComposedPreviewHtml(state, body, { title: 'Synthetic', includeFonts: false });
}
/** Stored OLS page rendered by the CURRENT frontend: regenerated body + current head. */
export function renderStored(project: any, pageIdx: number): string {
const page = project.pages_craft_state[pageIdx];
const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : '';
const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : '';
const body = exportBodyHtml(page.craftState).html;
return exportComposedPreviewHtml(page.craftState, hdr + body + ftr, { title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design });
}
/** The checked-in (OLD) stored body under the CURRENT head: "existing site, next publish". */
export function renderOldBodyNewHead(project: any, storedHtml: string): string {
const m = storedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const body = m ? m[1] : storedHtml;
return exportComposedPreviewHtml(project.pages_craft_state[0].craftState, body, { title: 'old', includeFonts: true, design: project.design });
}
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Real-browser regression test (Firefox AND Chromium, headless): published/Preview documents must
* stack multi-column layouts at phone width, must not overflow horizontally at 390px, and must lay
* out EXACTLY as before at desktop width.
*
* npm run test:browser:mobile
* MOBILE_PARITY_RECORD=<file> write a layout snapshot (run on the base commit to get a baseline)
* MOBILE_PARITY_BASELINE=<file> additionally assert desktop (1280) boxes equal that snapshot
*
* Env: CHROMIUM_PATH / FIREFOX_PATH, MOBILE_ONLY=chromium|firefox.
*/
import { chromium, firefox } from '@playwright/test';
import { build as esbuild } from 'esbuild';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fixDir = path.join(root, 'tests/fixtures/canvas-parity');
const out = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-mobile-parity-entry.ts')],
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
});
const tmp = path.join(os.tmpdir(), `wsb-mobile-${process.pid}.mjs`);
fs.writeFileSync(tmp, out.outputFiles[0].text);
const ent = await import(tmp);
fs.rmSync(tmp, { force: true });
const project = JSON.parse(fs.readFileSync(path.join(fixDir, 'ols.project.json'), 'utf8'));
const docs = {
'synthetic (navbar showMobileMenu unset = existing)': ent.renderSynthetic(undefined),
'synthetic (navbar showMobileMenu false)': ent.renderSynthetic(false),
'synthetic (navbar showMobileMenu true)': ent.renderSynthetic(true),
};
project.pages_craft_state.forEach((pg, i) => { docs[`ols/${pg.slug} (regenerated)`] = ent.renderStored(project, i); });
for (const slug of ['index', 'about', 'contact', 'brands']) {
docs[`ols/${slug} (stored body, current head)`] = ent.renderOldBodyNewHead(project, fs.readFileSync(path.join(fixDir, `ols.${slug}.html`), 'utf8'));
}
function measure() {
const vis = (el) => { const r = el.getBoundingClientRect(); return getComputedStyle(el).display !== 'none' && (r.width > 0 || r.height > 0); };
const els = [...document.body.querySelectorAll('*')].filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName));
const boxes = els.map((el, i) => {
const r = el.getBoundingClientRect();
return [i, el.tagName, Math.round(r.x * 10) / 10, Math.round(r.y * 10) / 10, Math.round(r.width * 10) / 10, Math.round(r.height * 10) / 10, getComputedStyle(el).display];
});
let maxRight = 0; let worst = '';
for (const el of els) { if (!vis(el)) continue; const r = el.getBoundingClientRect(); if (r.right > maxRight) { maxRight = r.right; worst = el.tagName + '.' + (el.className || '') + ':' + (el.textContent || '').slice(0, 20); } }
// Multi-column layouts: ColumnLayout wrappers (class cols_*) -> do their column children share a row?
const cols = [...document.querySelectorAll('[class^="cols_"]')].map((w) => {
const kids = [...w.children].filter((c) => !['STYLE', 'SCRIPT'].includes(c.tagName));
const tops = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top)));
return { id: w.id, n: kids.length, rows: tops.size, dir: getComputedStyle(w).flexDirection, minW: Math.min(...kids.map((k) => k.getBoundingClientRect().width)) };
});
const rowEls = [...document.querySelectorAll('div')].filter((d) => /flex-direction:\s*row/.test(d.getAttribute('style') || '') && /display:\s*flex/.test(d.getAttribute('style') || '') && !/^cols_/.test(d.className));
const rows = rowEls.map((d) => getComputedStyle(d).flexDirection);
const nav = document.querySelector('nav');
let navRight = 0;
if (nav) for (const el of [nav, ...nav.querySelectorAll('*')]) { if (vis(el)) navRight = Math.max(navRight, el.getBoundingClientRect().right); }
const navInfo = nav ? { w: nav.getBoundingClientRect().width, h: Math.round(nav.getBoundingClientRect().height), scrollW: nav.scrollWidth, hamburger: !!nav.querySelector('.navbar-hamburger') && getComputedStyle(nav.querySelector('.navbar-hamburger')).display } : null;
return { innerW: window.innerWidth, maxRight: Math.round(maxRight * 10) / 10, navRight: Math.round(navRight * 10) / 10, worst, cols, rows, navInfo, boxes };
}
const results = {};
let fail = 0;
const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } };
const baseline = process.env.MOBILE_PARITY_BASELINE ? JSON.parse(fs.readFileSync(process.env.MOBILE_PARITY_BASELINE, 'utf8')) : null;
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
results[name] = {};
for (const [docName, html] of Object.entries(docs)) {
results[name][docName] = {};
for (const w of [390, 1280]) {
const page = await browser.newPage({ viewport: { width: w, height: 900 } });
// Deterministic: no network (web fonts / CDN icons would race the measurement).
await page.route(/^https?:/, (r) => r.abort());
await page.setContent(html, { waitUntil: 'load' });
await page.waitForTimeout(50);
const m = await page.evaluate(measure);
await page.close();
results[name][docName][w] = m;
const tag = `[${name} ${w}] ${docName}`;
if (w === 390) {
// ols/index carries a customer HtmlBlock card grid that overflows on its own (out of scope here): for it,
// only the navbar is held to the phone width.
ok(m.navRight <= 390.5, `${tag}: navbar overflows, reaches x=${m.navRight}`);
if (!docName.startsWith('ols/index')) ok(m.maxRight <= 390.5, `${tag}: horizontal overflow, content reaches x=${m.maxRight} (${m.worst})`);
for (const c of m.cols) {
const optOut = c.id === 'optout';
if (optOut) ok(c.rows === 1 || c.n === 1, `${tag}: opt-out columns should stay side by side (${JSON.stringify(c)})`);
else ok(c.rows === c.n, `${tag}: columns not stacked (${JSON.stringify(c)})`);
}
}
if (w === 1280 && baseline) {
const b = baseline[name]?.[docName]?.[1280];
if (b) {
const sameCount = b.boxes.length === m.boxes.length;
ok(sameCount, `${tag}: element count ${b.boxes.length} -> ${m.boxes.length}`);
let diffs = 0;
if (sameCount) for (let i = 0; i < b.boxes.length; i++) {
const bb = b.boxes[i]; const mm = m.boxes[i];
if (bb.slice(2, 6).some((v, j) => Math.abs(v - mm[2 + j]) > 0.5) || bb[6] !== mm[6]) { if (diffs++ < 3) console.log(' diff', JSON.stringify(bb), JSON.stringify(mm)); }
}
ok(diffs === 0, `${tag}: ${diffs} element boxes changed at desktop`);
}
}
}
}
await browser.close();
}
const only = process.env.MOBILE_ONLY;
if (!only || only === 'chromium') await run('chromium', chromium, process.env.CHROMIUM_PATH);
if (!only || only === 'firefox') await run('firefox', firefox, process.env.FIREFOX_PATH);
if (process.env.MOBILE_PARITY_RECORD) fs.writeFileSync(process.env.MOBILE_PARITY_RECORD, JSON.stringify(results));
// Human-readable summary at 390.
for (const [b, per] of Object.entries(results)) {
for (const [d, r] of Object.entries(per)) {
const m = r[390];
console.log(`${b.padEnd(8)} 390 ${d.padEnd(55)} maxRight=${String(m.maxRight).padEnd(7)} cols(rows/n)=${m.cols.map((c) => c.rows + '/' + c.n).join(',') || '-'} nav=${m.navInfo ? m.navInfo.h + 'h/' + m.navInfo.hamburger + '/right=' + m.navRight : '-'}`);
}
}
console.log(fail ? `\n${fail} FAILED` : '\nall passed');
process.exit(fail ? 1 : 0);
@@ -195,3 +195,13 @@ describe('Navbar.toHtml design tokens (#44)', () => {
for (const hex of ['#3f3f46', '#e4e4e7']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
describe('Navbar mobile menu default', () => {
test('NEW Navbars (craft defaults) get the hamburger; a stored Navbar without the prop keeps its output', async () => {
const { Navbar } = await import('./Navbar');
expect((Navbar as any).craft.props.showMobileMenu).toBe(true);
const toHtml = (Navbar as any).toHtml;
expect(toHtml({}, '', 'n1').html).not.toContain('navbar-hamburger"');
expect(toHtml({ showMobileMenu: true }, '', 'n1').html).toContain('class="navbar-hamburger"');
});
});
+3 -1
View File
@@ -246,7 +246,9 @@ Navbar.craft = {
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
// On for NEW Navbars (links overflow a phone otherwise). The component's render default and toHtml
// stay falsy so a stored Navbar that never had the prop keeps its exact output.
showMobileMenu: true,
style: {
borderBottom: `1px solid ${tokenRef('border')}`,
},
@@ -86,3 +86,27 @@ describe('Columns mobile stacking: editor matches published', () => {
expect(editorStacksOnMobile(state)).toBe(true);
});
});
describe('Columns stack on phones BY DEFAULT (no flex-direction in the props)', () => {
test('published: wrapper carries the cols_ class the global head rule targets; mobile row override is the opt-out', () => {
const html = exportBodyHtml(colsState({ style: {} })).html;
expect(html).toMatch(/<div class="cols_[a-z0-9]+" id="cl"/);
expect(RESPONSIVE_CSS_MINIFIED).toContain('[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}');
const optOut = exportBodyHtml(colsState({ styleMobile: { flexDirection: 'row' } })).html;
expect(optOut).toMatch(/@media \(max-width:767px\)\{\[data-wsb-r="b-cl"\]\{flex-direction:row!important/);
});
test('editor mobile frame stacks default Columns, and a mobile row override un-stacks them', () => {
const stacksDefault = () => {
h = renderEditorHarness({
initialState: colsState({ style: {} }),
onRender: RenderNode,
wrap: (tree) => <DeviceProvider initialDevice="mobile"><div className="canvas-device-frame" data-device="mobile">{tree}</div></DeviceProvider>,
});
const el = h.container.querySelector('#cl') as HTMLElement;
return el.matches(editorMobileStackSelectors());
};
expect(stacksDefault()).toBe(true);
h?.unmount(); h = null;
expect(editorStacksOnMobile(colsState({ styleMobile: { flexDirection: 'row' } }))).toBe(false);
});
});
@@ -75,6 +75,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
data-wsb-cols=""
style={{
display: 'flex',
flexWrap: 'wrap',
@@ -53,10 +53,11 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
// override. Turning it off removes this device's override; if the columns
// would still inherit a stack (mobile under a stacked tablet), it writes
// 'row' instead so the toggle really un-stacks this device.
const stacked = style.flexDirection === 'column';
const inheritsStack =
device !== 'desktop' &&
resolveStyleForDevice(withoutOverride(nodeProps, device, 'flexDirection'), device).flexDirection === 'column';
// On mobile, Columns stack BY DEFAULT in the published head (`[class^="cols_"]` rule), so "no row override" = stacked.
const stacksByDefault = (dir: unknown) => isColumns && device === 'mobile' && !/^row/.test(String(dir ?? ''));
const stacked = style.flexDirection === 'column' || stacksByDefault(style.flexDirection);
const inheritedDir = resolveStyleForDevice(withoutOverride(nodeProps, device, 'flexDirection'), device).flexDirection;
const inheritsStack = device !== 'desktop' && (inheritedDir === 'column' || stacksByDefault(inheritedDir));
const toggleStack = () => setPropStyle('flexDirection', stacked ? (inheritsStack ? 'row' : '') : 'column');
return (
@@ -282,16 +282,30 @@ describe('setter routing + undo bursts', () => {
});
describe('Columns "Stack on this device"', () => {
test('hidden on desktop; on mobile it writes flexDirection:column to the mobile override only', () => {
test('hidden on desktop; on tablet it writes flexDirection:column to the tablet override only', () => {
mountPanel('desktop', ContainerStylePanel, 'cols');
expect(h!.container.querySelector('[data-testid="stack-columns"]')).toBeNull();
switchDevice('mobile');
switchDevice('tablet');
const box = h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement;
expect(box.checked).toBe(false);
click(box);
expect(props('cols').styleMobile).toEqual({ flexDirection: 'column' });
expect(props('cols').styleTablet).toEqual({ flexDirection: 'column' });
expect(props('cols').style.flexDirection).toBeUndefined();
click(h!.container.querySelector('[data-testid="stack-columns"]')!);
expect(props('cols').styleMobile).toEqual({});
expect(props('cols').styleTablet).toEqual({});
});
test('mobile: Columns stack by default (box checked); unchecking writes the row opt-out, re-checking writes column', () => {
mountPanel('desktop', ContainerStylePanel, 'cols');
switchDevice('mobile');
const box = () => h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement;
expect(box().checked).toBe(true);
click(box());
expect(props('cols').styleMobile).toEqual({ flexDirection: 'row' });
expect(box().checked).toBe(false);
click(box());
expect(props('cols').styleMobile).toEqual({ flexDirection: 'column' });
expect(props('cols').style.flexDirection).toBeUndefined();
});
test('un-stacking mobile under a stacked tablet writes row', () => {
+1 -1
View File
@@ -252,7 +252,7 @@ export const DEFAULT_HEADER_STATE = JSON.stringify({
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
showMobileMenu: true,
style: { borderBottom: '1px solid #e4e4e7' },
},
displayName: 'Navbar',
+7
View File
@@ -2366,6 +2366,13 @@ body {
flex-direction: column !important;
}
/* Columns stack on phones by default in the published head (`[class^="cols_"]`); a mobile `flexDirection:row`
override (resolved inline style) is the opt-out, exactly as published. */
.canvas-device-frame[data-device="mobile"] [data-wsb-cols]:not([style*="flex-direction: row"]) {
flex-direction: column !important;
align-items: stretch !important;
}
.canvas-device-frame:not([data-device="desktop"]) [data-fullwidth] {
width: 100% !important;
margin-left: 0 !important;
@@ -14,10 +14,11 @@ import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from
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>",
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}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}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}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}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}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>",
};
const PINNED_BODY: Record<string, string> = {
@@ -25,6 +26,7 @@ const PINNED_BODY: Record<string, string> = {
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>",
};
@@ -77,6 +79,10 @@ const CASES: Record<string, { state: string; opts: ExportOptions }> = {
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>' },
},
};
const headOf = (doc: string) => doc.slice(0, doc.indexOf('</head>') + '</head>'.length);
@@ -84,6 +90,8 @@ const headOf = (doc: string) => doc.slice(0, doc.indexOf('</head>') + '</head>'.
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`, () => {
@@ -159,8 +167,8 @@ describe('composed preview: animation head gate covers header+body+footer', () =
* 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}}@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}}@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>",
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}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}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', () => {
@@ -194,3 +202,44 @@ describe('Preview head === backend head for the head-code font scan and header/f
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) and wraps Navbar links, inside the 767px query only', 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('nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}');
expect(RESPONSIVE_CSS_MINIFIED.match(/@media/g)).toHaveLength(1);
});
});
+4 -1
View File
@@ -577,7 +577,10 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
const undo767 = (html: string) => {
const from = '@media(max-width:767px){[style*=';
expect(html.split(from).length).toBe(2);
return html.replace(from, '@media(max-width:768px){[style*=');
// mobile-parity then appended the ColumnLayout-stack and Navbar-wrap rules to that same media block.
const added = '[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}';
expect(html.split(added).length).toBe(2);
return html.replace(added, '').replace(from, '@media(max-width:768px){[style*=');
};
// Head parity (backend-followups) added html,body{overflow-x:clip} to the reset.
const undoClip = (html: string) => {
+38 -2
View File
@@ -391,15 +391,29 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
// `generateCompiledHTML` must emit the minified forms byte-for-byte.
const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS;
// Columns (ColumnLayout, every split incl. sidebar 30-70/70-30) have no inline `flex-direction:row`, so the
// first rule never matched them; `[class^="cols_"]` is the scopeId('cols') wrapper class, which also matches
// stored bodies saved before this rule existed. Specificity (0,1,0) < the first rule's (0,2,0) on purpose: a
// node's own `[data-wsb-r]` mobile `flexDirection:'row'` override (later in the document) still wins = the opt-out.
// Navbar fallback: a Navbar saved with showMobileMenu:false has no hamburger, so on a phone its links ran off the
// screen. Wrapping (plain, no !important, so a hamburger Navbar's own rules are untouched) keeps every link
// reachable without flipping anyone's saved setting. Same-class wrapper as `scopeId(..., 'nav')`.
const RESPONSIVE_CSS = `
@media (max-width: ${mobileMax}px) {
[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;
}
nav[class^="nav_"], nav[class^="nav_"] .navbar-links {
flex-wrap: wrap;
}
}`;
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[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}nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}`;
const VISIBILITY_CSS = `
@media (min-width: ${desktopMin}px) { [data-hide-desktop] { display: none !important; } }
@@ -453,6 +467,27 @@ export function buildAnimationScript(bodyHtml: string): string {
return bodyHtml.includes('data-animation') ? ANIMATION_SCRIPT : '';
}
// Font Awesome <link>s: byte-identical port of the backend's sb_font_awesome_link() (libs/SiteBuilderHead.php).
// Preview renders in a sandboxed srcdoc iframe, which does NOT inherit the editor page's Font Awesome, so
// without this every builder icon was blank in Preview but present once published.
const FONT_AWESOME_LINK = '<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">';
const FONT_AWESOME_SHIMS_LINK = '<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">';
const FA_TOK = '(?<![\\w-])';
const FA_ICON_RE = new RegExp(`\\sclass\\s*=\\s*(["'])(?:(?!\\1)[\\s\\S])*?${FA_TOK}(?:fa|fas|far|fab|fa-[\\w-]+)(?![\\w-])`, 'i');
const FA_LEGACY_RE = new RegExp(`\\sclass\\s*=\\s*(["'])(?:(?!\\1)[\\s\\S])*?${FA_TOK}fa(?![\\w-])`, 'i');
const FA_ALREADY_LINKED_RE = /<link\b[^>]*font-?awesome[^>]*>|@import[^;]*font-?awesome/i;
/** Font Awesome head links (each prefixed `\n `) or '' when the page has no icon / already links Font Awesome. */
export function buildFontAwesomeLinks(bodyHtml: string, headCode = ''): string {
if (!FA_ICON_RE.test(bodyHtml)) return '';
if (FA_ALREADY_LINKED_RE.test(bodyHtml + '\n' + headCode)) return '';
let out = `\n ${FONT_AWESOME_LINK}`;
if (FA_LEGACY_RE.test(bodyHtml)) out += `\n ${FONT_AWESOME_SHIMS_LINK}`;
return out;
}
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
const title = options.title || 'Untitled Page';
const minify = options.minifyCss !== false;
@@ -469,6 +504,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
// still gets its link), like generateCompiledHTML's $html . $css . $headCode.
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, (options.fontScanHtml ?? bodyHtml) + (options.headCode ?? ''))}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : '';
const faLinks = buildFontAwesomeLinks(options.fontScanHtml ?? bodyHtml, options.headCode ?? '');
const seoMeta = buildSeoMeta(options, title);
const tokenCss = buildTokenCss(design);
@@ -487,7 +523,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${seoMeta}${fonts}
${seoMeta}${fonts}${faLinks}
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
</head>
<body>