From f51bdd1c13fe8de3f44f30316b4321f42e378a74 Mon Sep 17 00:00:00 2001 From: func25 Date: Sat, 16 May 2026 20:21:25 +0700 Subject: [PATCH] fix(core): align sub-composition scoping across runtime and bundler --- .../src/compiler/compositionScoping.test.ts | 145 +++++ .../core/src/compiler/compositionScoping.ts | 191 +++++- .../core/src/compiler/htmlBundler.test.ts | 215 +++++++ packages/core/src/compiler/htmlBundler.ts | 339 ++++++++--- .../src/runtime/compositionLoader.test.ts | 547 ++++++++++++++++++ .../core/src/runtime/compositionLoader.ts | 261 +++++++-- 6 files changed, 1565 insertions(+), 133 deletions(-) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 49208d30c..6d7407f53 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -75,6 +75,33 @@ body { margin: 0; } expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" }); }); + it("scoped getVariables reads from the runtime composition id when it differs", () => { + const { document } = parseHTML(`
`); + const fakeWindow: Record = { + document, + __timelines: {}, + __hfVariablesByComp: { + scene: { title: "Wrong" }, + scene__hf1: { title: "Right" }, + }, + __hyperframes: { + getVariables: () => ({ title: "TOP-LEVEL-LEAK" }), + fitTextFontSize: () => undefined, + }, + }; + const wrapped = wrapScopedCompositionScript( + `window.__captured = __hyperframes.getVariables();`, + "scene", + "[HyperFrames] composition script error:", + undefined, + "scene__hf1", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(fakeWindow.__captured).toEqual({ title: "Right" }); + }); + it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => { const { document } = parseHTML(`
`); const fakeWindow: Record = { @@ -208,6 +235,124 @@ window.__selectedComp = expect(fakeWindow.__selectedComp).toBe("scene-b"); }); + it("scopes authored root id lookups after the flattened root drops its literal id", () => { + const { document } = parseHTML(` +
+
+

Scene

+
+
+ `); + const fakeWindow = { + document, + __selectedTitle: "", + __timelines: {}, + }; + const wrapped = wrapScopedCompositionScript( + ` +window.__selectedTitle = + document.getElementById("scene-root") + ?.querySelector(".title") + ?.textContent || "missing"; +`, + "scene", + "[HyperFrames] composition script error:", + undefined, + "scene", + "scene-root", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(fakeWindow.__selectedTitle).toBe("Scene"); + }); + + it("does not rewrite authored root hash text inside CSS attribute values", () => { + const scoped = scopeCssToComposition( + 'a[href="#scene-root"] { color: red; }', + "scene", + undefined, + "scene-root", + ); + + expect(scoped).toContain('[data-composition-id="scene"] a[href="#scene-root"]'); + expect(scoped).not.toContain('[href="[data-hf-authored-id='); + }); + + it("does not rewrite authored root hash text inside querySelector attribute values", () => { + const { document } = parseHTML(` +
+ Jump +
+
+ `); + const fakeWindow = { + document, + __selectedHref: "", + __timelines: {}, + }; + const wrapped = wrapScopedCompositionScript( + ` +window.__selectedHref = + document.querySelector('a[href="#scene-root"]') + ?.getAttribute("href") || "missing"; +`, + "scene", + "[HyperFrames] composition script error:", + undefined, + "scene", + "scene-root", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(fakeWindow.__selectedHref).toBe("#scene-root"); + }); + + it("normalizes gsap.utils.selector() selectors for authored root ids and root timing attrs", () => { + const { document } = parseHTML(` +
+
+

Scene

+
+
+
+
+

Other

