Skip to content

Commit e43b377

Browse files
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 `#1-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) <[email protected]>
1 parent aca40cd commit e43b377

2 files changed

Lines changed: 241 additions & 5 deletions

File tree

‎packages/core/src/studio-api/helpers/subComposition.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,136 @@ describe("buildSubCompositionHtml", () => {
134134
expect(html).not.toContain("url('../poster.png')");
135135
expect(html).not.toContain('url("../fonts/brand.woff2")');
136136
});
137+
138+
it("promotes the <template>'s data-composition-id onto the root element when the content lacks one", () => {
139+
const dir = makeTempProject({
140+
"index.html": `<!doctype html><html><head></head><body></body></html>`,
141+
"compositions/frames/02-music.html": `<template data-composition-id="02-music">
142+
<style>#music-scene { background: #121212; }</style>
143+
<div id="music-scene" class="clip" data-start="0" data-duration="3.213" data-track-index="0">
144+
<div id="music-headline">ready when you are.</div>
145+
</div>
146+
<script>
147+
window.__timelines = window.__timelines || {};
148+
window.__timelines["02-music"] = gsap.timeline({ paused: true });
149+
</script>
150+
</template>`,
151+
});
152+
153+
const html = buildSubCompositionHtml(
154+
dir,
155+
"compositions/frames/02-music.html",
156+
"/api/runtime.js",
157+
);
158+
159+
// Root rendered element gains the id so the runtime can bind the timeline
160+
// (attribute order is serializer-dependent, so match the tag as a whole).
161+
expect(html).toMatch(
162+
/<div\b(?=[^>]*\sid="music-scene")(?=[^>]*\sdata-composition-id="02-music")[^>]*>/,
163+
);
164+
// The <style>/<script> siblings must NOT receive it.
165+
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
166+
expect(html).not.toMatch(/<script[^>]*data-composition-id/i);
167+
});
168+
169+
it("extracts the real <template> even when a head comment mentions the literal text <template>", () => {
170+
// Regression: a greedy /<template>([\s\S]*)<\/template>/ regex latches onto
171+
// the "<template>" inside the head comment, mis-slicing the capture so the
172+
// real content stays wrapped in an inert <template> in the output — leaving
173+
// the preview with no [data-composition-id] element and rendering blank.
174+
const dir = makeTempProject({
175+
"index.html": `<!doctype html><html><head></head><body></body></html>`,
176+
"compositions/frames/03-force-pair.html": `<!doctype html>
177+
<html lang="en">
178+
<head>
179+
<meta charset="UTF-8" />
180+
<!-- head is metadata only; the HF runtime clones ONLY <template> contents -->
181+
</head>
182+
<body>
183+
<template>
184+
<style>#fp-root { width: 1920px; height: 1080px; }</style>
185+
<div id="fp-root" data-composition-id="03-force-pair" data-width="1920" data-height="1080">
186+
<div class="headline">forces come in pairs</div>
187+
</div>
188+
<script>
189+
window.__timelines = window.__timelines || {};
190+
window.__timelines["03-force-pair"] = gsap.timeline({ paused: true });
191+
</script>
192+
</template>
193+
</body>
194+
</html>`,
195+
});
196+
197+
const html = buildSubCompositionHtml(
198+
dir,
199+
"compositions/frames/03-force-pair.html",
200+
"/api/runtime.js",
201+
"/api/projects/demo/preview/",
202+
);
203+
204+
expect(html).not.toBeNull();
205+
// The real composition content is rendered into <body> directly...
206+
expect(html).toContain('data-composition-id="03-force-pair"');
207+
expect(html).toContain("forces come in pairs");
208+
expect(html).toContain('__timelines["03-force-pair"]');
209+
// ...and is NOT re-wrapped in an inert <template> (which the browser would
210+
// never render). The comment fragment must not leak through either.
211+
const bodyStart = html!.indexOf("<body>");
212+
const body = html!.slice(bodyStart);
213+
expect(body).not.toContain("<template");
214+
expect(body).not.toContain("clones ONLY");
215+
});
216+
217+
it("escapes #id selectors whose id starts with a digit so the rule is not dropped", () => {
218+
// A CSS ident can't start with a digit, so `#01-wall { ... }` is an invalid
219+
// selector and the browser drops the whole rule — the root loses its size and
220+
// background and a standalone preview renders blank. Escape it to a valid form.
221+
const dir = makeTempProject({
222+
"index.html": `<!doctype html><html><head></head><body></body></html>`,
223+
"compositions/frames/01-wall.html": `<template>
224+
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="5.5" data-track-index="0">
225+
<style>
226+
#01-wall { position: absolute; inset: 0; width: 1920px; height: 1080px; background-color: #F0EBDE; }
227+
.arrow { background: #1F2BE0; color: #1A2B3C; }
228+
</style>
229+
<div class="arrow">action</div>
230+
</div>
231+
</template>`,
232+
});
233+
234+
const html = buildSubCompositionHtml(
235+
dir,
236+
"compositions/frames/01-wall.html",
237+
"/api/runtime.js",
238+
);
239+
240+
expect(html).not.toBeNull();
241+
// The invalid `#01-wall` selector is rewritten to its escaped, valid form.
242+
expect(html).toContain("#\\30 1-wall {");
243+
expect(html).not.toMatch(/#01-wall\s*\{/);
244+
// Digit-leading hex color VALUES (not element ids) must be left untouched.
245+
expect(html).toContain("background: #1F2BE0;");
246+
expect(html).toContain("color: #1A2B3C;");
247+
// The element's id attribute itself is unchanged (only the selector is escaped).
248+
expect(html).toContain('id="01-wall"');
249+
});
250+
251+
it("does not add a duplicate data-composition-id when the root element already has one", () => {
252+
const dir = makeTempProject({
253+
"index.html": `<!doctype html><html><head></head><body></body></html>`,
254+
"compositions/frames/04-allinone.html": `<template data-composition-id="04-allinone">
255+
<div id="c04" data-composition-id="04-allinone" data-start="0" data-duration="5.695" data-track-index="0">
256+
<div class="headline">all in one app.</div>
257+
</div>
258+
</template>`,
259+
});
260+
261+
const html = buildSubCompositionHtml(
262+
dir,
263+
"compositions/frames/04-allinone.html",
264+
"/api/runtime.js",
265+
);
266+
const occurrences = html?.match(/data-composition-id="04-allinone"/g) ?? [];
267+
expect(occurrences).toHaveLength(1);
268+
});
137269
});

