diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 3e1377d84..862c6460a 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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> = {};
+ // 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 ), styles
- // and scripts in 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> = {
+ ...subCompResult.variablesByComp,
+ };
// Inline template compositions: inject content into
// matching empty host elements with data-composition-id="X" (no data-composition-src)
diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts
index 8b4a1cb54..85d66afea 100644
--- a/packages/core/src/compiler/index.ts
+++ b/packages/core/src/compiler/index.ts
@@ -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";
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
new file mode 100644
index 000000000..ce0cdbc28
--- /dev/null
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -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 `` element.
+ * The bundler passes `readDeclaredDefaults`; the producer can omit this.
+ */
+ readVariableDefaults?: (docElement: Element) => Record;
+
+ /**
+ * Parse host-level variable overrides from `data-variable-values`.
+ * The bundler passes `parseHostVariableValues`; the producer can omit this.
+ */
+ parseHostVariables?: (host: Element) => Record;
+
+ /**
+ * 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>;
+}
+
+// ---------------------------------------------------------------------------
+// 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 `` or `` content
+ * 3. Find the inner `[data-composition-id]` root
+ * 4. Extract `