+
+
+ `); + const fakeWindow = { + document, + __selectedRootCount: 0, + __selectedTimedCount: 0, + __selectedTitle: "", + __timelines: {}, + gsap: { + utils: {}, + }, + }; + const wrapped = wrapScopedCompositionScript( + ` +const select = gsap.utils.selector(document.querySelector('[data-composition-id="scene"]')); +window.__selectedRootCount = select('#scene-root').length; +window.__selectedTimedCount = select('[data-composition-id="scene"][data-start="0"] .title').length; +window.__selectedTitle = select('#scene-root .title')[0]?.textContent || "missing"; +`, + "scene", + "[HyperFrames] composition script error:", + undefined, + "scene", + "scene-root", + ); + + new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap); + + expect(fakeWindow.__selectedRootCount).toBe(1); + expect(fakeWindow.__selectedTimedCount).toBe(1); + expect(fakeWindow.__selectedTitle).toBe("Scene"); + }); + it("reads scoped proxy accessors with the original target receiver", () => { const root = { contains(node: unknown) { diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 5fec5b5c2..f6fb503d9 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -1,5 +1,7 @@ import postcss, { type AtRule, type Node, type Rule } from "postcss"; +const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id"; + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -8,9 +10,101 @@ function escapeCssAttributeValue(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } -function scopeSelector(selector: string, scope: string, compositionId: string): string { - const selectorWithoutRootTiming = normalizeCompositionRootSelector( +function escapeCssIdentifier(value: string): string { + if (!value) return value; + const escaped = value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`); + return escaped.replace(/^-?\d/, (match) => `\\${match}`); +} + +function getAuthoredRootIdSelectorForms(authoredRootId: string): string[] { + const trimmed = authoredRootId.trim(); + if (!trimmed) return []; + return Array.from(new Set([trimmed, escapeCssIdentifier(trimmed)])).filter(Boolean); +} + +function isSelectorNameChar(char: string | undefined): boolean { + return !!char && /[\w-]/.test(char); +} + +function replaceAuthoredRootIdSelectors( + selector: string, + authoredRootId: string, + replacement: string, +): string { + const forms = getAuthoredRootIdSelectorForms(authoredRootId).sort((a, b) => b.length - a.length); + if (forms.length === 0) return selector; + + let result = ""; + let bracketDepth = 0; + let quote: '"' | "'" | null = null; + + for (let index = 0; index < selector.length; index += 1) { + const char = selector[index]; + const previousChar = index > 0 ? selector[index - 1] : ""; + + if (quote) { + result += char; + if (char === quote && previousChar !== "\\") { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + result += char; + continue; + } + + if (char === "[") { + bracketDepth += 1; + result += char; + continue; + } + + if (char === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + result += char; + continue; + } + + if (char === "#" && bracketDepth === 0) { + const matchedForm = forms.find((form) => selector.startsWith(form, index + 1)); + if (matchedForm) { + const nextChar = selector[index + 1 + matchedForm.length]; + if (!isSelectorNameChar(nextChar)) { + result += replacement; + index += matchedForm.length; + continue; + } + } + } + + result += char; + } + + return result; +} + +function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: string | null): string { + const trimmed = authoredRootId?.trim(); + if (!trimmed) return selector; + return replaceAuthoredRootIdSelectors( selector, + trimmed, + `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(trimmed)}"]`, + ); +} + +function scopeSelector( + selector: string, + scope: string, + compositionId: string, + authoredRootId?: string | null, +): string { + const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId); + const selectorWithoutRootTiming = normalizeCompositionRootSelector( + selectorWithoutAuthoredRootId, scope, compositionId, ); @@ -63,6 +157,7 @@ export function scopeCssToComposition( css: string, compositionId: string, scopeSelectorOverride?: string, + authoredRootId?: string | null, ): string { const trimmedCompositionId = compositionId.trim(); if (!css || !trimmedCompositionId) return css; @@ -74,7 +169,7 @@ export function scopeCssToComposition( root.walkRules((rule) => { if (isInsideGlobalAtRule(rule)) return; rule.selectors = rule.selectors.map((selector) => - scopeSelector(selector, scope, trimmedCompositionId), + scopeSelector(selector, scope, trimmedCompositionId, authoredRootId), ); }); @@ -87,11 +182,13 @@ export function wrapScopedCompositionScript( errorLabel = "[HyperFrames] composition script error:", scopeSelectorOverride?: string, timelineCompositionId = compositionId, + authoredRootId?: string | null, ): string { const compositionIdLiteral = JSON.stringify(compositionId); const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId); const errorLabelLiteral = JSON.stringify(errorLabel); const escapedCompositionId = escapeRegExp(compositionId); + const authoredRootIdLiteral = JSON.stringify(authoredRootId?.trim() || null); const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null); const rootSelectorPatternLiteral = JSON.stringify( String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`, @@ -99,10 +196,15 @@ export function wrapScopedCompositionScript( const timingSelectorPatternLiteral = JSON.stringify( String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`, ); + const authoredRootIdFormsLiteral = JSON.stringify( + getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""), + ); return `(function(){ var __hfCompId = ${compositionIdLiteral}; var __hfTimelineCompId = ${timelineCompositionIdLiteral}; var __hfErrorLabel = ${errorLabelLiteral}; + var __hfAuthoredRootId = ${authoredRootIdLiteral}; + var __hfAuthoredRootAttr = ${JSON.stringify(AUTHORED_ROOT_ID_ATTR)}; var __hfEscapeAttr = function(value) { return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\""); }; @@ -112,11 +214,76 @@ export function wrapScopedCompositionScript( var __hfRoot = null; var __hfRootSelectorPattern = ${rootSelectorPatternLiteral}; var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral}; + var __hfAuthoredRootIdForms = ${authoredRootIdFormsLiteral}; + var __hfAuthoredRootSelector = __hfAuthoredRootId + ? "[" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(__hfAuthoredRootId) + '"]' + : ""; + var __hfIsSelectorNameChar = function(char) { + return !!char && /[\\w-]/.test(char); + }; + var __hfReplaceAuthoredRootIdSelectors = function(selector) { + if (!__hfAuthoredRootSelector || !__hfAuthoredRootIdForms.length || typeof selector !== "string") { + return selector; + } + var result = ""; + var bracketDepth = 0; + var quote = null; + for (var index = 0; index < selector.length; index += 1) { + var char = selector[index]; + var previousChar = index > 0 ? selector[index - 1] : ""; + if (quote) { + result += char; + if (char === quote && previousChar !== "\\\\") { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + result += char; + continue; + } + if (char === "[") { + bracketDepth += 1; + result += char; + continue; + } + if (char === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + result += char; + continue; + } + if (char === "#" && bracketDepth === 0) { + var matchedForm = null; + for (var formIndex = 0; formIndex < __hfAuthoredRootIdForms.length; formIndex += 1) { + var form = __hfAuthoredRootIdForms[formIndex]; + if (selector.slice(index + 1, index + 1 + form.length) === form) { + matchedForm = form; + break; + } + } + if (matchedForm) { + var nextChar = selector[index + 1 + matchedForm.length]; + if (!__hfIsSelectorNameChar(nextChar)) { + result += __hfAuthoredRootSelector; + index += matchedForm.length; + continue; + } + } + } + result += char; + } + return result; + }; var __hfNormalizeSelector = function(selector) { if (!__hfCompId || typeof selector !== "string") return selector; - return selector + var normalized = selector .replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector) .replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector); + if (__hfAuthoredRootSelector) { + normalized = __hfReplaceAuthoredRootIdSelectors(normalized); + } + return normalized; }; var __hfFindRoot = function() { if (!__hfRoot && __hfRootSelector) { @@ -147,8 +314,15 @@ export function wrapScopedCompositionScript( var root = __hfFindRoot(); if (!root) return found || null; var idValue = id + ""; + if (__hfAuthoredRootId && __hfAuthoredRootId === idValue && root.getAttribute && root.getAttribute(__hfAuthoredRootAttr) === idValue) { + return root; + } if (root.id === idValue) return root; if (typeof root.querySelector !== "function") return null; + try { + var authoredRootMatch = root.querySelector('[' + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(idValue) + '"]'); + if (authoredRootMatch) return authoredRootMatch; + } catch {} if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") { try { return root.querySelector("#" + CSS.escape(idValue)) || null; @@ -265,7 +439,12 @@ export function wrapScopedCompositionScript( var root = baseEl || __hfFindRoot(); return function(selector) { if (!root || typeof selector !== "string") return []; - return Array.prototype.slice.call(root.querySelectorAll(selector)); + return Array.prototype.filter.call( + window.document.querySelectorAll(__hfNormalizeSelector(selector)), + function(node) { + return node === root || (typeof root.contains === "function" && root.contains(node)); + }, + ); }; }; } @@ -284,7 +463,7 @@ export function wrapScopedCompositionScript( : Object.assign({}, __hfBaseHyperframes, { getVariables: function() { var byComp = window.__hfVariablesByComp; - var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null; + var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null; return scoped ? Object.assign({}, scoped) : {}; }, }); diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 4cf5a75d7..93945b411 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -381,6 +381,221 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain("__hfNormalizeSelector"); }); + it("keeps an authored inner root wrapper for root id and class selectors", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+ +`, + "compositions/scene.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const host = document.querySelector("#scene-host"); + const authoredRoot = host?.querySelector('[data-hf-authored-id="scene-root"]'); + + expect(host).toBeTruthy(); + expect(authoredRoot).toBeTruthy(); + expect(authoredRoot?.id).toBe(""); + expect(authoredRoot?.getAttribute("data-composition-id")).toBeNull(); + expect(authoredRoot?.getAttribute("data-hf-inner-root")).toBe("true"); + expect(authoredRoot?.getAttribute("data-hf-authored-id")).toBe("scene-root"); + expect(bundled).toContain('[data-composition-id="scene"] .scene-root .title'); + expect(bundled).toContain('[data-composition-id="scene"] [data-hf-authored-id="scene-root"]'); + }); + + it("does not keep duplicate authored root ids when the same external composition mounts twice", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ +`, + "compositions/scene.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const authoredRoots = document.querySelectorAll('[data-hf-authored-id="scene-root"]'); + + expect(authoredRoots).toHaveLength(2); + expect(document.querySelectorAll("#scene-root")).toHaveLength(0); + expect(Array.from(authoredRoots).every((root) => !root.getAttribute("id"))).toBe(true); + }); + + it("mounts duplicate inline-template hosts instead of only the first one", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ + +`, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const hostA = document.querySelector("#scene-host-a"); + const hostB = document.querySelector("#scene-host-b"); + + expect(hostA?.querySelector(".title")?.textContent).toBe("Scene"); + expect(hostB?.querySelector(".title")?.textContent).toBe("Scene"); + expect(hostA?.getAttribute("data-composition-id")).toBe("scene__hf1"); + expect(hostB?.getAttribute("data-composition-id")).toBe("scene__hf2"); + expect(hostA?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + expect(hostB?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + }); + + it("emits scoped style and script chunks for each duplicate inline-template host", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ + +`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain('[data-composition-id="scene__hf1"] .title'); + expect(bundled).toContain('[data-composition-id="scene__hf2"] .title'); + expect(bundled).toContain('var __hfTimelineCompId = "scene__hf1"'); + expect(bundled).toContain('var __hfTimelineCompId = "scene__hf2"'); + }); + + it("uniquifies duplicate sub-compositions across inline-template and external hosts", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ + +`, + "compositions/scene.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const inlineHost = document.querySelector("#scene-host-inline"); + const externalHost = document.querySelector("#scene-host-external"); + + expect(inlineHost?.getAttribute("data-composition-id")).toBe("scene__hf1"); + expect(externalHost?.getAttribute("data-composition-id")).toBe("scene__hf2"); + expect(inlineHost?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + expect(externalHost?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + expect(inlineHost?.querySelector("p")?.textContent).toBe("Inline scene"); + expect(externalHost?.querySelector("p")?.textContent).toBe("External scene"); + }); + + it("emits per-instance scoped variables for bundled sub-compositions", async () => { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ +`, + "compositions/card.html": ` + + +
+ +
+ +`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("window.__hfVariablesByComp"); + expect(bundled).toMatch(/card__hf1[\s\S]*Pro[\s\S]*light/); + expect(bundled).toMatch(/card__hf2[\s\S]*Enterprise[\s\S]*light/); + }); + it("scopes external sub-composition styles and classic scripts", async () => { const dir = makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 1ccbed894..3e1377d84 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -15,6 +15,7 @@ import { import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; +import { readDeclaredDefaults } from "../runtime/getVariables"; /** Resolve a relative path within projectDir, rejecting traversal outside it. */ function safePath(projectDir: string, relativePath: string): string | null { @@ -186,6 +187,138 @@ function uniqueCompositionId(baseId: string, index: number): string { return `${baseId}__hf${index}`; } +type BundledHostCompositionIdentity = { + authoredCompositionId: string | null; + runtimeCompositionId: string | null; +}; + +function getBundledHostCompositionIdentity(host: Element): BundledHostCompositionIdentity { + const currentCompositionId = (host.getAttribute("data-composition-id") || "").trim() || null; + const authoredCompositionId = + (host.getAttribute("data-hf-original-composition-id") || currentCompositionId || "").trim() || + null; + return { + authoredCompositionId, + runtimeCompositionId: currentCompositionId, + }; +} + +function getBundledTrackedCompositionHosts(document: Document): Element[] { + const hosts = Array.from( + document.querySelectorAll("[data-composition-src], [data-composition-id]"), + ); + return hosts.filter((host) => { + if (host.hasAttribute("data-composition-src")) return true; + const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId; + if (!authoredCompositionId) return false; + return !!document.getElementById(`${authoredCompositionId}-template`); + }); +} + +function shouldAssignBundledRuntimeCompositionId(host: Element, document: Document): boolean { + if (host.hasAttribute("data-composition-src")) return true; + const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId; + if (!authoredCompositionId) return false; + if (!document.getElementById(`${authoredCompositionId}-template`)) return false; + return host.children.length === 0; +} + +function countBundledAuthoredCompositionIds(hosts: Element[]): Map { + const counts = new Map(); + for (const host of hosts) { + const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId; + if (!authoredCompositionId) continue; + counts.set(authoredCompositionId, (counts.get(authoredCompositionId) || 0) + 1); + } + return counts; +} + +function assignBundledRuntimeCompositionIds( + hosts: Element[], + counts: Map = countBundledAuthoredCompositionIds(hosts), +): Map { + const instanceByCompositionId = new Map(); + const identities = new Map(); + + for (const host of hosts) { + const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } = + getBundledHostCompositionIdentity(host); + const shouldAssign = shouldAssignBundledRuntimeCompositionId(host, host.ownerDocument); + if (!authoredCompositionId) { + identities.set(host, { + authoredCompositionId: null, + runtimeCompositionId: previousRuntimeCompositionId, + }); + continue; + } + + const duplicateInstance = (counts.get(authoredCompositionId) || 0) > 1; + let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId; + if (shouldAssign) { + const instanceIndex = duplicateInstance + ? (instanceByCompositionId.get(authoredCompositionId) || 0) + 1 + : 0; + if (duplicateInstance) { + instanceByCompositionId.set(authoredCompositionId, instanceIndex); + host.setAttribute("data-hf-original-composition-id", authoredCompositionId); + } else { + host.removeAttribute("data-hf-original-composition-id"); + } + + runtimeCompositionId = duplicateInstance + ? uniqueCompositionId(authoredCompositionId, instanceIndex) + : authoredCompositionId; + host.setAttribute("data-composition-id", runtimeCompositionId); + } + identities.set(host, { + authoredCompositionId, + runtimeCompositionId, + }); + } + + return identities; +} + +function parseHostVariableValues(host: Element): Record { + const raw = host.getAttribute("data-variable-values"); + if (!raw) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as Record; +} + +const FLATTENED_INNER_ROOT_STRIP_ATTRS = [ + "data-composition-id", + "data-composition-file", + "data-start", + "data-duration", + "data-end", + "data-track-index", + "data-track", + "data-composition-src", + "data-hf-authored-duration", + "data-hf-authored-end", +]; + +function prepareFlattenedInnerRoot(innerRoot: Element): Element { + const prepared = innerRoot.cloneNode(true) as Element; + const authoredRootId = prepared.getAttribute("id")?.trim(); + for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) { + prepared.removeAttribute(attrName); + } + if (authoredRootId) { + prepared.removeAttribute("id"); + prepared.setAttribute("data-hf-authored-id", authoredRootId); + } + prepared.setAttribute("data-hf-inner-root", "true"); + return prepared; +} + function enforceCompositionPixelSizing(document: Document): void { const compositionEls = [ ...document.querySelectorAll("[data-composition-id][data-width][data-height]"), @@ -452,14 +585,12 @@ export async function bundleToSingleHtml( const compStyleChunks: string[] = []; const compScriptChunks: string[] = []; const compExternalScriptSrcs: string[] = []; - const subCompositionHosts = [...document.querySelectorAll("[data-composition-src]")]; - const hostCountsByCompositionId = new Map(); - for (const hostEl of subCompositionHosts) { - const compId = (hostEl.getAttribute("data-composition-id") || "").trim(); - if (!compId) continue; - hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1); - } - const hostInstanceByCompositionId = new Map(); + const compVariablesByComp: Record> = {}; + const trackedCompositionHosts = getBundledTrackedCompositionHosts(document); + const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts); + const subCompositionHosts = trackedCompositionHosts.filter((host) => + host.hasAttribute("data-composition-src"), + ); for (const hostEl of subCompositionHosts) { const src = hostEl.getAttribute("data-composition-src"); if (!src || !isRelativeUrl(src)) continue; @@ -471,7 +602,9 @@ export async function bundleToSingleHtml( } const compDoc = parseHTMLContent(compHtml); - const compId = hostEl.getAttribute("data-composition-id"); + const hostIdentity = hostIdentityByElement.get(hostEl); + const compId = hostIdentity?.authoredCompositionId || null; + const runtimeCompId = hostIdentity?.runtimeCompositionId || compId || ""; const contentRoot = compDoc.querySelector("template"); const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || ""; const contentDoc = parseHTMLContent(contentHtml); @@ -479,22 +612,19 @@ export async function bundleToSingleHtml( ? contentDoc.querySelector(`[data-composition-id="${compId}"]`) : contentDoc.querySelector("[data-composition-id]"); const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || ""; + const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; const scopeCompId = compId || inferredCompId; - const duplicateInstance = scopeCompId && (hostCountsByCompositionId.get(scopeCompId) || 0) > 1; - const instanceIndex = duplicateInstance - ? (hostInstanceByCompositionId.get(scopeCompId) || 0) + 1 - : 0; - if (duplicateInstance) hostInstanceByCompositionId.set(scopeCompId, instanceIndex); - const runtimeCompId = - duplicateInstance && scopeCompId - ? uniqueCompositionId(scopeCompId, instanceIndex) - : scopeCompId; const runtimeScope = runtimeCompId ? cssAttributeSelector("data-composition-id", runtimeCompId) : ""; - if (duplicateInstance && runtimeCompId) { - hostEl.setAttribute("data-hf-original-composition-id", scopeCompId); - hostEl.setAttribute("data-composition-id", runtimeCompId); + const mergedVariables = runtimeCompId + ? { + ...readDeclaredDefaults(compDoc.documentElement), + ...parseHostVariableValues(hostEl), + } + : {}; + if (runtimeCompId && Object.keys(mergedVariables).length > 0) { + compVariablesByComp[runtimeCompId] = mergedVariables; } // When a sub-composition is a full HTML document (no