diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 9f0fc64e3..5f5d937f8 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -97,4 +97,102 @@ describe("bundleToSingleHtml", () => {
).length;
expect(gsapOccurrences).toBe(1);
});
+
+ it("inlines compositions into matching empty host elements", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // Template element should be removed
+ expect(bundled).not.toContain(" {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // Existing content should be preserved
+ expect(bundled).toContain("Already filled");
+
+ // Template content should NOT replace the existing host content
+ // (template element may still exist in the output since it was not consumed)
+ const hostMatch = bundled.match(
+ /data-composition-id="comp"[^>]*data-start="0"[^>]*>([\s\S]*?)<\/div>/,
+ );
+ expect(hostMatch).toBeTruthy();
+ expect(hostMatch![1]).toContain("Already filled");
+ expect(hostMatch![1]).not.toContain("Template content");
+ });
+
+ it("copies dimension attributes from inline template to host", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // The host should have dimensions copied from the template inner root
+ expect(bundled).toContain('data-width="800"');
+ expect(bundled).toContain('data-height="600"');
+ expect(bundled).toContain("Sized content");
+ });
});
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 54b3250c7..dbe96e95e 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -449,6 +449,83 @@ export async function bundleToSingleHtml(
$(hostEl).removeAttr("data-composition-src");
});
+ // Inline template compositions: inject content into
+ // matching empty host elements with data-composition-id="X" (no data-composition-src)
+ $("template[id]").each((_, templateEl) => {
+ const templateId = $(templateEl).attr("id") || "";
+ const match = templateId.match(/^(.+)-template$/);
+ if (!match) return;
+ const compId = match[1];
+
+ // Find the matching host element (must have data-composition-id, no data-composition-src,
+ // and must NOT be inside a element). In cheerio, elements inside
+ // have a detached parent chain (parents().length === 0), so we filter those out.
+ const hostSelector = `[data-composition-id="${compId}"]:not([data-composition-src])`;
+ const $candidates = $(hostSelector).filter((__, el) => $(el).parents().length > 0);
+ const $host = $candidates.first();
+ if ($host.length === 0) return;
+ if ($host.children().length > 0) return; // already has content
+
+ // Get template content and inject into host
+ const templateHtml = $(templateEl).html() || "";
+ const $inner = cheerio.load(templateHtml, { xml: false });
+ const $innerRoot = $inner(`[data-composition-id="${compId}"]`).first();
+
+ if ($innerRoot.length > 0) {
+ // Hoist styles into the collected style chunks
+ $innerRoot.find("style").each((__, styleEl) => {
+ compStyleChunks.push($inner(styleEl).html() || "");
+ $inner(styleEl).remove();
+ });
+ // Hoist scripts into the collected script chunks
+ $innerRoot.find("script").each((__, scriptEl) => {
+ const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
+ if (externalSrc) {
+ if (!compExternalScriptSrcs.includes(externalSrc)) {
+ compExternalScriptSrcs.push(externalSrc);
+ }
+ } else {
+ compScriptChunks.push(
+ `(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ );
+ }
+ $inner(scriptEl).remove();
+ });
+
+ // Copy dimension attributes from inner root to host if not already set
+ const innerW = $innerRoot.attr("data-width");
+ const innerH = $innerRoot.attr("data-height");
+ if (innerW && !$host.attr("data-width")) $host.attr("data-width", innerW);
+ if (innerH && !$host.attr("data-height")) $host.attr("data-height", innerH);
+
+ // Set host content from inner root
+ $host.html($innerRoot.html() || "");
+ } else {
+ // No matching inner root — inject all template content directly
+ $inner("style").each((__, styleEl) => {
+ compStyleChunks.push($inner(styleEl).html() || "");
+ $inner(styleEl).remove();
+ });
+ $inner("script").each((__, scriptEl) => {
+ const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
+ if (externalSrc) {
+ if (!compExternalScriptSrcs.includes(externalSrc)) {
+ compExternalScriptSrcs.push(externalSrc);
+ }
+ } else {
+ compScriptChunks.push(
+ `(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ );
+ }
+ $inner(scriptEl).remove();
+ });
+ $host.html($inner.html() || "");
+ }
+
+ // Remove the template element from the document
+ $(templateEl).remove();
+ });
+
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
// that aren't already present in the main document.
for (const extSrc of compExternalScriptSrcs) {
diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts
index ca7bcbf69..a43f5f6fe 100644
--- a/packages/core/src/runtime/compositionLoader.test.ts
+++ b/packages/core/src/runtime/compositionLoader.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
-import { loadExternalCompositions } from "./compositionLoader";
+import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
// jsdom doesn't provide CSS.escape
beforeAll(() => {
@@ -212,3 +212,207 @@ describe("loadExternalCompositions", () => {
expect(host2.querySelector("p")?.textContent).toBe("B");
});
});
+
+describe("loadInlineTemplateCompositions", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ document.head.querySelectorAll("style").forEach((s) => s.remove());
+ vi.restoreAllMocks();
+ });
+
+ const defaultParams = {
+ injectedStyles: [] as HTMLStyleElement[],
+ injectedScripts: [] as HTMLScriptElement[],
+ parseDimensionPx: (v: string | null) => (v ? `${v}px` : null),
+ };
+
+ it("mounts template content into matching empty host", async () => {
+ const template = document.createElement("template");
+ template.id = "logo-reveal-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "logo-reveal");
+ host.setAttribute("data-start", "0");
+ host.setAttribute("data-duration", "10");
+ document.body.appendChild(host);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ expect(host.querySelector("p")?.textContent).toBe("Logo content");
+ });
+
+ it("does nothing when no matching template exists", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "no-template");
+ document.body.appendChild(host);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ // Host should remain empty
+ expect(host.children.length).toBe(0);
+ });
+
+ it("does nothing when no inline template hosts exist", async () => {
+ // Add a template with no matching host
+ const template = document.createElement("template");
+ template.id = "orphan-template";
+ template.innerHTML = "Orphan
";
+ document.body.appendChild(template);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ // Nothing should change — no hosts match
+ expect(document.querySelector("p")).toBeNull();
+ });
+
+ it("skips hosts that already have content", async () => {
+ const template = document.createElement("template");
+ template.id = "filled-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "filled");
+ host.innerHTML = "Existing content";
+ document.body.appendChild(host);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ // Original content should remain
+ expect(host.querySelector("span")?.textContent).toBe("Existing content");
+ expect(host.querySelector("p")).toBeNull();
+ });
+
+ it("skips hosts that have data-composition-src", async () => {
+ const template = document.createElement("template");
+ template.id = "external-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "external");
+ host.setAttribute("data-composition-src", "https://example.com/comp.html");
+ document.body.appendChild(host);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ // Host should not have template content (it has data-composition-src)
+ expect(host.querySelector("p")).toBeNull();
+ });
+
+ it("processes multiple inline templates", async () => {
+ const template1 = document.createElement("template");
+ template1.id = "comp-a-template";
+ template1.innerHTML = `
+
+ `;
+ document.body.appendChild(template1);
+
+ const template2 = document.createElement("template");
+ template2.id = "comp-b-template";
+ template2.innerHTML = `
+
+ `;
+ document.body.appendChild(template2);
+
+ const host1 = document.createElement("div");
+ host1.setAttribute("data-composition-id", "comp-a");
+ document.body.appendChild(host1);
+
+ const host2 = document.createElement("div");
+ host2.setAttribute("data-composition-id", "comp-b");
+ document.body.appendChild(host2);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ expect(host1.querySelector("p")?.textContent).toBe("Content A");
+ expect(host2.querySelector("p")?.textContent).toBe("Content B");
+ });
+
+ it("injects styles from template into document head", async () => {
+ const template = document.createElement("template");
+ template.id = "styled-comp-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "styled-comp");
+ document.body.appendChild(host);
+
+ const injectedStyles: HTMLStyleElement[] = [];
+ await loadInlineTemplateCompositions({
+ ...defaultParams,
+ injectedStyles,
+ });
+
+ expect(injectedStyles.length).toBeGreaterThan(0);
+ });
+
+ it("injects scripts from template", async () => {
+ const template = document.createElement("template");
+ template.id = "scripted-comp-template";
+ template.innerHTML = `
+
+
Content with script
+
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "scripted-comp");
+ document.body.appendChild(host);
+
+ const injectedScripts: HTMLScriptElement[] = [];
+ await loadInlineTemplateCompositions({
+ ...defaultParams,
+ injectedScripts,
+ });
+
+ expect(injectedScripts.length).toBeGreaterThan(0);
+ expect(injectedScripts[0].textContent).toContain("inline template script");
+ });
+
+ it("copies dimension attributes from template inner root to host", async () => {
+ const template = document.createElement("template");
+ template.id = "dim-comp-template";
+ template.innerHTML = `
+
+ `;
+ document.body.appendChild(template);
+
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-id", "dim-comp");
+ document.body.appendChild(host);
+
+ await loadInlineTemplateCompositions({ ...defaultParams });
+
+ expect(host.getAttribute("data-width")).toBe("1920");
+ expect(host.getAttribute("data-height")).toBe("1080");
+ });
+});
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index 6bd4db02b..a2958745b 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -191,6 +191,47 @@ async function mountCompositionContent(params: {
}
}
+export async function loadInlineTemplateCompositions(
+ params: LoadExternalCompositionsParams,
+): Promise {
+ // Find all elements with data-composition-id but WITHOUT data-composition-src
+ // that are empty (no children) and have a matching
+ const hosts = Array.from(
+ document.querySelectorAll("[data-composition-id]:not([data-composition-src])"),
+ ).filter((host) => {
+ // Only process empty hosts (no meaningful content)
+ if (host.children.length > 0) return false;
+ const compId = host.getAttribute("data-composition-id");
+ if (!compId) return false;
+ // Check for matching template
+ return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
+ });
+
+ if (hosts.length === 0) return;
+
+ for (const host of hosts) {
+ const compId = host.getAttribute("data-composition-id")!;
+ const template = document.querySelector(
+ `template#${CSS.escape(compId)}-template`,
+ )!;
+
+ resetCompositionHost(host);
+ await mountCompositionContent({
+ host,
+ hostCompositionId: 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 {
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 71d8da60b..a73739f37 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -11,7 +11,7 @@ import { createRuntimePlayer } from "./player";
import { createRuntimeState } from "./state";
import { collectRuntimeTimelinePayload } from "./timeline";
import { createRuntimeStartTimeResolver } from "./startResolver";
-import { loadExternalCompositions } from "./compositionLoader";
+import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
@@ -362,7 +362,25 @@ export function initSandboxRuntimeModular(): void {
});
return resolver.resolveDurationForElement(element);
};
- let externalCompositionsReady = !document.querySelector("[data-composition-src]");
+ const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
+ let hasInlineTemplateCompositions = false;
+ {
+ const candidates = document.querySelectorAll(
+ "[data-composition-id]:not([data-composition-src])",
+ );
+ for (const el of candidates) {
+ const cid = el.getAttribute("data-composition-id");
+ if (
+ cid &&
+ el.children.length === 0 &&
+ document.querySelector(`template#${CSS.escape(cid)}-template`)
+ ) {
+ hasInlineTemplateCompositions = true;
+ break;
+ }
+ }
+ }
+ let externalCompositionsReady = !hasExternalCompositions && !hasInlineTemplateCompositions;
const getTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null): number | null => {
if (!timeline || typeof timeline.duration !== "function") return null;
@@ -1272,11 +1290,17 @@ export function initSandboxRuntimeModular(): void {
};
if (!externalCompositionsReady) {
- void loadExternalCompositions({
+ const compositionLoaderParams = {
injectedStyles: state.injectedCompStyles,
injectedScripts: state.injectedCompScripts,
parseDimensionPx,
- onDiagnostic: ({ code, details }) => {
+ onDiagnostic: ({
+ code,
+ details,
+ }: {
+ code: string;
+ details: Record;
+ }) => {
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
@@ -1284,14 +1308,17 @@ export function initSandboxRuntimeModular(): void {
details,
});
},
- }).finally(() => {
- externalCompositionsReady = true;
- runAdapters("discover", state.currentTime);
- bindMediaMetadataListeners();
- installAssetFailureDiagnostics();
- postTimeline();
- postState(true);
- });
+ };
+ void loadExternalCompositions(compositionLoaderParams)
+ .then(() => loadInlineTemplateCompositions(compositionLoaderParams))
+ .finally(() => {
+ externalCompositionsReady = true;
+ runAdapters("discover", state.currentTime);
+ bindMediaMetadataListeners();
+ installAssetFailureDiagnostics();
+ postTimeline();
+ postState(true);
+ });
}
const picker = createPickerModule({