fix(site-builder): entrance-animation reveal script survives Preview + well-formed void-tag attrs + no-JS fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,12 +52,29 @@ function buildDataAttrs(props: Record<string, any>): string {
|
||||
|
||||
/**
|
||||
* Inject data attributes into the first HTML opening tag of a rendered string.
|
||||
*
|
||||
* For a void/self-closing tag (e.g. `<img src="x" />`) the first `>` is
|
||||
* preceded by a `/` -- naively inserting before the `>` produces the
|
||||
* malformed `<img ... / data-animation="...">` (attrs land AFTER the
|
||||
* self-close slash, outside the tag). Detect that trailing `/` and insert
|
||||
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
|
||||
* Non-void tags (no trailing `/`) are unaffected.
|
||||
*/
|
||||
function injectAttrs(html: string, attrs: string): string {
|
||||
if (!attrs) return html;
|
||||
// Find the first > of the opening tag and inject before it
|
||||
const idx = html.indexOf('>');
|
||||
if (idx === -1) return html;
|
||||
if (idx > 0 && html[idx - 1] === '/') {
|
||||
// Void/self-closing tag (`<img ... />`): inserting before `>` would land
|
||||
// the attrs after the `/`, outside the tag (`<img ... / data-x="y">`).
|
||||
// Insert before the `/` instead -- also trim any whitespace directly
|
||||
// preceding it so we don't end up with a double space, since `attrs`
|
||||
// already carries its own leading space(s).
|
||||
let contentEnd = idx - 1;
|
||||
while (contentEnd > 0 && /\s/.test(html[contentEnd - 1])) contentEnd--;
|
||||
return html.slice(0, contentEnd) + attrs + html.slice(idx - 1);
|
||||
}
|
||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||
}
|
||||
|
||||
@@ -344,6 +361,22 @@ document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||
});
|
||||
</script>`;
|
||||
|
||||
// No-JS safety net (contract "No-JS safety"): un-hides animated elements
|
||||
// when JS is disabled, so `[data-animation]{opacity:0}` never permanently
|
||||
// hides content that the reveal script would otherwise never run for.
|
||||
const ANIMATION_NOSCRIPT = `<noscript><style>[data-animation]{opacity:1}</style></noscript>`;
|
||||
|
||||
/**
|
||||
* Returns the reveal `<script>` (byte-identical to the shared contract, and
|
||||
* to the backend's `generateCompiledHTML` emission) when `bodyHtml` contains
|
||||
* an animated element, else `''`. Single source of the script string so
|
||||
* every caller (wrapInDocument's in-body emission, and TopBar's Preview
|
||||
* body-replacement) stays in sync.
|
||||
*/
|
||||
export function buildAnimationScript(bodyHtml: string): string {
|
||||
return bodyHtml.includes('data-animation') ? ANIMATION_SCRIPT : '';
|
||||
}
|
||||
|
||||
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const title = options.title || 'Untitled Page';
|
||||
const minify = options.minifyCss !== false;
|
||||
@@ -361,10 +394,15 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const seoMeta = buildSeoMeta(options, title);
|
||||
const tokenCss = buildTokenCss(design);
|
||||
|
||||
// Only include animation CSS + script if body contains data-animation
|
||||
// Only include animation CSS + noscript fallback + script if body contains
|
||||
// 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 animationBlock = hasAnimations ? animation : '';
|
||||
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
|
||||
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
|
||||
const revealScript = buildAnimationScript(bodyHtml);
|
||||
const animationScript = revealScript ? `\n${revealScript}` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -372,7 +410,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${seoMeta}${fonts}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${headCode}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}${animationScript}
|
||||
|
||||
Reference in New Issue
Block a user