mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
The prepareFlattenedInnerRoot function creates a wrapper div when inlining sub-compositions. This wrapper had no width/height, which broke CSS height:100% chains — any sub-composition using percentage heights with flexbox centering would collapse to 0px and render content at the top instead of centered. Read data-width/data-height from the inner root and set matching pixel dimensions on the wrapper's inline style. Applied in both the compiler (server-side bundling) and the runtime (browser-side composition loader). Adds a producer regression test with a centered card sub-composition that fails without this fix.
643 lines
23 KiB
TypeScript
643 lines
23 KiB
TypeScript
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
|
import { readDeclaredDefaults } from "./getVariables";
|
|
|
|
type LoadExternalCompositionsParams = {
|
|
injectedStyles: HTMLStyleElement[];
|
|
injectedScripts: HTMLScriptElement[];
|
|
parseDimensionPx: (value: string | null) => string | null;
|
|
onDiagnostic?: (payload: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
}) => void;
|
|
};
|
|
|
|
type PendingScript =
|
|
| {
|
|
kind: "inline";
|
|
content: string;
|
|
type: string;
|
|
scopeCompositionId: string | null;
|
|
}
|
|
| {
|
|
kind: "external";
|
|
src: string;
|
|
type: string;
|
|
};
|
|
|
|
const EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS = 8000;
|
|
const BARE_RELATIVE_PATH_RE = /^(?![a-zA-Z][a-zA-Z\d+\-.]*:)(?!\/\/)(?!\/)(?!\.\.?\/).+/;
|
|
|
|
function uniqueCompositionId(baseId: string, index: number): string {
|
|
return `${baseId}__hf${index}`;
|
|
}
|
|
|
|
const waitForExternalScriptLoad = (
|
|
scriptEl: HTMLScriptElement,
|
|
): Promise<{ status: "load" | "error" | "timeout"; elapsedMs: number }> =>
|
|
new Promise((resolve) => {
|
|
let settled = false;
|
|
const startedAt = Date.now();
|
|
let timeoutId: number | null = null;
|
|
const settle = (status: "load" | "error" | "timeout") => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeoutId != null) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
resolve({
|
|
status,
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
});
|
|
};
|
|
scriptEl.addEventListener("load", () => settle("load"), { once: true });
|
|
scriptEl.addEventListener("error", () => settle("error"), { once: true });
|
|
timeoutId = window.setTimeout(() => settle("timeout"), EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS);
|
|
});
|
|
|
|
function resetCompositionHost(host: Element) {
|
|
while (host.firstChild) {
|
|
host.removeChild(host.firstChild);
|
|
}
|
|
host.textContent = "";
|
|
}
|
|
|
|
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: HTMLElement): HTMLElement {
|
|
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
|
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");
|
|
const w = prepared.getAttribute("data-width");
|
|
const h = prepared.getAttribute("data-height");
|
|
prepared.style.width = w ? `${w}px` : "100%";
|
|
prepared.style.height = h ? `${h}px` : "100%";
|
|
return prepared;
|
|
}
|
|
|
|
function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): string {
|
|
const trimmedSrc = scriptSrc.trim();
|
|
if (!trimmedSrc) return scriptSrc;
|
|
try {
|
|
if (BARE_RELATIVE_PATH_RE.test(trimmedSrc)) {
|
|
// Composition payloads may use root-relative semantics without a leading slash.
|
|
return new URL(trimmedSrc, document.baseURI).toString();
|
|
}
|
|
if (compositionUrl) {
|
|
return new URL(trimmedSrc, compositionUrl).toString();
|
|
}
|
|
return new URL(trimmedSrc, document.baseURI).toString();
|
|
} catch {
|
|
return scriptSrc;
|
|
}
|
|
}
|
|
|
|
function parseHostVariableValues(host: Element): Record<string, unknown> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
type HostCompositionIdentity = {
|
|
authoredCompositionId: string | null;
|
|
runtimeCompositionId: string | null;
|
|
};
|
|
|
|
function getHostCompositionIdentity(host: Element): HostCompositionIdentity {
|
|
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 countAuthoredCompositionIds(hosts: Element[]): Map<string, number> {
|
|
const hostCountsByCompositionId = new Map<string, number>();
|
|
for (const host of hosts) {
|
|
const compId = getHostCompositionIdentity(host).authoredCompositionId || "";
|
|
if (!compId) continue;
|
|
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
|
|
}
|
|
return hostCountsByCompositionId;
|
|
}
|
|
|
|
function hasMatchingInlineTemplate(host: Element): boolean {
|
|
const authoredCompositionId = getHostCompositionIdentity(host).authoredCompositionId;
|
|
if (!authoredCompositionId) return false;
|
|
return !!document.querySelector(`template#${CSS.escape(authoredCompositionId)}-template`);
|
|
}
|
|
|
|
function isMountedInlineCompositionHost(host: Element): boolean {
|
|
return !!host.querySelector('[data-hf-inner-root="true"]');
|
|
}
|
|
|
|
function shouldAssignRuntimeCompositionId(host: Element): boolean {
|
|
if (host.hasAttribute("data-composition-src")) return true;
|
|
if (!hasMatchingInlineTemplate(host)) return false;
|
|
if (host.children.length === 0) return true;
|
|
if (host.hasAttribute("data-hf-original-composition-id")) return true;
|
|
return isMountedInlineCompositionHost(host);
|
|
}
|
|
|
|
function getTrackedCompositionHosts(): Element[] {
|
|
const hosts = Array.from(
|
|
document.querySelectorAll<Element>("[data-composition-src], [data-composition-id]"),
|
|
);
|
|
return hosts.filter((host) => {
|
|
if (host.hasAttribute("data-composition-src")) return true;
|
|
return hasMatchingInlineTemplate(host);
|
|
});
|
|
}
|
|
|
|
function cleanupDetachedScopedVariables() {
|
|
const byComp = window.__hfVariablesByComp;
|
|
if (!byComp) return;
|
|
|
|
const activeRuntimeCompositionIds = new Set(
|
|
getTrackedCompositionHosts()
|
|
.map((host) => getHostCompositionIdentity(host).runtimeCompositionId)
|
|
.filter((compositionId): compositionId is string => !!compositionId),
|
|
);
|
|
|
|
for (const runtimeCompositionId of Object.keys(byComp)) {
|
|
if (!activeRuntimeCompositionIds.has(runtimeCompositionId)) {
|
|
delete byComp[runtimeCompositionId];
|
|
}
|
|
}
|
|
}
|
|
|
|
function assignRuntimeCompositionIds(
|
|
hosts: Element[],
|
|
hostCountsByCompositionId: Map<string, number> = countAuthoredCompositionIds(hosts),
|
|
): Map<Element, HostCompositionIdentity> {
|
|
const hostInstanceByCompositionId = new Map<string, number>();
|
|
const hostIdentityByElement = new Map<Element, HostCompositionIdentity>();
|
|
|
|
for (const host of hosts) {
|
|
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
|
|
getHostCompositionIdentity(host);
|
|
const shouldAssign = shouldAssignRuntimeCompositionId(host);
|
|
if (!authoredCompositionId) {
|
|
hostIdentityByElement.set(host, {
|
|
authoredCompositionId: null,
|
|
runtimeCompositionId: previousRuntimeCompositionId,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const duplicateInstance = (hostCountsByCompositionId.get(authoredCompositionId) || 0) > 1;
|
|
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
|
|
if (shouldAssign) {
|
|
const instanceIndex = duplicateInstance
|
|
? (hostInstanceByCompositionId.get(authoredCompositionId) || 0) + 1
|
|
: 0;
|
|
if (duplicateInstance) {
|
|
hostInstanceByCompositionId.set(authoredCompositionId, instanceIndex);
|
|
}
|
|
|
|
runtimeCompositionId = duplicateInstance
|
|
? uniqueCompositionId(authoredCompositionId, instanceIndex)
|
|
: authoredCompositionId;
|
|
|
|
if (duplicateInstance) {
|
|
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
|
|
} else {
|
|
host.removeAttribute("data-hf-original-composition-id");
|
|
}
|
|
host.setAttribute("data-composition-id", runtimeCompositionId);
|
|
if (
|
|
previousRuntimeCompositionId &&
|
|
previousRuntimeCompositionId !== runtimeCompositionId &&
|
|
window.__hfVariablesByComp
|
|
) {
|
|
delete window.__hfVariablesByComp[previousRuntimeCompositionId];
|
|
}
|
|
}
|
|
|
|
hostIdentityByElement.set(host, {
|
|
authoredCompositionId,
|
|
runtimeCompositionId,
|
|
});
|
|
}
|
|
|
|
return hostIdentityByElement;
|
|
}
|
|
|
|
async function mountCompositionContent(params: {
|
|
host: Element;
|
|
authoredCompositionId: string | null;
|
|
runtimeCompositionId: string | null;
|
|
hostCompositionSrc: string;
|
|
sourceNode: ParentNode;
|
|
hasTemplate: boolean;
|
|
fallbackBodyInnerHtml: string;
|
|
compositionUrl: URL | null;
|
|
injectedStyles: HTMLStyleElement[];
|
|
injectedScripts: HTMLScriptElement[];
|
|
parseDimensionPx: (value: string | null) => string | null;
|
|
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
|
|
headStyles?: HTMLStyleElement[];
|
|
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
|
headScripts?: HTMLScriptElement[];
|
|
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
|
|
headLinks?: HTMLLinkElement[];
|
|
/**
|
|
* Defaults extracted from the sub-composition's own
|
|
* `<html data-composition-variables="...">` attribute. Layered under the
|
|
* host element's `data-variable-values` to produce the per-instance
|
|
* variables visible inside the sub-comp's scoped `getVariables()`.
|
|
* Populated only by `loadExternalCompositions`; inline templates have no
|
|
* separate document root so no declared defaults are passed.
|
|
*/
|
|
declaredVariableDefaults?: Record<string, unknown>;
|
|
onDiagnostic?: (payload: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
}) => void;
|
|
}): Promise<void> {
|
|
let innerRoot: Element | null = null;
|
|
if (params.authoredCompositionId) {
|
|
const candidateRoots = Array.from(
|
|
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
|
|
);
|
|
innerRoot =
|
|
candidateRoots.find(
|
|
(candidate) =>
|
|
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
|
|
) ?? null;
|
|
}
|
|
const contentNode = innerRoot ?? params.sourceNode;
|
|
const authoredScopeCompositionId =
|
|
innerRoot?.getAttribute("data-composition-id")?.trim() || params.authoredCompositionId || null;
|
|
const runtimeScopeCompositionId =
|
|
params.runtimeCompositionId || authoredScopeCompositionId || null;
|
|
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
|
const runtimeScopeSelector = runtimeScopeCompositionId
|
|
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
|
|
: undefined;
|
|
|
|
if (params.headLinks) {
|
|
for (const link of params.headLinks) {
|
|
const href = link.getAttribute("href") || "";
|
|
if (!href) continue;
|
|
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
|
document.head.appendChild(link.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
// Inject <head> styles from non-template sub-compositions first (they define
|
|
// element styles like backgrounds and positioning that the composition needs).
|
|
if (params.headStyles) {
|
|
for (const style of params.headStyles) {
|
|
const clonedStyle = style.cloneNode(true);
|
|
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
if (authoredScopeCompositionId) {
|
|
clonedStyle.textContent = scopeCssToComposition(
|
|
clonedStyle.textContent || "",
|
|
authoredScopeCompositionId,
|
|
runtimeScopeSelector,
|
|
authoredRootId,
|
|
);
|
|
}
|
|
document.head.appendChild(clonedStyle);
|
|
params.injectedStyles.push(clonedStyle);
|
|
}
|
|
}
|
|
|
|
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
|
for (const style of styles) {
|
|
const clonedStyle = style.cloneNode(true);
|
|
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
if (authoredScopeCompositionId) {
|
|
clonedStyle.textContent = scopeCssToComposition(
|
|
clonedStyle.textContent || "",
|
|
authoredScopeCompositionId,
|
|
runtimeScopeSelector,
|
|
authoredRootId,
|
|
);
|
|
}
|
|
document.head.appendChild(clonedStyle);
|
|
params.injectedStyles.push(clonedStyle);
|
|
}
|
|
|
|
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
|
// then content scripts. Head scripts must execute before content scripts.
|
|
const headScriptPayloads: PendingScript[] = [];
|
|
if (params.headScripts) {
|
|
for (const script of params.headScripts) {
|
|
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
|
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
if (scriptSrc) {
|
|
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
|
|
} else {
|
|
const scriptText = script.textContent?.trim() ?? "";
|
|
if (scriptText) {
|
|
headScriptPayloads.push({
|
|
kind: "inline",
|
|
content: scriptText,
|
|
type: scriptType,
|
|
scopeCompositionId: authoredScopeCompositionId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
|
|
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
|
|
for (const script of scripts) {
|
|
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
|
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
if (scriptSrc) {
|
|
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
scriptPayloads.push({
|
|
kind: "external",
|
|
src: resolvedSrc,
|
|
type: scriptType,
|
|
});
|
|
} else {
|
|
const scriptText = script.textContent?.trim() ?? "";
|
|
if (scriptText) {
|
|
scriptPayloads.push({
|
|
kind: "inline",
|
|
content: scriptText,
|
|
type: scriptType,
|
|
scopeCompositionId: authoredScopeCompositionId,
|
|
});
|
|
}
|
|
}
|
|
script.parentNode?.removeChild(script);
|
|
}
|
|
const remainingStyles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
|
for (const style of remainingStyles) {
|
|
style.parentNode?.removeChild(style);
|
|
}
|
|
|
|
if (innerRoot) {
|
|
const widthRaw = innerRoot.getAttribute("data-width");
|
|
const heightRaw = innerRoot.getAttribute("data-height");
|
|
const widthPx = params.parseDimensionPx(widthRaw);
|
|
const heightPx = params.parseDimensionPx(heightRaw);
|
|
if (widthRaw) params.host.setAttribute("data-width", widthRaw);
|
|
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
|
|
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
|
|
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
|
|
if (innerRoot.hasAttribute("data-timeline-locked")) {
|
|
params.host.setAttribute("data-timeline-locked", "");
|
|
}
|
|
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
|
|
} else if (params.hasTemplate) {
|
|
params.host.appendChild(document.importNode(contentNode, true));
|
|
} else {
|
|
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
|
}
|
|
|
|
// Stash the per-instance variables BEFORE running scripts. The scoped
|
|
// `getVariables()` injected by `compositionScoping.ts` reads from
|
|
// `window.__hfVariablesByComp[compId]`, so this table must be populated
|
|
// before the wrapped IIFE evaluates.
|
|
if (runtimeScopeCompositionId) {
|
|
const merged = {
|
|
...(params.declaredVariableDefaults ?? {}),
|
|
...parseHostVariableValues(params.host),
|
|
};
|
|
if (Object.keys(merged).length > 0) {
|
|
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
|
|
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
|
|
} else if (window.__hfVariablesByComp) {
|
|
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
|
|
}
|
|
}
|
|
|
|
for (const scriptPayload of scriptPayloads) {
|
|
const injectedScript = document.createElement("script");
|
|
if (scriptPayload.type) {
|
|
injectedScript.type = scriptPayload.type;
|
|
}
|
|
// Preserve deterministic script execution order across injected composition scripts.
|
|
injectedScript.async = false;
|
|
if (scriptPayload.kind === "external") {
|
|
injectedScript.src = scriptPayload.src;
|
|
} else if (scriptPayload.type.toLowerCase() === "module") {
|
|
injectedScript.textContent = scriptPayload.content;
|
|
} else if (scriptPayload.scopeCompositionId) {
|
|
injectedScript.textContent = wrapScopedCompositionScript(
|
|
scriptPayload.content,
|
|
scriptPayload.scopeCompositionId,
|
|
"[HyperFrames] composition script error:",
|
|
runtimeScopeSelector,
|
|
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
|
|
authoredRootId,
|
|
);
|
|
} else {
|
|
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
|
|
}
|
|
document.body.appendChild(injectedScript);
|
|
params.injectedScripts.push(injectedScript);
|
|
if (scriptPayload.kind === "external") {
|
|
const loadResult = await waitForExternalScriptLoad(injectedScript);
|
|
if (loadResult.status !== "load") {
|
|
params.onDiagnostic?.({
|
|
code: "external_composition_script_load_issue",
|
|
details: {
|
|
hostCompositionId: params.authoredCompositionId,
|
|
runtimeCompositionId: params.runtimeCompositionId,
|
|
hostCompositionSrc: params.hostCompositionSrc,
|
|
resolvedScriptSrc: scriptPayload.src,
|
|
loadStatus: loadResult.status,
|
|
elapsedMs: loadResult.elapsedMs,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function loadInlineTemplateCompositions(
|
|
params: LoadExternalCompositionsParams,
|
|
): Promise<void> {
|
|
const trackedHosts = getTrackedCompositionHosts();
|
|
cleanupDetachedScopedVariables();
|
|
if (trackedHosts.length === 0) return;
|
|
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
|
|
const hosts = trackedHosts.filter((host) => {
|
|
if (host.hasAttribute("data-composition-src")) return false;
|
|
if (host.children.length > 0) return false;
|
|
const compId = hostIdentityByElement.get(host)?.authoredCompositionId;
|
|
if (!compId) return false;
|
|
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
|
|
});
|
|
|
|
if (hosts.length === 0) return;
|
|
|
|
for (const host of hosts) {
|
|
const hostIdentity = hostIdentityByElement.get(host);
|
|
const compId = hostIdentity?.authoredCompositionId;
|
|
if (!compId) continue;
|
|
const template = document.querySelector<HTMLTemplateElement>(
|
|
`template#${CSS.escape(compId)}-template`,
|
|
)!;
|
|
|
|
resetCompositionHost(host);
|
|
await mountCompositionContent({
|
|
host,
|
|
authoredCompositionId: compId,
|
|
runtimeCompositionId: hostIdentity?.runtimeCompositionId || compId,
|
|
hostCompositionSrc: `template#${compId}-template`,
|
|
sourceNode: template.content,
|
|
hasTemplate: true,
|
|
fallbackBodyInnerHtml: "",
|
|
compositionUrl: null,
|
|
injectedStyles: params.injectedStyles,
|
|
injectedScripts: params.injectedScripts,
|
|
parseDimensionPx: params.parseDimensionPx,
|
|
onDiagnostic: params.onDiagnostic,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function loadExternalCompositions(
|
|
params: LoadExternalCompositionsParams,
|
|
): Promise<void> {
|
|
const trackedHosts = getTrackedCompositionHosts();
|
|
cleanupDetachedScopedVariables();
|
|
if (trackedHosts.length === 0) return;
|
|
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
|
|
const hosts = trackedHosts.filter((host) => host.hasAttribute("data-composition-src"));
|
|
if (hosts.length === 0) return;
|
|
|
|
await Promise.all(
|
|
hosts.map(async (host) => {
|
|
const src = host.getAttribute("data-composition-src");
|
|
if (!src) return;
|
|
const hostIdentity = hostIdentityByElement.get(host);
|
|
const authoredCompositionId = hostIdentity?.authoredCompositionId || null;
|
|
const runtimeCompositionId =
|
|
hostIdentity?.runtimeCompositionId || authoredCompositionId || null;
|
|
let compositionUrl: URL | null = null;
|
|
try {
|
|
compositionUrl = new URL(src, document.baseURI);
|
|
} catch {
|
|
compositionUrl = null;
|
|
}
|
|
resetCompositionHost(host);
|
|
try {
|
|
const localTemplate =
|
|
authoredCompositionId != null
|
|
? document.querySelector<HTMLTemplateElement>(
|
|
`template#${CSS.escape(authoredCompositionId)}-template`,
|
|
)
|
|
: null;
|
|
if (localTemplate) {
|
|
await mountCompositionContent({
|
|
host,
|
|
authoredCompositionId,
|
|
runtimeCompositionId,
|
|
hostCompositionSrc: src,
|
|
sourceNode: localTemplate.content,
|
|
hasTemplate: true,
|
|
fallbackBodyInnerHtml: "",
|
|
compositionUrl,
|
|
injectedStyles: params.injectedStyles,
|
|
injectedScripts: params.injectedScripts,
|
|
parseDimensionPx: params.parseDimensionPx,
|
|
onDiagnostic: params.onDiagnostic,
|
|
});
|
|
return;
|
|
}
|
|
const response = await fetch(src);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const html = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const template =
|
|
(authoredCompositionId
|
|
? doc.querySelector<HTMLTemplateElement>(
|
|
`template#${CSS.escape(authoredCompositionId)}-template`,
|
|
)
|
|
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
|
|
const sourceNode = template ? template.content : doc.body;
|
|
// When loading a non-template sub-composition (full HTML document),
|
|
// extract <style> and <script> elements from the parsed document's
|
|
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
|
|
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
|
|
// because mountCompositionContent only looks inside the composition
|
|
// root element.
|
|
const headStyles = !template
|
|
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
|
|
: undefined;
|
|
const headScripts = !template
|
|
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
|
|
: undefined;
|
|
const headLinks = !template
|
|
? Array.from(
|
|
doc.head.querySelectorAll<HTMLLinkElement>(
|
|
'link[rel="stylesheet"], link[rel="preconnect"]',
|
|
),
|
|
)
|
|
: undefined;
|
|
await mountCompositionContent({
|
|
host,
|
|
authoredCompositionId,
|
|
runtimeCompositionId,
|
|
hostCompositionSrc: src,
|
|
sourceNode,
|
|
hasTemplate: Boolean(template),
|
|
fallbackBodyInnerHtml: doc.body.innerHTML,
|
|
compositionUrl,
|
|
injectedStyles: params.injectedStyles,
|
|
injectedScripts: params.injectedScripts,
|
|
parseDimensionPx: params.parseDimensionPx,
|
|
headStyles,
|
|
headScripts,
|
|
headLinks,
|
|
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
|
|
onDiagnostic: params.onDiagnostic,
|
|
});
|
|
} catch (error) {
|
|
params.onDiagnostic?.({
|
|
code: "external_composition_load_failed",
|
|
details: {
|
|
hostCompositionId: authoredCompositionId,
|
|
runtimeCompositionId,
|
|
hostCompositionSrc: src,
|
|
errorMessage: error instanceof Error ? error.message : "unknown_error",
|
|
},
|
|
});
|
|
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
|
|
resetCompositionHost(host);
|
|
}
|
|
}),
|
|
);
|
|
}
|