@@ -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+
82152function 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+ / < t e m p l a t e [ ^ > ] * \s d a t a - c o m p o s i t i o n - i d \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 ( / < t e m p l a t e [ ^ > ] * > ( [ \s \S ] * ) < \/ t e m p l a t e > / 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