/** * 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 = { A_defaults: "\n\n\n \n \n Joe's Pizza & Grill\n \n \n \n \n \n \n \n", B_full: "\n\n\n \n \n \n About "Us"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", C_unknown_font: "\n\n\n \n \n Contact\n \n \n \n \n \n \n \n \n \n \n", D_body_font_and_blank_tokens: "\n\n\n \n \n Services\n \n \n \n \n \n \n \n", E_icons: "\n\n\n \n \n Icons\n \n \n \n \n \n \n \n \n \n", }; PINNED_HEAD.E_brand_swatches = "\n\n\n \n \n Brand\n \n \n \n \n \n \n \n"; PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults; const PINNED_BODY: Record = { A_defaults: "
", B_full: "
", C_unknown_font: "
", D_body_font_and_blank_tokens: "
", E_icons: "", E_brand_swatches: "
", F_swatches_no_brand: "
", }; const root = (props: Record) => 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 => ({ ...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 = { 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: '\n', description: "We're 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: '' }, }, // 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('') + ''.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, '

x

'); 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 = '
'; const bodyOnly = exportToHtml(state, { title: 'T', design: inter }).html; expect(bodyOnly).not.toContain('family=Merriweather'); const composed = exportToHtml(state, { title: 'T', design: inter, fontScanHtml: '
' + 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 = ''; const plainBody = root({}); const animatedHeader = '
H
'; 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('')); 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, '
H
' + 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 '
', headCode '', * default design, canonical https://example.com/notes * F: title 'Home', composed '
H
' (animation * only in the header), default design, canonical https://example.com/ */ const BACKEND_PINNED_HEAD: Record = { E_headcode_font: "\n\n\n \n \n Notes\n \n \n \n \n \n \n \n \n", F_header_animation: "\n\n\n \n \n Home\n \n \n \n \n \n \n \n \n", }; 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: '', }).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({}), '
', { title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes', headCode: '', }); 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({}), '
H
', { 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('

x

')).not.toContain('font-awesome'); }); test('v7 markup => all.min.css once, no shims', () => { const h = head(''); 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(''); expect([count(h, FA), count(h, SHIM)]).toEqual([1, 1]); }); test('order: fonts link, then Font Awesome, then