mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Fixes #1847 The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview. Changes: - Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's. - Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding. - Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts). - Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts). Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review. Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
410 lines
16 KiB
TypeScript
410 lines
16 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";
|
|
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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. `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;
|
|
}
|
|
|
|
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);
|
|
// 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 || "";
|
|
}
|
|
|
|
// 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);
|
|
if (!compId && inferredCompId) {
|
|
// 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", inferredCompId);
|
|
}
|
|
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 };
|
|
}
|