@@ -7,12 +7,108 @@ import {
77 rewriteInlineStyleAssetUrls ,
88} from "../../compiler/rewriteSubCompPaths.js" ;
99
10+ /**
11+ * Detect whether `html` is a full document (has `<html>`, `<head>`, or
12+ * `<!doctype`), as opposed to a `<template>`-wrapped fragment.
13+ * Anchored to start-of-string (ignoring leading whitespace) so stray
14+ * occurrences inside script/template content don't false-positive.
15+ */
16+ function isFullHtmlDocument ( html : string ) : boolean {
17+ return / ^ \s * (?: < ! d o c t y p e \s | < h t m l [ \s > ] ) / i. test ( html ) ;
18+ }
19+
20+ /**
21+ * Rewrite relative asset paths in a parsed DOM tree. Shared across all
22+ * three dispatch branches (template, full-doc, fragment) to avoid drift.
23+ */
24+ function rewriteRelativePaths ( root : ParentNode , compPath : string ) : void {
25+ rewriteAssetPaths (
26+ root . querySelectorAll ( "[src], [href]" ) ,
27+ compPath ,
28+ ( el : Element , attr : string ) => el . getAttribute ( attr ) ,
29+ ( el : Element , attr : string , value : string ) => el . setAttribute ( attr , value ) ,
30+ ) ;
31+ rewriteInlineStyleAssetUrls (
32+ root . querySelectorAll ( "[style]" ) ,
33+ compPath ,
34+ ( el : Element ) => el . getAttribute ( "style" ) ,
35+ ( el : Element , value : string ) => el . setAttribute ( "style" , value ) ,
36+ ) ;
37+ for ( const styleEl of root . querySelectorAll ( "style" ) ) {
38+ styleEl . textContent = rewriteCssAssetUrls ( styleEl . textContent || "" , compPath ) ;
39+ }
40+ }
41+
42+ /**
43+ * Parse a full HTML document and extract its head elements and body
44+ * content separately, so they can be reassembled into a clean standalone
45+ * page without nesting `<html>` inside `<body>`.
46+ *
47+ * Extracts the full innerHTML of `<head>` — this preserves `<style>`,
48+ * `<script>`, `<link>`, `<meta>`, and any other head-level tags the
49+ * composition declares. Dropping `<link rel="stylesheet">` or `<meta>`
50+ * would cause silent rendering failures for compositions that ship with
51+ * external CSS or viewport-dependent meta.
52+ *
53+ * `<html>` and `<body>` attributes (lang, class, data-*) are extracted
54+ * so callers can forward them to the assembled page.
55+ */
56+ function extractFullDocumentParts (
57+ rawHtml : string ,
58+ compPath : string ,
59+ ) : {
60+ headContent : string ;
61+ bodyContent : string ;
62+ htmlAttrs : string ;
63+ bodyAttrs : string ;
64+ } {
65+ const { document : doc } = parseHTML ( rawHtml ) ;
66+
67+ const rewriteTargets = [ doc . head , doc . body ] . filter ( Boolean ) ;
68+ for ( const target of rewriteTargets ) {
69+ rewriteRelativePaths ( target , compPath ) ;
70+ }
71+
72+ const headContent = doc . head ?. innerHTML ?? "" ;
73+ const bodyContent = doc . body ?. innerHTML ?? "" ;
74+
75+ const htmlEl = doc . documentElement ;
76+ const htmlAttrs = extractElementAttrs ( htmlEl ) ;
77+ const bodyAttrs = doc . body ? extractElementAttrs ( doc . body ) : "" ;
78+
79+ return { headContent, bodyContent, htmlAttrs, bodyAttrs } ;
80+ }
81+
82+ function extractElementAttrs ( el : Element ) : string {
83+ const parts : string [ ] = [ ] ;
84+ for ( let i = 0 ; i < el . attributes . length ; i ++ ) {
85+ const attr = el . attributes [ i ] ! ;
86+ if ( attr . value === "" ) {
87+ parts . push ( attr . name ) ;
88+ } else {
89+ parts . push ( `${ attr . name } ="${ attr . value } "` ) ;
90+ }
91+ }
92+ return parts . join ( " " ) ;
93+ }
94+
1095/**
1196 * Build a standalone HTML page for a sub-composition.
1297 *
1398 * Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
1499 * Lottie, reset styles, runtime) are preserved — instead of building a minimal
15100 * page from scratch that would miss important scripts/styles.
101+ *
102+ * Three dispatch modes, tried in order:
103+ * 1. `<template>` wrapper → extract template content (existing compositions)
104+ * 2. Full HTML document → parse and extract head/body separately (registry blocks)
105+ * 3. Raw fragment → wrap in a minimal document
106+ *
107+ * For full-doc mode, the composition's own `<head>` content (styles, scripts,
108+ * links, meta) is appended AFTER the project's index.html head. When both
109+ * declare the same dependency (e.g. GSAP CDN), the composition's copy wins
110+ * by last-write-wins script execution order — this is intentional so the
111+ * composition can pin a specific version.
16112 */
17113export function buildSubCompositionHtml (
18114 projectDir : string ,
@@ -25,35 +121,34 @@ export function buildSubCompositionHtml(
25121
26122 const rawComp = readFileSync ( compFile , "utf-8" ) ;
27123
28- // Extract content from <template> wrapper (compositions are always templates)
124+ let compHeadContent = "" ;
125+ let rewrittenContent : string ;
126+ let htmlAttrs = "" ;
127+ let bodyAttrs = "" ;
128+
29129 const templateMatch = rawComp . match ( / < t e m p l a t e [ ^ > ] * > ( [ \s \S ] * ) < \/ t e m p l a t e > / i) ;
30- const content = templateMatch ?. [ 1 ] ?? rawComp ;
31- const { document : contentDoc } = parseHTML (
32- `<!DOCTYPE html><html><head></head><body>${ content } </body></html>` ,
33- ) ;
34130
35- rewriteAssetPaths (
36- contentDoc . querySelectorAll ( "[src], [href]" ) ,
37- compPath ,
38- ( el : Element , attr : string ) => el . getAttribute ( attr ) ,
39- ( el : Element , attr : string , value : string ) => {
40- el . setAttribute ( attr , value ) ;
41- } ,
42- ) ;
43- rewriteInlineStyleAssetUrls (
44- contentDoc . querySelectorAll ( "[style]" ) ,
45- compPath ,
46- ( el : Element ) => el . getAttribute ( "style" ) ,
47- ( el : Element , value : string ) => {
48- el . setAttribute ( "style" , value ) ;
49- } ,
50- ) ;
51- for ( const styleEl of contentDoc . querySelectorAll ( "style" ) ) {
52- styleEl . textContent = rewriteCssAssetUrls ( styleEl . textContent || "" , compPath ) ;
131+ if ( templateMatch ) {
132+ const content = templateMatch [ 1 ] ;
133+ const { document : contentDoc } = parseHTML (
134+ `<!DOCTYPE html><html><head></head><body>${ content } </body></html>` ,
135+ ) ;
136+ rewriteRelativePaths ( contentDoc , compPath ) ;
137+ rewrittenContent = contentDoc . body . innerHTML || content ! ;
138+ } else if ( isFullHtmlDocument ( rawComp ) ) {
139+ const parts = extractFullDocumentParts ( rawComp , compPath ) ;
140+ compHeadContent = parts . headContent ;
141+ rewrittenContent = parts . bodyContent ;
142+ htmlAttrs = parts . htmlAttrs ;
143+ bodyAttrs = parts . bodyAttrs ;
144+ } else {
145+ const { document : contentDoc } = parseHTML (
146+ `<!DOCTYPE html><html><head></head><body>${ rawComp } </body></html>` ,
147+ ) ;
148+ rewriteRelativePaths ( contentDoc , compPath ) ;
149+ rewrittenContent = contentDoc . body . innerHTML || rawComp ;
53150 }
54151
55- const rewrittenContent = contentDoc . body . innerHTML || content ;
56-
57152 // Use the project's index.html <head> to preserve all dependencies
58153 const indexPath = join ( projectDir , "index.html" ) ;
59154 let headContent = "" ;
@@ -69,6 +164,11 @@ export function buildSubCompositionHtml(
69164 headContent = `<base href="${ baseHref } ">\n${ headContent } ` ;
70165 }
71166
167+ // Append the sub-composition's own <head> content so its CSS, scripts,
168+ // links, and meta tags are preserved. Placed after the project head so
169+ // the composition's deps take precedence (last-write-wins for scripts).
170+ if ( compHeadContent ) headContent += `\n${ compHeadContent } ` ;
171+
72172 // Ensure runtime is present (might differ from the one in index.html)
73173 if (
74174 ! headContent . includes ( "hyperframe.runtime" ) &&
@@ -82,12 +182,15 @@ export function buildSubCompositionHtml(
82182 headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` ;
83183 }
84184
185+ const htmlOpen = htmlAttrs ? `<html ${ htmlAttrs } >` : "<html>" ;
186+ const bodyOpen = bodyAttrs ? `<body ${ bodyAttrs } >` : "<body>" ;
187+
85188 return `<!DOCTYPE html>
86- <html>
189+ ${ htmlOpen }
87190<head>
88191${ headContent }
89192</head>
90- <body>
193+ ${ bodyOpen }
91194<script>window.__timelines=window.__timelines||{};</script>
92195${ rewrittenContent }
93196</body>
0 commit comments