mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
/**
|
|
* 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 {
|
|
rewriteAssetPaths,
|
|
rewriteCssAssetUrls,
|
|
rewriteInlineStyleAssetUrls,
|
|
} from "./rewriteSubCompPaths";
|
|
import { queryByAttr } from "../utils/cssSelector";
|
|
import {
|
|
scopeCssToComposition,
|
|
wrapInlineScriptWithErrorBoundary,
|
|
wrapScopedCompositionScript,
|
|
} from "./compositionScoping";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/**
|
|
* Log a warning when a composition file cannot be resolved.
|
|
* Defaults to `console.warn`.
|
|
*/
|
|
onMissingComposition?: (srcPath: string) => void;
|
|
}
|
|
|
|
export interface InlineSubCompositionsResult {
|
|
styles: string[];
|
|
scripts: string[];
|
|
externalScriptSrcs: string[];
|
|
scriptItems: Array<{ kind: "inline"; content: string } | { kind: "external"; src: string }>;
|
|
externalLinks: { href: string; rel: string; crossorigin?: string }[];
|
|
variablesByComp: Record<string, Record<string, unknown>>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, find `<template>` or `<body>` 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
|
|
*/
|
|
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,
|
|
} = options;
|
|
|
|
const styles: string[] = [];
|
|
const scripts: string[] = [];
|
|
const externalScriptSrcs: string[] = [];
|
|
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
|
|
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
|
|
const seenLinkHrefs = new Set<string>();
|
|
const variablesByComp: Record<string, Record<string, unknown>> = {};
|
|
|
|
for (const hostEl of hosts) {
|
|
const src = hostEl.getAttribute("data-composition-src");
|
|
if (!src) continue;
|
|
|
|
const compHtml = resolveHtml(src);
|
|
if (compHtml == null || !compHtml.trim()) {
|
|
onMissingComposition?.(src);
|
|
continue;
|
|
}
|
|
|
|
const compDoc = parseHtml(compHtml);
|
|
if (!compDoc.documentElement) {
|
|
onMissingComposition?.(src);
|
|
continue;
|
|
}
|
|
|
|
// 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 || "";
|
|
}
|
|
|
|
// Find content: prefer <template>, fall back to <body>
|
|
const contentRoot = compDoc.querySelector("template");
|
|
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
|
|
if (!contentHtml.trim()) {
|
|
onMissingComposition?.(src);
|
|
continue;
|
|
}
|
|
const contentDoc = parseHtml(contentHtml);
|
|
if (!contentDoc.documentElement) {
|
|
onMissingComposition?.(src);
|
|
continue;
|
|
}
|
|
|
|
// Find the inner composition root
|
|
const innerRoot = compId
|
|
? queryByAttr(contentDoc, "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 runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
|
|
|
|
// Variable merging (bundler feature)
|
|
if (readVariableDefaults && parseHostVariables && runtimeCompId) {
|
|
const mergedVariables = {
|
|
...readVariableDefaults(compDoc.documentElement),
|
|
...parseHostVariables(hostEl),
|
|
};
|
|
if (Object.keys(mergedVariables).length > 0) {
|
|
variablesByComp[runtimeCompId] = mergedVariables;
|
|
}
|
|
}
|
|
|
|
// When a sub-composition is a full HTML document (no <template>), styles
|
|
// and scripts in <head> are not part of contentDoc (which only has body
|
|
// content). Extract them so backgrounds, positioning, fonts, and library
|
|
// scripts (e.g. GSAP CDN) are not silently dropped.
|
|
if (!contentRoot && compDoc.head) {
|
|
for (const s of [...compDoc.head.querySelectorAll("style")]) {
|
|
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
|
styles.push(
|
|
scopeCompId
|
|
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
|
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
|
})
|
|
: css,
|
|
);
|
|
}
|
|
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
|
const externalSrc = (s.getAttribute("src") || "").trim();
|
|
if (externalSrc) {
|
|
if (!externalScriptSrcs.includes(externalSrc)) {
|
|
externalScriptSrcs.push(externalSrc);
|
|
}
|
|
scriptItems.push({ kind: "external", src: externalSrc });
|
|
}
|
|
}
|
|
for (const link of [
|
|
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
|
|
]) {
|
|
const href = (link.getAttribute("href") || "").trim();
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract styles from content
|
|
for (const s of [...contentDoc.querySelectorAll("style")]) {
|
|
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
|
styles.push(
|
|
scopeCompId
|
|
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
|
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
|
})
|
|
: css,
|
|
);
|
|
s.remove();
|
|
}
|
|
|
|
// Extract scripts from content
|
|
for (const s of [...contentDoc.querySelectorAll("script")]) {
|
|
const externalSrc = (s.getAttribute("src") || "").trim();
|
|
if (externalSrc) {
|
|
if (!externalScriptSrcs.includes(externalSrc)) {
|
|
externalScriptSrcs.push(externalSrc);
|
|
}
|
|
scriptItems.push({ kind: "external", src: externalSrc });
|
|
} else {
|
|
const wrappedScript = scopeCompId
|
|
? wrapScopedCompositionScript(
|
|
s.textContent || "",
|
|
scopeCompId,
|
|
scriptErrorLabel,
|
|
runtimeScope || undefined,
|
|
runtimeCompId || scopeCompId,
|
|
authoredRootId,
|
|
)
|
|
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
|
|
scripts.push(wrappedScript);
|
|
scriptItems.push({ kind: "inline", content: wrappedScript });
|
|
}
|
|
s.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);
|
|
},
|
|
);
|
|
|
|
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);
|
|
},
|
|
);
|
|
}
|
|
|
|
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("style, script")]) child.remove();
|
|
if (flattenInnerRoot) {
|
|
const prepared = flattenInnerRoot(innerRoot);
|
|
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("style, script")]) 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");
|
|
}
|
|
|
|
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
|
|
}
|