From db543410063eea34fa2c10feefc33c5eb0a7c003 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:41:36 -0700 Subject: [PATCH 01/10] fix(export): Preview head parity with the published head Frontend half of the backend-followups head parity work: - CSS reset gains html,body{overflow-x:clip} (no phantom h-scroll, sticky navbar still works) - buildFontsLink adds per-element preset fonts found in the page HTML (fontScanHtml option, defaults to the body); own-key preset lookup so names like 'constructor' behave like the backend's isset - blank/whitespace design tokens fall back to the defaults - description/ogTitle/twitterCard are trimmed - history snapshot preview scans header+body+footer and trims metaTitle Re-baselines the two TOKEN_VARS-move document hashes (the clip rule is proven to be the only change) and pins the four complete heads from WHP scripts/test-site-builder-head-parity.php. Refs #54 Co-Authored-By: Claude Opus 5.5 --- craft/src/utils/history-restore.test.ts | 12 ++ craft/src/utils/history-restore.ts | 4 +- .../src/utils/html-export.head-parity.test.ts | 114 ++++++++++++++++++ craft/src/utils/html-export.test.ts | 22 +++- craft/src/utils/html-export.ts | 39 ++++-- 5 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 craft/src/utils/html-export.head-parity.test.ts diff --git a/craft/src/utils/history-restore.test.ts b/craft/src/utils/history-restore.test.ts index 1b826c3..f6a9d59 100644 --- a/craft/src/utils/history-restore.test.ts +++ b/craft/src/utils/history-restore.test.ts @@ -113,4 +113,16 @@ describe('buildSnapshotPreviewHtml', () => { test('throws for a missing page index', () => { expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow(); }); + test('head parity: fonts are scanned from header+body+footer, and metaTitle is trimmed', () => { + const footer = JSON.stringify({ + ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: { fontFamily: 'Merriweather, serif' } }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null }, + }); + const html = buildSnapshotPreviewHtml( + { pages_craft_state: [{ id: 'a', name: 'Page A', slug: 'index', craftState: state('Body'), seo: { metaTitle: ' ' } }], footer_craft_state: footer }, + 0, + 'https://panel.example', + ); + expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap'); + expect(html).toContain('Page A'); + }); }); diff --git a/craft/src/utils/history-restore.ts b/craft/src/utils/history-restore.ts index 64e620c..7ac8509 100644 --- a/craft/src/utils/history-restore.ts +++ b/craft/src/utils/history-restore.ts @@ -143,7 +143,7 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number, const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign; const seo = page.seo; let html = exportToHtml(state, { - title: seo?.metaTitle || page.name || 'Preview', + title: seo?.metaTitle?.trim() || page.name || 'Preview', includeFonts: true, headCode: fullDesign.headCode, description: seo?.metaDescription, @@ -153,6 +153,8 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number, noindex: seo?.noindex, favicon: fullDesign.favicon, design: fullDesign, + // Fonts: scan header+body+footer like the published head does (head parity). + fontScanHtml: composedBody, }).html; // Function replacer: user content may contain `$&` / `$1`. diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts new file mode 100644 index 0000000..75b0b0d --- /dev/null +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -0,0 +1,114 @@ +/** + * Head parity, frontend half (backend-followups / integration-followups item 14). + * + * The PINNED_HEAD strings below are copied VERBATIM from WHP + * scripts/test-site-builder-head-parity.php ($FE_HEAD_*), which asserts that the + * backend generateCompiledHTML() emits exactly these bytes for the same inputs. + * This test asserts that the frontend Preview head (exportToHtml/wrapInDocument) + * emits the SAME bytes, so Preview head === Published head is pinned from both + * sides. Never hand-edit these strings: if wrapInDocument changes, regenerate them + * here AND in the WHP script together. + */ +import { describe, expect, test } from 'vitest'; +import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from './html-export'; +import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext'; + +const PINNED_HEAD: Record = { + 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", +}; + +const PINNED_BODY: Record = { + A_defaults: "
", + B_full: "
", + C_unknown_font: "
", + D_body_font_and_blank_tokens: "
", +}; + + +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 ' }), + }, + }, +}; + +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`, () => { + 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'); + }); +}); diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index bb842e2..d61bba0 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -579,11 +579,22 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () => expect(html.split(from).length).toBe(2); return html.replace(from, '@media(max-width:768px){[style*='); }; + // Head parity (backend-followups) added html,body{overflow-x:clip} to the reset. + const undoClip = (html: string) => { + const clip = 'html,body{overflow-x:clip}'; + expect(html.split(clip).length).toBe(2); + return html.replace(clip, ''); + }; test('default design document hash matches the pre-move baseline', async () => { const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html); - expect(html.length).toBe(2164); - expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6'); + // Re-baselined for the head-parity reset rule html,body{overflow-x:clip}. + expect(html.length).toBe(2190); + expect(await sha(html)).toBe('d180b901f8e06ea7d23e195f24cc7f37658ba3cc31d16ecb941a88bbe3ad917b'); + // ...and that rule is the ONLY change: removing it gives the pre-move bytes. + const pre = undoClip(html); + expect(pre.length).toBe(2164); + expect(await sha(pre)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6'); }); test('custom + hostile design document hash matches the pre-move baseline', async () => { @@ -594,8 +605,11 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () => accentColor: 'red}