mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* 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
629 lines
26 KiB
TypeScript
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,
|
|
};
|
|
}
|