fix(core): escape digit-leading id selectors in standalone sub-composition preview

A CSS identifier cannot start with a digit, so an authored rule like
`#01-wall-pushes-back { ... }` is an invalid selector and the browser drops
the whole rule — taking the root's size/background with it. A full
composition masks this (the host stretches/paints the frame), but a
standalone preview has no host, so the root collapses to height:0 +
transparent and renders blank.

extractFullDocumentParts now rewrites `#<digit-leading-id>` selectors to
their escaped valid form (`#\30 1-...`, still matching the element id),
scoped to ids actually present and matched only as `#id` not followed by an
ident char so hex colors are never touched. Also harden the <template>
inner-HTML extraction to use the DOM instead of a greedy regex. Tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miao Yang
2026-06-22 02:40:41 +08:00
co-authored by Claude Opus 4.8
parent aca40cde0a
commit e43b377fc0
2 changed files with 241 additions and 5 deletions
@@ -134,4 +134,136 @@ describe("buildSubCompositionHtml", () => {
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
it("promotes the <template>'s data-composition-id onto the root element when the content lacks one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/02-music.html": `<template data-composition-id="02-music">
<style>#music-scene { background: #121212; }</style>
<div id="music-scene" class="clip" data-start="0" data-duration="3.213" data-track-index="0">
<div id="music-headline">ready when you are.</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["02-music"] = gsap.timeline({ paused: true });
</script>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/02-music.html",
"/api/runtime.js",
);
// Root rendered element gains the id so the runtime can bind the timeline
// (attribute order is serializer-dependent, so match the tag as a whole).
expect(html).toMatch(
/<div\b(?=[^>]*\sid="music-scene")(?=[^>]*\sdata-composition-id="02-music")[^>]*>/,
);
// The <style>/<script> siblings must NOT receive it.
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
expect(html).not.toMatch(/<script[^>]*data-composition-id/i);
});
it("extracts the real <template> even when a head comment mentions the literal text <template>", () => {
// Regression: a greedy /<template>([\s\S]*)<\/template>/ regex latches onto
// the "<template>" inside the head comment, mis-slicing the capture so the
// real content stays wrapped in an inert <template> in the output — leaving
// the preview with no [data-composition-id] element and rendering blank.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/03-force-pair.html": `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- head is metadata only; the HF runtime clones ONLY <template> contents -->
</head>
<body>
<template>
<style>#fp-root { width: 1920px; height: 1080px; }</style>
<div id="fp-root" data-composition-id="03-force-pair" data-width="1920" data-height="1080">
<div class="headline">forces come in pairs</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["03-force-pair"] = gsap.timeline({ paused: true });
</script>
</template>
</body>
</html>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/03-force-pair.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
// The real composition content is rendered into <body> directly...
expect(html).toContain('data-composition-id="03-force-pair"');
expect(html).toContain("forces come in pairs");
expect(html).toContain('__timelines["03-force-pair"]');
// ...and is NOT re-wrapped in an inert <template> (which the browser would
// never render). The comment fragment must not leak through either.
const bodyStart = html!.indexOf("<body>");
const body = html!.slice(bodyStart);
expect(body).not.toContain("<template");
expect(body).not.toContain("clones ONLY");
});
it("escapes #id selectors whose id starts with a digit so the rule is not dropped", () => {
// A CSS ident can't start with a digit, so `#01-wall { ... }` is an invalid
// selector and the browser drops the whole rule — the root loses its size and
// background and a standalone preview renders blank. Escape it to a valid form.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/01-wall.html": `<template>
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="5.5" data-track-index="0">
<style>
#01-wall { position: absolute; inset: 0; width: 1920px; height: 1080px; background-color: #F0EBDE; }
.arrow { background: #1F2BE0; color: #1A2B3C; }
</style>
<div class="arrow">action</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/01-wall.html",
"/api/runtime.js",
);
expect(html).not.toBeNull();
// The invalid `#01-wall` selector is rewritten to its escaped, valid form.
expect(html).toContain("#\\30 1-wall {");
expect(html).not.toMatch(/#01-wall\s*\{/);
// Digit-leading hex color VALUES (not element ids) must be left untouched.
expect(html).toContain("background: #1F2BE0;");
expect(html).toContain("color: #1A2B3C;");
// The element's id attribute itself is unchanged (only the selector is escaped).
expect(html).toContain('id="01-wall"');
});
it("does not add a duplicate data-composition-id when the root element already has one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/04-allinone.html": `<template data-composition-id="04-allinone">
<div id="c04" data-composition-id="04-allinone" data-start="0" data-duration="5.695" data-track-index="0">
<div class="headline">all in one app.</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/04-allinone.html",
"/api/runtime.js",
);
const occurrences = html?.match(/data-composition-id="04-allinone"/g) ?? [];
expect(occurrences).toHaveLength(1);
});
});
@@ -39,6 +39,53 @@ function rewriteRelativePaths(root: ParentNode, compPath: string): void {
}
}
/**
* Escape a CSS identifier whose first character is a digit so it is a valid
* selector. A CSS ident cannot start with a digit, so it must be written as an
* escaped code point: `01-foo` → `\30 1-foo` (leading `0` → `\30 `, rest kept).
*/
function escapeLeadingDigitIdent(id: string): string {
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
}
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
/**
* Fix `#<digit-leading-id>` selectors in the tree's `<style>` blocks.
*
* CSS identifiers cannot start with a digit, so an authored rule like
* `#01-wall-pushes-back { width: 1920px; height: 1080px; background: #F0EBDE }`
* is an invalid selector and the browser silently drops the WHOLE rule — taking
* the root's size and background with it. In a full composition the frame is
* stretched/painted by its `data-composition-src` host so the collapse is
* masked, but a standalone preview has no host: the root falls back to
* `height: 0` + transparent and the frame renders blank (black).
*
* Rewrite each such selector to its escaped, valid form (`#\30 1-wall-pushes-back`,
* which still matches `id="01-wall-pushes-back"`) so the rule applies and the
* whole declaration block — size, background, position, container-type — comes
* back. Scoped to ids that are actually present on elements in the content and
* matched only as `#id` not followed by another ident char, so hex colors
* (`#1F2BE0`) and other values are never touched (they are not element ids).
*/
function fixDigitLeadingIdSelectors(root: ParentNode): void {
const digitIds = new Set<string>();
for (const el of root.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
if (id && /^\d/.test(id)) digitIds.add(id);
}
if (digitIds.size === 0) return;
for (const styleEl of root.querySelectorAll("style")) {
let css = styleEl.textContent || "";
for (const id of digitIds) {
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
}
styleEl.textContent = css;
}
}
/**
* Parse a full HTML document and extract its head elements and body
* content separately, so they can be reassembled into a clean standalone
@@ -68,6 +115,9 @@ function extractFullDocumentParts(
for (const target of rewriteTargets) {
rewriteRelativePaths(target, compPath);
}
// Run on the whole document: ids live in <body> but their rules may live in
// a <head> <style>, so the scope must span both.
fixDigitLeadingIdSelectors(doc);
const headContent = doc.head?.innerHTML ?? "";
const bodyContent = doc.body?.innerHTML ?? "";
@@ -79,6 +129,26 @@ function extractFullDocumentParts(
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
}
/**
* Extract the inner HTML of the composition's wrapping `<template>` element, or
* `null` if the source has no `<template>`.
*
* Located via the DOM rather than a regex. A greedy
* `/<template[^>]*>([\s\S]*)<\/template>/` can latch onto a literal
* `"<template>"` that appears inside an HTML comment — e.g. a head note such as
* "the HF runtime clones ONLY <template> contents" — and mis-slice the capture,
* leaving the real composition content re-wrapped in an inert `<template>` in
* the output. That template is never rendered by the browser, so the standalone
* preview has no `[data-composition-id]` element and no registered timeline, and
* renders blank. `querySelector("template")` only ever matches a real element
* node, so comment text can't fool it.
*/
function extractTemplateInnerHtml(rawComp: string): string | null {
const { document: doc } = parseHTML(rawComp);
const template = doc.querySelector("template");
return template ? template.innerHTML : null;
}
function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
@@ -92,6 +162,38 @@ function extractElementAttrs(el: Element): string {
return parts.join(" ");
}
const NON_RENDERED_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
/**
* Carry the `<template>`'s `data-composition-id` onto the content's root
* rendered element when the author declared it only on the `<template>` tag.
*
* In a full composition, each sub-composition is mounted under a wrapper
* element (the `data-composition-src` host) that carries the composition id,
* which is how the runtime binds `window.__timelines[id]` into the player's
* master timeline. A standalone preview has no such wrapper, so it relies on
* the frame's own root element carrying `data-composition-id`. If the id lives
* only on the inert `<template>` tag (a common authoring pattern), the rendered
* body has no `[data-composition-id]` element — the runtime then never selects
* a root composition, the registered GSAP timeline stays unbound, and seeking
* does nothing. The frame renders at its pre-animation state (GSAP `fromTo`
* pins `opacity:0`), producing a blank preview/thumbnail.
*
* This is a no-op when the content already exposes a `[data-composition-id]`
* element (e.g. the id is authored on the root div), so compositions that
* already render correctly are untouched.
*/
function promoteTemplateCompositionId(rawComp: string, body: Element): void {
const templateCompositionId = rawComp.match(
/<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i,
)?.[1];
if (!templateCompositionId) return;
if (body.querySelector("[data-composition-id]")) return;
const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
root?.setAttribute("data-composition-id", templateCompositionId);
}
/**
* Build a standalone HTML page for a sub-composition.
*
@@ -126,15 +228,16 @@ export function buildSubCompositionHtml(
let htmlAttrs = "";
let bodyAttrs = "";
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
const templateInner = extractTemplateInnerHtml(rawComp);
if (templateMatch) {
const content = templateMatch[1];
if (templateInner != null) {
const { document: contentDoc } = parseHTML(
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
rewrittenContent = contentDoc.body.innerHTML || content!;
fixDigitLeadingIdSelectors(contentDoc);
promoteTemplateCompositionId(rawComp, contentDoc.body);
rewrittenContent = contentDoc.body.innerHTML || templateInner;
} else if (isFullHtmlDocument(rawComp)) {
const parts = extractFullDocumentParts(rawComp, compPath);
compHeadContent = parts.headContent;
@@ -146,6 +249,7 @@ export function buildSubCompositionHtml(
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
fixDigitLeadingIdSelectors(contentDoc);
rewrittenContent = contentDoc.body.innerHTML || rawComp;
}