fix(preview): gate head animation CSS/noscript on composed header+body+footer
Preview (TopBar handlePreview + history snapshot preview) gated the animation CSS and no-JS fallback on the page body only, while the published head (generateCompiledHTML) gates on the composed header+body+footer. Add exportComposedPreviewHtml (head scanned over composed content for fonts and animation; body replaced with composed HTML + one reveal script) and use it from both Preview paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2d18b7f4b
commit
faf129c509
5 files changed
+86
-37
No files matched your search
@@ -274,7 +274,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
const win = window.open('', '_blank');
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportToHtml, exportBodyHtml, buildAnimationScript }, { openSandboxedPreview }]) => {
|
||||
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openSandboxedPreview }]) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
@@ -295,18 +295,11 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
// Compose full page: header + body + footer. `handlePreview` below
|
||||
// replaces the ENTIRE wrapped-doc `<body>` inner (including the
|
||||
// in-body reveal `<script>` wrapInDocument already emitted) with
|
||||
// this composed string, so the script would otherwise be clobbered
|
||||
// and animated elements would stay hidden forever
|
||||
// ([data-animation]{opacity:0} with no IntersectionObserver to ever
|
||||
// add `.animated`). Re-append the reveal script here -- built from
|
||||
// the SAME composed content it will end up living alongside -- so
|
||||
// it survives the replacement below and fires exactly once.
|
||||
const composedBody =
|
||||
headerHtml + bodyHtml + footerHtml +
|
||||
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
|
||||
// Compose full page: header + body + footer. exportComposedPreviewHtml
|
||||
// builds the head over this composed content (fonts + animation gate,
|
||||
// like the published head) and swaps it in as the body with ONE reveal
|
||||
// script, so animated elements in header/footer still reveal.
|
||||
const composedHtml = headerHtml + bodyHtml + footerHtml;
|
||||
|
||||
// PKG-H: fold the active page's own SEO overrides + the site-wide
|
||||
// design tokens/favicon into the Preview export so editor Preview
|
||||
@@ -317,7 +310,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
const seo = activePage?.seo;
|
||||
const title = seo?.metaTitle?.trim() || activePage?.name || whpConfig?.siteName || 'Preview';
|
||||
|
||||
const result = exportToHtml(serialized, {
|
||||
let html = exportComposedPreviewHtml(serialized, composedHtml, {
|
||||
title,
|
||||
includeFonts: true,
|
||||
headCode: design.headCode,
|
||||
@@ -332,20 +325,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}`
|
||||
: undefined,
|
||||
design,
|
||||
// Fonts: scan header+body+footer like the published head does (head parity).
|
||||
fontScanHtml: composedBody,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version.
|
||||
// Use a function replacer -- a plain string replacer treats
|
||||
// `$&`/`$1`/`$$` sequences in user content as replacement
|
||||
// patterns, silently corrupting the output.
|
||||
let html = result.html;
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (bodyMatch) {
|
||||
html = html.replace(bodyMatch[1], () => composedBody);
|
||||
}
|
||||
|
||||
// Make proxy URLs absolute so they resolve from the sandboxed (opaque-origin) frame.
|
||||
const origin = window.location.origin;
|
||||
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
|
||||
|
||||
@@ -125,6 +125,20 @@ describe('buildSnapshotPreviewHtml', () => {
|
||||
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
|
||||
expect(html).toContain('<title>Page A</title>');
|
||||
});
|
||||
test('head parity: animation CSS + noscript gate on header+body+footer (animated footer only)', () => {
|
||||
const footer = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { animation: 'fade-in' }, 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') }], footer_craft_state: footer },
|
||||
0,
|
||||
'https://panel.example',
|
||||
);
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
expect(head).toContain('[data-animation]{opacity:0}');
|
||||
expect(head).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
|
||||
expect(html.split("document.querySelectorAll('[data-animation]')").length - 1).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageData, PageNav, PageSeo } from '../types';
|
||||
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml, exportToHtml, buildAnimationScript } from './html-export';
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from './html-export';
|
||||
|
||||
/**
|
||||
* Pure helpers for version history (#51). A snapshot is the gunzipped previous
|
||||
@@ -150,12 +150,13 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
|
||||
const headerHtml = zoneHtml(snapshot.header_craft_state, 'header');
|
||||
const footerHtml = zoneHtml(snapshot.footer_craft_state, 'footer');
|
||||
const composed = headerHtml + bodyHtml + footerHtml;
|
||||
const composedBody = composed + buildAnimationScript(composed);
|
||||
|
||||
const design = snapshotDesign(snapshot);
|
||||
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
|
||||
const seo = page.seo;
|
||||
let html = exportToHtml(state, {
|
||||
// Head (fonts + animation gate) is scanned over header+body+footer, like the
|
||||
// published head (head parity); the body is the composed content.
|
||||
let html = exportComposedPreviewHtml(state, composed, {
|
||||
title: seo?.metaTitle?.trim() || page.name || 'Preview',
|
||||
includeFonts: true,
|
||||
headCode: fullDesign.headCode,
|
||||
@@ -166,13 +167,7 @@ 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`.
|
||||
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (m) html = html.replace(m[1], () => composedBody);
|
||||
});
|
||||
|
||||
html = html.replace(/src="\/api\//g, `src="${apiOrigin}/api/`);
|
||||
html = html.replace(/url\('\/api\//g, `url('${apiOrigin}/api/`);
|
||||
|
||||
@@ -112,3 +112,36 @@ describe('buildFontsLink per-element presets (item 14/15)', () => {
|
||||
expect(link).not.toContain('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Minor finding: Preview gated the animation CSS + noscript on the PAGE BODY
|
||||
* only, while generateCompiledHTML gates on the composed header+body+footer
|
||||
* ($hasAnim = strpos($html,'data-animation') over $composedBody). An animated
|
||||
* element only in the header/footer therefore had no keyframes/noscript in
|
||||
* Preview but did once published.
|
||||
*/
|
||||
describe('composed preview: animation head gate covers header+body+footer', () => {
|
||||
const ANIM_CSS_MARK = '[data-animation]{opacity:0}';
|
||||
const NOSCRIPT = '<noscript><style>[data-animation]{opacity:1}</style></noscript>';
|
||||
const plainBody = root({});
|
||||
const animatedHeader = '<header data-animation="fade-in">H</header>';
|
||||
|
||||
test('animation only in the header -> head has the animation CSS + noscript, one reveal script', async () => {
|
||||
const { exportComposedPreviewHtml } = await import('./html-export');
|
||||
const bodyHtml = exportBodyHtml(plainBody).html;
|
||||
const html = exportComposedPreviewHtml(plainBody, animatedHeader + bodyHtml, { title: 'T' });
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
expect(head).toContain(ANIM_CSS_MARK);
|
||||
expect(head).toContain(NOSCRIPT);
|
||||
expect(html.split("document.querySelectorAll('[data-animation]')").length - 1).toBe(1);
|
||||
expect(html).toContain(animatedHeader);
|
||||
});
|
||||
|
||||
test('no animation anywhere -> no animation CSS/noscript/script', async () => {
|
||||
const { exportComposedPreviewHtml } = await import('./html-export');
|
||||
const html = exportComposedPreviewHtml(plainBody, '<header>H</header>' + exportBodyHtml(plainBody).html, { title: 'T' });
|
||||
expect(html).not.toContain(ANIM_CSS_MARK);
|
||||
expect(html).not.toContain(NOSCRIPT);
|
||||
expect(html).not.toContain("querySelectorAll('[data-animation]')");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,10 @@ export interface ExportOptions {
|
||||
* the body. Preview passes the composed header+body+footer so it matches the
|
||||
* published head, which scans all three (head parity). */
|
||||
fontScanHtml?: string;
|
||||
/** HTML scanned for `data-animation` to gate the head animation CSS + noscript;
|
||||
* defaults to the body. Preview passes the composed header+body+footer, matching
|
||||
* generateCompiledHTML's $hasAnim over the composed body (head parity). */
|
||||
animationScanHtml?: string;
|
||||
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
|
||||
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
|
||||
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
|
||||
@@ -474,7 +478,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
// data-animation (contract gate). `buildAnimationScript` is the single
|
||||
// source of the reveal-script string -- TopBar's Preview body-replacement
|
||||
// uses the same helper so the two emissions never drift apart.
|
||||
const hasAnimations = bodyHtml.includes('data-animation');
|
||||
const hasAnimations = (options.animationScanHtml ?? bodyHtml).includes('data-animation');
|
||||
const animationBlock = hasAnimations ? animation : '';
|
||||
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
|
||||
const revealScript = buildAnimationScript(bodyHtml);
|
||||
@@ -536,6 +540,28 @@ export function exportToHtml(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview document for a page composed with its header/footer: the head is built
|
||||
* over the composed content (fonts + animation gate, like generateCompiledHTML)
|
||||
* and the body is replaced by the composed HTML plus ONE reveal script built from
|
||||
* it. Function replacer: user content may contain `$&` / `$1`.
|
||||
*/
|
||||
export function exportComposedPreviewHtml(
|
||||
serializedState: string,
|
||||
composedHtml: string,
|
||||
options: ExportOptions = {},
|
||||
): string {
|
||||
const composedBody = composedHtml + buildAnimationScript(composedHtml);
|
||||
let html = exportToHtml(serializedState, {
|
||||
...options,
|
||||
fontScanHtml: composedBody,
|
||||
animationScanHtml: composedBody,
|
||||
}).html;
|
||||
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (m) html = html.replace(m[1], () => composedBody);
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user