Files
hyperframes/packages/core/src/compiler/inlineSubCompositions.ts
T
Miguel Ángel dce0acd6d2 perf(studio): tag each scene's parts in the preview so one scene can be swapped (#4503)
* perf(studio): tag each scene's parts in the preview so one can be swapped

With sceneParts (Studio preview only) the bundler tags each top-level scene's host, styles and
inline scripts with data-hf-scene; nested scenes belong to their top-level scene. The preview
route then adds a manifest: a hash of everything outside the scenes and one hash per scene,
so the preview can tell when a rebuilt document differs only inside some scenes. Renders are
unchanged.

* fix(core): keep scene order in the preview manifest and mark unswappable scenes

A moved or reordered scene now changes the shared hash, a nested scene
keeps its @import first, and a scene whose script leaves work running
is marked so edits to it take the full reload.

* refactor(core): keep the scene swap side-effect check in the bundler

* fix(core): refuse swapping a scene that runs its own script file

A swap never re-runs external scripts, so a scene file keeps driving the
replaced nodes. Library URLs stay swappable.

* fix(core): refuse swaps for async scene setup and keep the shared @import first

Scenes that finish building after an await or a .then, animate elements
directly or touch the page head and body now take the full reload, and
splitting out scene styles no longer leaves the shared import late.

* fix(core): refuse swaps for scene scripts that throw when run again

Custom element and CSS property registration, sockets, draggables and
infinite tweens now refuse too, and ticker or animate inside a name no
longer does.

* fix(core): refuse swapping a scene that runs a module script or import map

* fix(core): run a scene's local script file in source order in the preview

* fix(core): refuse a scene swap only for defineProperty on a global

* refactor(core): cut scene swap comments to what names and types do not say

* fix(core): find the head tag for the scene manifest in linear time

* fix(core): leave a document with an unclosed head tag without a scene manifest

* fix(core): keep preview scene parts in render order and refuse scene-only script URLs

* fix(core): keep render @import precedence in the preview and swap only known libraries

* fix(core): keep preview scene styles where a render merges them and resolve library URLs

* fix(core): scene swap refuses standalone gsap tweens and root scripts that hold scene nodes

* fix(core): refuse to swap exactly the scenes a script outside them selects

* perf(core): the preview scene scan looks up ids and classes once instead of querying per string

* docs(core): trim three scene-parts comments to the facts a reader needs

* fix(core): refuse to swap a scene a script outside it reaches by tag name

* fix(core): keep scenes swappable when a script outside them only creates elements by tag

* test(core): check scene tags on elements, not in the inlined runtime's text

* fix(core): refuse swaps for chained scene timelines and any-case or universal root selectors

* fix(core): refuse swaps for root selectors through tags above the scenes

* fix(core): put the scene manifest inside the head when a head attribute holds a quoted >

* fix(core): place the scene manifest with the shared head scanner instead of a regex
2026-09-26 20:23:13 +00:00

629 lines
26 KiB
TypeScript

