mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor: extract shared inlineSubCompositions from bundler and producer
Both the core bundler (htmlBundler.ts) and the producer (htmlCompiler.ts) had parallel ~200-line implementations of sub-composition inlining. This divergence caused bug #911 (producer didn't set data-composition-file). Extract the shared logic into core/compiler/inlineSubCompositions.ts: - Single function handles: template/body extraction, CSS/script scoping, asset path rewriting, data-composition-file attribution, content injection - Callers provide environment-specific callbacks (HTML resolution, parsing, variable handling, inner root flattening) - Core bundler passes its advanced features (runtime IDs, variables, inline style rewriting, inner root flattening) - Producer passes a simpler resolver (map + filesystem fallback) and adds pixel sizing post-hoc Net: -215 lines, one source of truth for sub-comp inlining. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b9bdc80db7
commit
581e7a7eda
@@ -7,15 +7,12 @@ import {
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths";
|
||||
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
import { readDeclaredDefaults } from "../runtime/getVariables";
|
||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||
|
||||
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
|
||||
function safePath(projectDir: string, relativePath: string): string | null {
|
||||
@@ -581,144 +578,36 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// Inline sub-compositions
|
||||
const compStyleChunks: string[] = [];
|
||||
const compScriptChunks: string[] = [];
|
||||
const compExternalScriptSrcs: string[] = [];
|
||||
const compVariablesByComp: Record<string, Record<string, unknown>> = {};
|
||||
// Inline sub-compositions (via shared function)
|
||||
const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
|
||||
const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
|
||||
const subCompositionHosts = trackedCompositionHosts.filter((host) =>
|
||||
host.hasAttribute("data-composition-src"),
|
||||
);
|
||||
for (const hostEl of subCompositionHosts) {
|
||||
const src = hostEl.getAttribute("data-composition-src");
|
||||
if (!src || !isRelativeUrl(src)) continue;
|
||||
const compPath = safePath(projectDir, src);
|
||||
const compHtml = compPath ? safeReadFile(compPath) : null;
|
||||
if (compHtml == null) {
|
||||
console.warn(`[Bundler] Composition file not found: ${src}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const compDoc = parseHTMLContent(compHtml);
|
||||
const hostIdentity = hostIdentityByElement.get(hostEl);
|
||||
const compId = hostIdentity?.authoredCompositionId || null;
|
||||
const runtimeCompId = hostIdentity?.runtimeCompositionId || compId || "";
|
||||
const contentRoot = compDoc.querySelector("template");
|
||||
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || "";
|
||||
const contentDoc = parseHTMLContent(contentHtml);
|
||||
const innerRoot = compId
|
||||
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
|
||||
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
||||
const scopeCompId = compId || inferredCompId;
|
||||
const runtimeScope = runtimeCompId
|
||||
? cssAttributeSelector("data-composition-id", runtimeCompId)
|
||||
: "";
|
||||
const mergedVariables = runtimeCompId
|
||||
? {
|
||||
...readDeclaredDefaults(compDoc.documentElement),
|
||||
...parseHostVariableValues(hostEl),
|
||||
}
|
||||
: {};
|
||||
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
|
||||
compVariablesByComp[runtimeCompId] = mergedVariables;
|
||||
}
|
||||
|
||||
// When a sub-composition is a full HTML document (no <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);
|
||||
compStyleChunks.push(
|
||||
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope, authoredRootId) : css,
|
||||
);
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
if (externalSrc && !compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of [...contentDoc.querySelectorAll("style")]) {
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
compStyleChunks.push(
|
||||
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope, authoredRootId) : css,
|
||||
);
|
||||
s.remove();
|
||||
}
|
||||
for (const s of [...contentDoc.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
if (externalSrc) {
|
||||
// External CDN/remote script — collect for deduped injection into the document.
|
||||
// Do NOT try to inline the content (external scripts have no innerHTML).
|
||||
if (!compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
scopeCompId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scopeCompId,
|
||||
"[HyperFrames] composition script error:",
|
||||
runtimeScope,
|
||||
runtimeCompId || scopeCompId,
|
||||
authoredRootId,
|
||||
)
|
||||
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
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);
|
||||
},
|
||||
);
|
||||
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) {
|
||||
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);
|
||||
innerRoot.setAttribute("data-composition-file", src);
|
||||
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
||||
const preparedInnerRoot = prepareFlattenedInnerRoot(innerRoot);
|
||||
hostEl.innerHTML = preparedInnerRoot.outerHTML || "";
|
||||
} else {
|
||||
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = contentDoc.body.innerHTML || "";
|
||||
}
|
||||
hostEl.setAttribute("data-composition-file", src);
|
||||
hostEl.removeAttribute("data-composition-src");
|
||||
}
|
||||
const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
|
||||
resolveHtml: (srcPath: string) => {
|
||||
if (!isRelativeUrl(srcPath)) return null;
|
||||
const compPath = safePath(projectDir, srcPath);
|
||||
return compPath ? safeReadFile(compPath) : null;
|
||||
},
|
||||
parseHtml: parseHTMLContent,
|
||||
hostIdentityMap: hostIdentityByElement,
|
||||
rewriteInlineStyles: true,
|
||||
flattenInnerRoot: prepareFlattenedInnerRoot,
|
||||
readVariableDefaults: readDeclaredDefaults,
|
||||
parseHostVariables: parseHostVariableValues,
|
||||
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
|
||||
scriptErrorLabel: "[HyperFrames] composition script error:",
|
||||
onMissingComposition: (srcPath: string) => {
|
||||
console.warn(`[Bundler] Composition file not found: ${srcPath}`);
|
||||
},
|
||||
});
|
||||
const compStyleChunks: string[] = [...subCompResult.styles];
|
||||
const compScriptChunks: string[] = [...subCompResult.scripts];
|
||||
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
|
||||
const compVariablesByComp: Record<string, Record<string, unknown>> = {
|
||||
...subCompResult.variablesByComp,
|
||||
};
|
||||
|
||||
// Inline template compositions: inject <template id="X-template"> content into
|
||||
// matching empty host elements with data-composition-id="X" (no data-composition-src)
|
||||
|
||||
@@ -34,3 +34,10 @@ export {
|
||||
|
||||
// Composition isolation helpers
|
||||
export { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
|
||||
// Sub-composition inlining (shared between bundler and producer)
|
||||
export {
|
||||
inlineSubCompositions,
|
||||
type InlineSubCompositionsOptions,
|
||||
type InlineSubCompositionsResult,
|
||||
} from "./inlineSubCompositions";
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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 { scopeCssToComposition, 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;
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
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,
|
||||
readVariableDefaults,
|
||||
parseHostVariables,
|
||||
buildScopeSelector = defaultBuildScopeSelector,
|
||||
scriptErrorLabel = "[HyperFrames] composition script error:",
|
||||
onMissingComposition,
|
||||
} = options;
|
||||
|
||||
const styles: string[] = [];
|
||||
const scripts: string[] = [];
|
||||
const externalScriptSrcs: 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) {
|
||||
if (onMissingComposition) {
|
||||
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 || "";
|
||||
}
|
||||
|
||||
// Find content: prefer <template>, fall back to <body>
|
||||
const contentRoot = compDoc.querySelector("template");
|
||||
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
|
||||
const contentDoc = parseHtml(contentHtml);
|
||||
|
||||
// Find the inner composition root
|
||||
const innerRoot = compId
|
||||
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
|
||||
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
||||
const scopeCompId = compId || inferredCompId;
|
||||
const 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)
|
||||
: css,
|
||||
);
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
if (externalSrc && !externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
: 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);
|
||||
}
|
||||
} else {
|
||||
scripts.push(
|
||||
scopeCompId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scopeCompId,
|
||||
scriptErrorLabel,
|
||||
runtimeScope || undefined,
|
||||
runtimeCompId || scopeCompId,
|
||||
authoredRootId,
|
||||
)
|
||||
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error(${JSON.stringify(scriptErrorLabel)}, _err); } })();`,
|
||||
);
|
||||
}
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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 || "";
|
||||
}
|
||||
} else {
|
||||
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = contentDoc.body?.innerHTML || "";
|
||||
}
|
||||
|
||||
hostEl.setAttribute("data-composition-file", src);
|
||||
hostEl.removeAttribute("data-composition-src");
|
||||
}
|
||||
|
||||
return { styles, scripts, externalScriptSrcs, variablesByComp };
|
||||
}
|
||||
@@ -20,10 +20,8 @@ import {
|
||||
shouldClampMediaDuration,
|
||||
type ResolvedDuration,
|
||||
type UnresolvedElement,
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "@hyperframes/core/compiler";
|
||||
import { inlineSubCompositions as inlineSubCompositionsShared } from "@hyperframes/core/compiler";
|
||||
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import {
|
||||
@@ -543,13 +541,11 @@ function coalesceHeadStylesAndBodyScripts(html: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline sub-composition HTML into the main document, mirroring what the
|
||||
* bundler's step 6 does. For each host element with `data-composition-src`:
|
||||
* - Resolve the composition HTML from the pre-compiled map or disk
|
||||
* - Extract <template> (or <body>) content
|
||||
* - Move composition <style> to <head>, <script> to end of <body>
|
||||
* - Replace host innerHTML with composition children
|
||||
* - Remove data-composition-src so the runtime skips async fetching
|
||||
* Inline sub-composition HTML into the main document using the shared
|
||||
* inlining logic from @hyperframes/core. This wrapper handles the
|
||||
* producer-specific concerns: parsing HTML via linkedom, resolving
|
||||
* compositions from the pre-compiled map or disk, and setting explicit
|
||||
* pixel dimensions on host elements for headless rendering.
|
||||
*/
|
||||
function inlineSubCompositions(
|
||||
html: string,
|
||||
@@ -559,142 +555,33 @@ function inlineSubCompositions(
|
||||
const { document } = parseHTML(html);
|
||||
const head = document.querySelector("head");
|
||||
const body = document.querySelector("body");
|
||||
const hosts = document.querySelectorAll("[data-composition-src]");
|
||||
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
||||
|
||||
if (!hosts.length) return html;
|
||||
|
||||
const collectedStyles: string[] = [];
|
||||
const collectedScripts: string[] = [];
|
||||
const collectedExternalScriptSrcs: string[] = [];
|
||||
const result = inlineSubCompositionsShared(
|
||||
document as unknown as Document,
|
||||
hosts as unknown as Element[],
|
||||
{
|
||||
resolveHtml: (srcPath: string) => {
|
||||
let compHtml = subCompositions.get(srcPath) || null;
|
||||
if (!compHtml) {
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
if (existsSync(filePath)) {
|
||||
compHtml = readFileSync(filePath, "utf-8");
|
||||
}
|
||||
}
|
||||
return compHtml;
|
||||
},
|
||||
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
||||
scriptErrorLabel: "[Compiler] Composition script failed",
|
||||
},
|
||||
);
|
||||
|
||||
// Producer-specific: set explicit pixel dimensions on host elements so
|
||||
// children using width/height: 100% resolve correctly. The runtime does
|
||||
// this automatically but compiled HTML needs it inline.
|
||||
for (const host of hosts) {
|
||||
const srcPath = host.getAttribute("data-composition-src");
|
||||
if (!srcPath) continue;
|
||||
|
||||
let compHtml = subCompositions.get(srcPath) || null;
|
||||
if (!compHtml) {
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
if (existsSync(filePath)) {
|
||||
compHtml = readFileSync(filePath, "utf-8");
|
||||
}
|
||||
}
|
||||
if (!compHtml) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compDoc = parseHTML(compHtml).document;
|
||||
const compId = host.getAttribute("data-composition-id");
|
||||
|
||||
const templateEl = compDoc.querySelector("template");
|
||||
const bodyEl = compDoc.querySelector("body");
|
||||
const contentHtml = templateEl
|
||||
? templateEl.innerHTML || ""
|
||||
: bodyEl
|
||||
? bodyEl.innerHTML || ""
|
||||
: compDoc.toString();
|
||||
|
||||
const contentDoc = parseHTML(contentHtml).document;
|
||||
|
||||
const innerRoot = compId
|
||||
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || null;
|
||||
|
||||
// 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 separately so backgrounds, positioning, fonts,
|
||||
// and library scripts (e.g. GSAP CDN) are not silently dropped.
|
||||
if (!templateEl) {
|
||||
const compHead = compDoc.querySelector("head");
|
||||
if (compHead) {
|
||||
for (const styleEl of compHead.querySelectorAll("style")) {
|
||||
const css = rewriteCssAssetUrls(styleEl.textContent || "", srcPath);
|
||||
const scopeId = compId || inferredCompId;
|
||||
if (scopeId && css.trim()) {
|
||||
collectedStyles.push(scopeCssToComposition(css, scopeId));
|
||||
} else {
|
||||
collectedStyles.push(css);
|
||||
}
|
||||
}
|
||||
for (const scriptEl of compHead.querySelectorAll("script")) {
|
||||
const src = (scriptEl.getAttribute("src") || "").trim();
|
||||
if (src && !collectedExternalScriptSrcs.includes(src)) {
|
||||
collectedExternalScriptSrcs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const styleEl of contentDoc.querySelectorAll("style")) {
|
||||
const css = rewriteCssAssetUrls(styleEl.textContent || "", srcPath);
|
||||
const scopeId = compId || inferredCompId;
|
||||
if (scopeId && css.trim()) {
|
||||
// Scope sub-composition styles to their composition ID to prevent
|
||||
// CSS class collisions when multiple compositions use the same
|
||||
// class names (e.g. ".content"). This matches preview behavior
|
||||
// where each composition's styles are naturally scoped.
|
||||
collectedStyles.push(scopeCssToComposition(css, scopeId));
|
||||
} else {
|
||||
collectedStyles.push(css);
|
||||
}
|
||||
styleEl.remove();
|
||||
}
|
||||
|
||||
for (const scriptEl of contentDoc.querySelectorAll("script")) {
|
||||
const src = (scriptEl.getAttribute("src") || "").trim();
|
||||
if (src) {
|
||||
// External CDN/remote script — collect for deduped injection into the
|
||||
// parent document, mirroring the bundler's hoisting behavior.
|
||||
if (!collectedExternalScriptSrcs.includes(src)) {
|
||||
collectedExternalScriptSrcs.push(src);
|
||||
}
|
||||
scriptEl.remove();
|
||||
continue;
|
||||
}
|
||||
const content = (scriptEl.textContent || "").trim();
|
||||
if (content) {
|
||||
const scriptMountCompId = compId || inferredCompId || "";
|
||||
collectedScripts.push(
|
||||
scriptMountCompId
|
||||
? wrapScopedCompositionScript(
|
||||
content,
|
||||
scriptMountCompId,
|
||||
"[Compiler] Composition script failed",
|
||||
)
|
||||
: `(function(){ try { ${content} } catch (_err) { console.error("[Compiler] Composition script failed", _err); } })()`,
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
}
|
||||
|
||||
// Rewrite relative asset paths before inlining so ../foo.svg from
|
||||
// compositions/ resolves correctly when the content moves to root.
|
||||
const rewriteTarget = innerRoot || contentDoc;
|
||||
rewriteAssetPaths(
|
||||
rewriteTarget.querySelectorAll("[src], [href]"),
|
||||
srcPath,
|
||||
(el, attr) => (el.getAttribute(attr) || "").trim(),
|
||||
(el, attr, val) => el.setAttribute(attr, val),
|
||||
);
|
||||
|
||||
if (innerRoot) {
|
||||
const innerW = innerRoot.getAttribute("data-width");
|
||||
const innerH = innerRoot.getAttribute("data-height");
|
||||
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
|
||||
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
|
||||
innerRoot.querySelectorAll("style, script").forEach((el) => el.remove());
|
||||
host.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
|
||||
} else {
|
||||
contentDoc.querySelectorAll("style, script").forEach((el) => el.remove());
|
||||
host.innerHTML = contentDoc.toString();
|
||||
}
|
||||
|
||||
host.setAttribute("data-composition-file", srcPath);
|
||||
host.removeAttribute("data-composition-src");
|
||||
|
||||
// Set explicit pixel dimensions on the host element so children using
|
||||
// width/height: 100% resolve correctly. The runtime does this
|
||||
// automatically but compiled HTML needs it inline.
|
||||
const hostW = host.getAttribute("data-width");
|
||||
const hostH = host.getAttribute("data-height");
|
||||
if (hostW && hostH) {
|
||||
@@ -713,22 +600,23 @@ function inlineSubCompositions(
|
||||
}
|
||||
}
|
||||
|
||||
if (collectedStyles.length && head) {
|
||||
// Append collected styles to <head>
|
||||
if (result.styles.length && head) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = collectedStyles.join("\n\n");
|
||||
styleEl.textContent = result.styles.join("\n\n");
|
||||
head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
// Inject external CDN scripts before inline scripts so plugins (e.g.
|
||||
// TextPlugin, ScrollTrigger) are registered before composition code runs.
|
||||
// Deduplicate against scripts already present in the document.
|
||||
if (collectedExternalScriptSrcs.length && body) {
|
||||
if (result.externalScriptSrcs.length && body) {
|
||||
const existingScriptSrcs = new Set(
|
||||
Array.from(document.querySelectorAll("script[src]")).map((el) =>
|
||||
Array.from(document.querySelectorAll("script[src]")).map((el: Element) =>
|
||||
(el.getAttribute("src") || "").trim(),
|
||||
),
|
||||
);
|
||||
for (const src of collectedExternalScriptSrcs) {
|
||||
for (const src of result.externalScriptSrcs) {
|
||||
if (!existingScriptSrcs.has(src)) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.setAttribute("src", src);
|
||||
@@ -738,9 +626,10 @@ function inlineSubCompositions(
|
||||
}
|
||||
}
|
||||
|
||||
if (collectedScripts.length && body) {
|
||||
// Append collected inline scripts to <body>
|
||||
if (result.scripts.length && body) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent = collectedScripts.join("\n;\n");
|
||||
scriptEl.textContent = result.scripts.join("\n;\n");
|
||||
body.appendChild(scriptEl);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user