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}