import { readExternalScriptAttributes, type ExternalScriptAttributes } from "./externalScripts";
import { parseImportMap, type ImportMap } from "./importMaps";
/**
* Shared sub-composition inlining logic.
*
* Both the core bundler (preview) and the producer compiler (render) need to
* inline sub-composition HTML referenced via `data-composition-src`. This
* module is the single source of truth for that transformation, eliminating
* divergence that previously caused bugs (e.g. producer not setting
* `data-composition-file`).
*/
import {
rewriteAssetPath,
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
type AssetExists,
} from "./rewriteSubCompPaths";
import { warnUnknownEnumValues } from "../runtime/getVariables";
import {
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
scopedModulePrelude,
wrapScopedCompositionScript,
} from "./compositionScoping";
import {
checkSubCompositionUsability,
resolveSubCompositionContent,
} from "@hyperframes/parsers/sub-composition-validity";
import {
enumerateNestedCompositionHosts,
planCompositionAssembly,
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
} from "./compositionAssembly";
import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts";
// Anything a scene script can leave running, pending or registered outside its timeline, or that
// throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse.
const SIDE_EFFECT_RE =
/\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.animate\s*\(|\.ticker\b|repeat\s*:\s*-1|\.repeat\s*\(\s*-1|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\bfonts\s*\.\s*add\b|\.on[a-z]+\s*=(?!=)|\bon(resize|scroll|message|key\w+|click|pointer\w+|mouse\w+|wheel|visibilitychange|hashchange|popstate|error|load)\s*=(?!=)|\[\s*["']on[a-z]+["']\s*\]|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|\bgsap\s*\.\s*(?:to|from|fromTo)\s*\(|\bgsap\s*\.\s*timeline\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:to|from|fromTo|set|add|call)\s*\(/;
// npm packages that only define globals when loaded; lottie-web is absent because it scans the page on load.
const SWAP_SAFE_LIBRARY_URL =
/^https:\/\/(cdn\.jsdelivr\.net\/npm|unpkg\.com)\/(gsap|three|d3|d3-[a-z-]+|topojson-client|clipper-lib)(@[^/]+)?\//;
const isSwapSafeLibrary = (src: string) =>
URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href);
/** Why an authored scene script cannot be swapped out cleanly, or null when it can. */
function sceneScriptSwapRefusal(script: string): string | null {
const match = SIDE_EFFECT_RE.exec(script);
return match ? `its script uses ${match[0].trim()}` : null;
}
// A quoted string in a script, in any of the three quote styles.
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g;
// Text ending where createElement takes its tag: that string makes a node, it selects none.
const CREATES_ELEMENT_RE = /createElement\s*\(\s*$|createElementNS\s*\([^()]*,\s*$/;
// A "*" after a comma is a later argument, like postMessage's target origin; selector calls take it first.
const isNotASelector = (script: string, at: number, literal: string) =>
CREATES_ELEMENT_RE.test(script.slice(Math.max(0, at - 60), at)) ||
(literal.trim() === "*" && /,\s*$/.test(script.slice(Math.max(0, at - 20), at)));
/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would strand it. */
export function refuseSwapsReachedByRootScripts(document: Document, rootScripts: string[]): void {
const hosts = [...document.querySelectorAll(`[${SCENE_PART_ATTR}]`)];
const byName = new Map<string, Set<Element>>();
for (const host of hosts) {
for (const el of [host, ...host.querySelectorAll("*")]) {
for (const name of [
el.localName,
el.id && `#${el.id}`,
...[...el.classList].map((c) => `.${c}`),
]) {
if (name) byName.set(name, (byName.get(name) ?? new Set()).add(host));
}
}
}
// A selector naming a tag the document lacks cannot match, so it is not worth a query.
const namesKnown = (selector: string) =>
selector
.split(",")
.some((alt) =>
alt
.split(/[\s>+~]+/)
.every(
(part) =>
!/^[a-z][\w-]*$/i.test(part) ||
byName.has(part.toLowerCase()) ||
document.querySelector(part) !== null,
),
);
const literals = new Set(
rootScripts.flatMap((s) =>
[...s.matchAll(STRING_LITERAL_RE)]
.filter((m) => !isNotASelector(s, m.index, m[2] ?? ""))
.map((m) => m[2] ?? ""),
),
);
for (const literal of literals) {
const open = hosts.filter((host) => !host.hasAttribute(SCENE_NO_SWAP_ATTR));
if (open.length === 0) return;
// Tag names match in any case; ids and classes do not.
const reached =
literal.trim() === "*"
? open
: /^[A-Za-z_][\w-]*$/.test(literal)
? [literal.toLowerCase(), `#${literal}`, `.${literal}`].flatMap((name) => [
...(byName.get(name) ?? []),
])
: /[#.[:>]|[\w\]*] *[\s,]+ *[\w*]/.test(literal) &&
literal.length <= 120 &&
namesKnown(literal)
? open.filter((host) => reaches(host, literal))
: [];
for (const host of reached)
if (!host.hasAttribute(SCENE_NO_SWAP_ATTR))
host.setAttribute(SCENE_NO_SWAP_ATTR, `a script outside the scene selects ${literal}`);
}
}
const reaches = (host: Element, selector: string) => {
try {
return host.matches(selector) || host.querySelector(selector) !== null;
} catch {
return false;
}
};
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface InlineSubCompositionsOptions {
/**
* Resolve the HTML content for a sub-composition given its `data-composition-src` value.
* Return `null` when the file cannot be found.
*/
resolveHtml: (srcPath: string) => string | null;
/**
* Parse an HTML string into a Document. The returned object must expose
* standard DOM APIs (querySelector, querySelectorAll, body, head, etc.).
* Both linkedom's `parseHTML(...).document` and the core bundler's
* `parseHTMLContent(...)` satisfy this contract.
*/
parseHtml: (html: string) => Document;
/**
* Identity map produced by `assignBundledRuntimeCompositionIds`.
* When provided, authoredCompositionId and runtimeCompositionId are read
* from this map instead of from the host element's attributes directly.
* The bundler uses this; the producer can omit it.
*/
hostIdentityMap?: Map<
Element,
{ authoredCompositionId: string | null; runtimeCompositionId: string | null }
>;
/**
* When true, rewrite `url(...)` references in inline `style` attributes
* on sub-composition elements. The bundler enables this; the producer
* can skip it.
*/
rewriteInlineStyles?: boolean;
/**
* Prepare the inner root element before injecting it into the host.
* The bundler's `prepareFlattenedInnerRoot` clones the element, strips
* timing attributes, and adds `data-hf-inner-root`. When omitted, the
* inner root's outerHTML is injected as-is.
*/
flattenInnerRoot?: (innerRoot: Element) => Element;
/**
* When true, CSS selectors targeting the authored root use a compound
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
* Enable this in the producer path where the inner root merges onto
* the host element via innerHTML — both attributes end up on the same
* element and a descendant selector won't match.
*/
compoundAuthoredRoot?: boolean;
/**
* Read declared variable defaults from a sub-composition's `<html>` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
*/
readVariableDefaults?: (docElement: Element) => Record<string, unknown>;
/**
* Parse host-level variable overrides from `data-variable-values`.
* The bundler passes `parseHostVariableValues`; the producer can omit this.
*/
parseHostVariables?: (host: Element) => Record<string, unknown>;
/**
* Build a CSS attribute selector for scoping, e.g.
* `[data-composition-id="my-comp"]`. Defaults to a simple implementation
* when not provided. The bundler passes `cssAttributeSelector` which
* handles escaping.
*/
buildScopeSelector?: (compId: string) => string;
/**
* Error label prefix used in wrapped composition scripts.
* Defaults to `"[HyperFrames] composition script error:"`.
*/
scriptErrorLabel?: string;
/**
* Probe for "does this project-root-relative path exist?". Supplied by
* callers that can see the filesystem so a sub-composition's SIBLING asset
* refs (`<link href="_shared.css">` next to the composition) resolve against
* its own directory instead of 404ing at the project root. Omit it and plain
* relative paths pass through unchanged. See `AssetExists`.
*/
assetExists?: AssetExists;
/**
* Log a warning when a composition file cannot be resolved. `reason` is a
* short, human-readable explanation (e.g. "the file is empty (0 bytes or
* whitespace-only)") from `checkSubCompositionUsability` — present for
* every skip except when `resolveHtml` returns `null` (file not found,
* which callers detect themselves before calling `resolveHtml`).
* Defaults to `console.warn`.
*/
onMissingComposition?: (srcPath: string, reason?: string) => void;
/** Tag each top-level host with `data-hf-scene` and report every part's scene, for preview swaps. */
tagScenes?: boolean;
}
export interface InlineSubCompositionsResult {
styles: string[];
/** With `tagScenes`: the scene each entry of `styles` belongs to. */
styleScenes: string[];
scripts: string[];
externalScriptSrcs: string[];
scriptItems: Array<
| { kind: "inline"; content: string; scene?: string }
| ({ kind: "external"; src: string; scene?: string } & ExternalScriptAttributes)
>;
externalLinks: { href: string; rel: string; crossorigin?: string }[];
variablesByComp: Record<string, Record<string, unknown>>;
/** Mounted files' import maps, addresses rebased; emit with `emitMountedModuleScripts`. */
importMaps: ImportMap[];
/** Mounted files' inline module scripts, each already carrying its scoped `__hyperframes`. */
moduleScripts: string[];
}
// ---------------------------------------------------------------------------
// Default helpers
// ---------------------------------------------------------------------------
function defaultBuildScopeSelector(compId: string): string {
const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[data-composition-id="${escaped}"]`;
}
// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
/**
* Inline sub-compositions into a document. For each host element in `hosts`:
*
* 1. Resolve the sub-composition HTML via `options.resolveHtml`
* 2. Parse it, resolve template, body, or bare-fragment content
* 3. Find the inner `[data-composition-id]` root
* 4. Extract `<style>` elements, scope CSS, collect them
* 5. Extract `<script>` elements, wrap inline scripts, collect them
* 6. Collect external script `src` URLs for deduplication
* 7. Rewrite asset paths (and optionally inline-style asset URLs)
* 8. Copy dimension attrs from inner root to host if missing
* 9. Set `data-composition-file` on host
* 10. Remove `data-composition-src` from host
* 11. Inject the content into the host element
*/
// fallow-ignore-next-line complexity
export function inlineSubCompositions(
document: Document,
hosts: Element[],
options: InlineSubCompositionsOptions,
): InlineSubCompositionsResult {
const {
resolveHtml,
parseHtml,
hostIdentityMap,
rewriteInlineStyles = false,
flattenInnerRoot,
compoundAuthoredRoot,
readVariableDefaults,
parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector,
scriptErrorLabel = "[HyperFrames] composition script error:",
onMissingComposition,
assetExists,
tagScenes = false,
} = options;
const styles: string[] = [];
const styleScenes: string[] = [];
const scripts: string[] = [];
const externalScriptSrcs: string[] = [];
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
const importMaps: ImportMap[] = [];
const moduleScripts: string[] = [];
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
const seenLinkHrefs = new Set<string>();
const variablesByComp: Record<string, Record<string, unknown>> = {};
const sceneHosts = new Map<string, Element>();
const queue = hosts.map((element) => ({
element,
ancestry: [] as string[],
scene: undefined as string | undefined,
}));
for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
const { element: hostEl, ancestry, scene: parentScene } = queue[queueIndex]!;
const src = hostEl.getAttribute("data-composition-src");
if (!src) continue;
const compHtml = resolveHtml(src);
// Shared with lint + render pre-flight (@hyperframes/parsers'
// subCompositionValidity.ts) so all three callers agree on what counts
// as a usable sub-composition file. This path stays intentionally
// tolerant (skip, don't throw) — preview and studio must keep bundling
// around a scene that's still being authored. Lint and the render
// pre-flight check use the same helper to fail loudly instead.
const validity = checkSubCompositionUsability(compHtml, parseHtml);
if (!validity.ok) {
onMissingComposition?.(src, validity.detail);
continue;
}
if (compHtml == null) {
// Unreachable in practice — checkSubCompositionUsability's "empty"
// reason already covers null/undefined — but this lets TypeScript
// narrow compHtml to `string` below without an `as T` assertion.
onMissingComposition?.(src);
continue;
}
const compDoc = parseHtml(compHtml);
// Determine composition IDs
let compId: string | null;
let runtimeCompId: string;
if (hostIdentityMap) {
const identity = hostIdentityMap.get(hostEl);
compId = identity?.authoredCompositionId || null;
runtimeCompId = identity?.runtimeCompositionId || compId || "";
} else {
compId = hostEl.getAttribute("data-composition-id") || null;
runtimeCompId = compId || "";
}
const { contentHtml, hasTemplate } = resolveSubCompositionContent(compHtml, compDoc);
if (!contentHtml.trim()) {
onMissingComposition?.(src);
continue;
}
const contentDoc = parseHtml(contentHtml);
if (!contentDoc.documentElement) {
onMissingComposition?.(src);
continue;
}
// Which node is the composition root, which id its CSS scopes to, which
// id its scripts scope to, where its assets come from and in what order —
// every one of those is decided by the shared assembly module, so the mount
// path in runtime/compositionLoader.ts decides them the same way.
const plan = planCompositionAssembly<Element>({
contentNode: contentDoc,
head: compDoc.head,
documentElement: compDoc.documentElement,
hasTemplate,
compositionId: compId,
});
const innerRoot = plan.innerRoot;
const authoredRootId = plan.authoredRootId;
const scopeCompId = plan.authoredCompositionId || "";
const scriptCompositionId = plan.scriptCompositionId || "";
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
const scene = tagScenes ? (parentScene ?? (runtimeCompId || src)) : undefined;
if (scene && !parentScene) {
hostEl.setAttribute(SCENE_PART_ATTR, scene);
sceneHosts.set(scene, hostEl);
}
// Lazy, so renders (no scene hosts) never run the refusal checks.
const refuseSwap = (why: () => string | null) => {
const host = scene ? sceneHosts.get(scene) : undefined;
if (!host || host.hasAttribute(SCENE_NO_SWAP_ATTR)) return;
const reason = why();
if (reason) host.setAttribute(SCENE_NO_SWAP_ATTR, reason);
};
// Variable merging (bundler feature). Read declared defaults from the
// document element (full-document sub-comps) AND the inner composition root
// (template/fragment sub-comps store their schema on the root div, not a
// synthetic <html>), then let per-instance host values override.
if (readVariableDefaults && parseHostVariables && runtimeCompId) {
const mergedVariables: Record<string, unknown> = {};
for (const carrier of plan.variableDefaultCarriers) {
Object.assign(mergedVariables, readVariableDefaults(carrier));
}
Object.assign(mergedVariables, parseHostVariables(hostEl));
if (Object.keys(mergedVariables).length > 0) {
variablesByComp[runtimeCompId] = mergedVariables;
}
// Compile time is the only place this defect is visible on the sub-comp
// path: the instance value is baked into `__hfVariablesByComp` right
// here, and the scoped `getVariables` shim only reads that table, so the
// runtime's identical guard never runs. Same helper, so the message and
// the per-process dedupe set are shared with the runtime path and the
// author sees one warning either way.
warnUnknownEnumValues(compDoc.documentElement, mergedVariables, runtimeCompId);
warnUnknownEnumValues(innerRoot, mergedVariables, runtimeCompId);
}
// `<head>` <link>/<script src> are hoisted into the ROOT document, so they
// need the same directory rewrite the body's [src]/[href] pass applies —
// without it even the documented `../` form escapes the project and 404s.
const resolveSubAssetPath = (raw: string | null): string =>
rewriteAssetPath(src, (raw || "").trim(), assetExists);
// Scope one sub-composition <style> body. scopeRootSelectors keeps the
// sub-comp's html/body/:root rules from clobbering the host document (they
// are remapped to the composition box); see compositionScoping.
const scopeSubStyle = (raw: string): string => {
const css = rewriteCssAssetUrls(raw, src, assetExists);
return scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
scopeRootSelectors: true,
})
: css;
};
// <link> hoisting is unconditional. A templated sub-composition's webfont
// link is as load-bearing as a non-templated one's, and the mount path has
// always hoisted both; gating this on `!hasTemplate` dropped a templated
// composition's font from the render while preview kept it.
for (const link of plan.linkSources) {
const href = resolveSubAssetPath(link.getAttribute("href"));
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
const crossorigin = link.hasAttribute("crossorigin")
? link.getAttribute("crossorigin") || ""
: undefined;
externalLinks.push({ href, rel, crossorigin });
}
}
// Head-sourced assets come first: a non-templated sub-composition's <head>
// carries its backgrounds, positioning and fonts, and a <head> library tag
// (GSAP from a CDN) has to run before the content scripts calling into it.
for (const styleEl of plan.styleSources) {
styles.push(scopeSubStyle(styleEl.textContent || ""));
if (scene) styleScenes.push(scene);
styleEl.remove();
}
// Head- and content-sourced scripts take the same branch.
for (const scriptEl of plan.scriptSources) {
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
const type = (scriptEl.getAttribute("type") || "").trim().toLowerCase();
// A swap never re-runs external or module scripts: only a known library that just defines globals is safe.
refuseSwap(() =>
type === "importmap" || type === "module"
? "it runs a module script or import map"
: !externalSrc
? sceneScriptSwapRefusal(scriptEl.textContent || "")
: isSwapSafeLibrary(externalSrc)
? null
: "it runs a script that is not a known library",
);
if (type === "importmap") {
const map = parseImportMap(scriptEl.textContent || "", (url) => {
// The rebase drops a leading "./" and a trailing "/"; an import map address needs both.
const authored = url.trim();
const rebased = resolveSubAssetPath(authored);
if (rebased === authored) return rebased;
const urlLike = /^(\/|\.\.?\/|[a-z][a-z\d+.-]*:)/i.test(rebased)
? rebased
: `./${rebased}`;
return authored.endsWith("/") && !urlLike.endsWith("/") ? `${urlLike}/` : urlLike;
});
if (map) importMaps.push(map);
else console.warn(`[HyperFrames] ${src}: import map is not valid JSON, so it is skipped.`);
} else if (type === "module" && !externalSrc) {
const prelude = scriptCompositionId
? scopedModulePrelude(runtimeCompId || scopeCompId || scriptCompositionId, src)
: "";
moduleScripts.push(prelude + (scriptEl.textContent || ""));
} else if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
}
scriptItems.push({
kind: "external",
src: externalSrc,
...readExternalScriptAttributes(scriptEl),
...(scene ? { scene } : {}),
});
} else {
const wrappedScript = scriptCompositionId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
scriptCompositionId,
scriptErrorLabel,
runtimeScope || undefined,
runtimeCompId || scopeCompId || scriptCompositionId,
authoredRootId,
src,
)
: wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
scripts.push(wrappedScript);
scriptItems.push({ kind: "inline", content: wrappedScript, ...(scene ? { scene } : {}) });
}
scriptEl.remove();
}
// Rewrite relative asset paths before inlining so ../foo.svg from
// compositions/ resolves correctly when the content moves to root.
const assetEls = innerRoot
? innerRoot.querySelectorAll("[src], [href]")
: contentDoc.querySelectorAll("[src], [href]");
rewriteAssetPaths(
assetEls,
src,
(el: Element, attr: string) => el.getAttribute(attr),
(el: Element, attr: string, val: string) => {
el.setAttribute(attr, val);
},
assetExists,
);
if (rewriteInlineStyles) {
const styledEls = innerRoot
? innerRoot.querySelectorAll("[style]")
: contentDoc.querySelectorAll("[style]");
rewriteInlineStyleAssetUrls(
styledEls,
src,
(el: Element) => el.getAttribute("style"),
(el: Element, val: string) => {
el.setAttribute("style", val);
},
assetExists,
);
}
if (innerRoot?.hasAttribute("data-timeline-locked")) {
hostEl.setAttribute("data-timeline-locked", "");
}
// Copy dimension attributes from inner root to host if missing
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
if (innerH && !hostEl.getAttribute("data-height")) {
hostEl.setAttribute("data-height", innerH);
}
}
// Inject content into the host element
if (innerRoot) {
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
child.remove();
if (flattenInnerRoot) {
const prepared = flattenInnerRoot(innerRoot);
if (!compId && scopeCompId) {
// Anonymous host: flattenInnerRoot strips data-composition-id,
// assuming the host already carries the composition's identity.
// When the host has none, nothing in the render DOM matches the
// composition's own root-styling CSS or self-referencing scripts
// (e.g. document.querySelector('[data-composition-id="X"]')).
// Restore it on the wrapper so both keep resolving, same as
// before flattening preserved it via outerHTML.
prepared.setAttribute("data-composition-id", scopeCompId);
}
hostEl.innerHTML = prepared.outerHTML || "";
} else {
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
// When the producer path strips the inner root (innerHTML), the
// authored id attribute is lost. Propagate it to the host so that
// rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
if (compId && authoredRootId) {
hostEl.setAttribute("data-hf-authored-id", authoredRootId);
}
}
} else {
for (const child of [...contentDoc.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
child.remove();
// linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
// the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
// to preserve the composition wrapper.
const bodyHtml = contentDoc.body?.innerHTML || "";
hostEl.innerHTML = bodyHtml || contentDoc.documentElement?.outerHTML || "";
}
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
const nestedAncestry = [...ancestry, src];
const nested = enumerateNestedCompositionHosts(hostEl, nestedAncestry);
for (const skipped of nested.skipped) {
onMissingComposition?.(skipped.src, skipped.reason);
}
for (const nestedHost of nested.hosts) {
queue.push({ element: nestedHost.host, ancestry: nestedAncestry, scene });
}
}
return {
styles,
styleScenes,
scripts,
externalScriptSrcs,
scriptItems,
externalLinks,
variablesByComp,
importMaps,
moduleScripts,
};
}