‎packages/core/src/studio-api/helpers/subComposition.ts‎

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,53 @@ function rewriteRelativePaths(root: ParentNode, compPath: string): void {
3939
}
4040
}
4141

42+
/**
43+
* Escape a CSS identifier whose first character is a digit so it is a valid
44+
* selector. A CSS ident cannot start with a digit, so it must be written as an
45+
* escaped code point: `01-foo` → `\30 1-foo` (leading `0` → `\30 `, rest kept).
46+
*/
47+
function escapeLeadingDigitIdent(id: string): string {
48+
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
49+
}
50+
51+
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
52+
53+
/**
54+
* Fix `#<digit-leading-id>` selectors in the tree's `<style>` blocks.
55+
*
56+
* CSS identifiers cannot start with a digit, so an authored rule like
57+
* `#01-wall-pushes-back { width: 1920px; height: 1080px; background: #F0EBDE }`
58+
* is an invalid selector and the browser silently drops the WHOLE rule — taking
59+
* the root's size and background with it. In a full composition the frame is
60+
* stretched/painted by its `data-composition-src` host so the collapse is
61+
* masked, but a standalone preview has no host: the root falls back to
62+
* `height: 0` + transparent and the frame renders blank (black).
63+
*
64+
* Rewrite each such selector to its escaped, valid form (`#\30 1-wall-pushes-back`,
65+
* which still matches `id="01-wall-pushes-back"`) so the rule applies and the
66+
* whole declaration block — size, background, position, container-type — comes
67+
* back. Scoped to ids that are actually present on elements in the content and
68+
* matched only as `#id` not followed by another ident char, so hex colors
69+
* (`#1F2BE0`) and other values are never touched (they are not element ids).
70+
*/
71+
function fixDigitLeadingIdSelectors(root: ParentNode): void {
72+
const digitIds = new Set<string>();
73+
for (const el of root.querySelectorAll("[id]")) {
74+
const id = el.getAttribute("id");
75+
if (id && /^\d/.test(id)) digitIds.add(id);
76+
}
77+
if (digitIds.size === 0) return;
78+
79+
for (const styleEl of root.querySelectorAll("style")) {
80+
let css = styleEl.textContent || "";
81+
for (const id of digitIds) {
82+
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
83+
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
84+
}
85+
styleEl.textContent = css;
86+
}
87+
}
88+
4289
/**
4390
* Parse a full HTML document and extract its head elements and body
4491
* content separately, so they can be reassembled into a clean standalone
@@ -68,6 +115,9 @@ function extractFullDocumentParts(
68115
for (const target of rewriteTargets) {
69116
rewriteRelativePaths(target, compPath);
70117
}
118+
// Run on the whole document: ids live in <body> but their rules may live in
119+
// a <head> <style>, so the scope must span both.
120+
fixDigitLeadingIdSelectors(doc);
71121

72122
const headContent = doc.head?.innerHTML ?? "";
73123
const bodyContent = doc.body?.innerHTML ?? "";
@@ -79,6 +129,26 @@ function extractFullDocumentParts(
79129
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
80130
}
81131

132+
/**
133+
* Extract the inner HTML of the composition's wrapping `<template>` element, or
134+
* `null` if the source has no `<template>`.
135+
*
136+
* Located via the DOM rather than a regex. A greedy
137+
* `/<template[^>]*>([\s\S]*)<\/template>/` can latch onto a literal
138+
* `"<template>"` that appears inside an HTML comment — e.g. a head note such as
139+
* "the HF runtime clones ONLY <template> contents" — and mis-slice the capture,
140+
* leaving the real composition content re-wrapped in an inert `<template>` in
141+
* the output. That template is never rendered by the browser, so the standalone
142+
* preview has no `[data-composition-id]` element and no registered timeline, and
143+
* renders blank. `querySelector("template")` only ever matches a real element
144+
* node, so comment text can't fool it.
145+
*/
146+
function extractTemplateInnerHtml(rawComp: string): string | null {
147+
const { document: doc } = parseHTML(rawComp);
148+
const template = doc.querySelector("template");
149+
return template ? template.innerHTML : null;
150+
}
151+
82152
function extractElementAttrs(el: Element): string {
83153
const parts: string[] = [];
84154
for (let i = 0; i < el.attributes.length; i++) {
@@ -92,6 +162,38 @@ function extractElementAttrs(el: Element): string {
92162
return parts.join(" ");
93163
}
94164

165+
const NON_RENDERED_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
166+
167+
/**
168+
* Carry the `<template>`'s `data-composition-id` onto the content's root
169+
* rendered element when the author declared it only on the `<template>` tag.
170+
*
171+
* In a full composition, each sub-composition is mounted under a wrapper
172+
* element (the `data-composition-src` host) that carries the composition id,
173+
* which is how the runtime binds `window.__timelines[id]` into the player's
174+
* master timeline. A standalone preview has no such wrapper, so it relies on
175+
* the frame's own root element carrying `data-composition-id`. If the id lives
176+
* only on the inert `<template>` tag (a common authoring pattern), the rendered
177+
* body has no `[data-composition-id]` element — the runtime then never selects
178+
* a root composition, the registered GSAP timeline stays unbound, and seeking
179+
* does nothing. The frame renders at its pre-animation state (GSAP `fromTo`
180+
* pins `opacity:0`), producing a blank preview/thumbnail.
181+
*
182+
* This is a no-op when the content already exposes a `[data-composition-id]`
183+
* element (e.g. the id is authored on the root div), so compositions that
184+
* already render correctly are untouched.
185+
*/
186+
function promoteTemplateCompositionId(rawComp: string, body: Element): void {
187+
const templateCompositionId = rawComp.match(
188+
/<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i,
189+
)?.[1];
190+
if (!templateCompositionId) return;
191+
if (body.querySelector("[data-composition-id]")) return;
192+
193+
const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
194+
root?.setAttribute("data-composition-id", templateCompositionId);
195+
}
196+
95197
/**
96198
* Build a standalone HTML page for a sub-composition.
97199
*
@@ -126,15 +228,16 @@ export function buildSubCompositionHtml(
126228
let htmlAttrs = "";
127229
let bodyAttrs = "";
128230

129-
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
231+
const templateInner = extractTemplateInnerHtml(rawComp);
130232

131-
if (templateMatch) {
132-
const content = templateMatch[1];
233+
if (templateInner != null) {
133234
const { document: contentDoc } = parseHTML(
134-
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
235+
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
135236
);
136237
rewriteRelativePaths(contentDoc, compPath);
137-
rewrittenContent = contentDoc.body.innerHTML || content!;
238+
fixDigitLeadingIdSelectors(contentDoc);
239+
promoteTemplateCompositionId(rawComp, contentDoc.body);
240+
rewrittenContent = contentDoc.body.innerHTML || templateInner;
138241
} else if (isFullHtmlDocument(rawComp)) {
139242
const parts = extractFullDocumentParts(rawComp, compPath);
140243
compHeadContent = parts.headContent;
@@ -146,6 +249,7 @@ export function buildSubCompositionHtml(
146249
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
147250
);
148251
rewriteRelativePaths(contentDoc, compPath);
252+
fixDigitLeadingIdSelectors(contentDoc);
149253
rewrittenContent = contentDoc.body.innerHTML || rawComp;
150254
}
151255

0 commit comments

Comments
 (0)