mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(core): support inline <template> compositions without data-composition-src
Adds loadInlineTemplateCompositions to the runtime to handle compositions defined inline via <template id="X-template"> elements paired with empty host elements that have data-composition-id="X" but no data-composition-src. Also updates the HTML bundler to inline template content during compilation, hoisting styles and scripts from the template into the appropriate locations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0d51fb751c
commit
92f23f181e
@@ -97,4 +97,102 @@ describe("bundleToSingleHtml", () => {
|
||||
).length;
|
||||
expect(gsapOccurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("inlines <template> compositions into matching empty host elements", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<template id="logo-reveal-template">
|
||||
<div data-composition-id="logo-reveal" data-width="1920" data-height="1080">
|
||||
<style>.logo { opacity: 0; }</style>
|
||||
<div class="logo">Logo Here</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["logo-reveal"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="logo-host"
|
||||
data-composition-id="logo-reveal"
|
||||
data-start="0" data-duration="5"
|
||||
data-track-index="1"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// Template element should be removed
|
||||
expect(bundled).not.toContain("<template");
|
||||
|
||||
// Host should contain the template content (the logo div)
|
||||
expect(bundled).toContain("Logo Here");
|
||||
|
||||
// Styles from template should be hoisted
|
||||
expect(bundled).toContain(".logo");
|
||||
|
||||
// Scripts from template should be included
|
||||
expect(bundled).toContain('window.__timelines["logo-reveal"]');
|
||||
});
|
||||
|
||||
it("does not inline template when host already has content", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<template id="comp-template">
|
||||
<div data-composition-id="comp" data-width="800" data-height="600">
|
||||
<p>Template content</p>
|
||||
</div>
|
||||
</template>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="comp" data-start="0" data-duration="5">
|
||||
<span>Already filled</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></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": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<template id="sized-template">
|
||||
<div data-composition-id="sized" data-width="800" data-height="600">
|
||||
<p>Sized content</p>
|
||||
</div>
|
||||
</template>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="sized" data-start="0" data-duration="3"></div>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,6 +449,83 @@ export async function bundleToSingleHtml(
|
||||
$(hostEl).removeAttr("data-composition-src");
|
||||
});
|
||||
|
||||
// Inline template compositions: inject <template id="X-template"> 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 <template> element). In cheerio, elements inside <template>
|
||||
// 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) {
|
||||
|
||||
@@ -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 = `
|
||||
<div data-composition-id="logo-reveal" data-width="1920" data-height="1080">
|
||||
<p>Logo content</p>
|
||||
</div>
|
||||
`;
|
||||
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 = "<p>Orphan</p>";
|
||||
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 = `
|
||||
<div data-composition-id="filled" data-width="800" data-height="600">
|
||||
<p>Template content</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(template);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-id", "filled");
|
||||
host.innerHTML = "<span>Existing content</span>";
|
||||
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 = `
|
||||
<div data-composition-id="external" data-width="800" data-height="600">
|
||||
<p>Should not mount</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div data-composition-id="comp-a" data-width="1920" data-height="1080">
|
||||
<p>Content A</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(template1);
|
||||
|
||||
const template2 = document.createElement("template");
|
||||
template2.id = "comp-b-template";
|
||||
template2.innerHTML = `
|
||||
<div data-composition-id="comp-b" data-width="800" data-height="600">
|
||||
<p>Content B</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div data-composition-id="styled-comp" data-width="1920" data-height="1080">
|
||||
<style>.test-inline { color: blue; }</style>
|
||||
<p>Styled content</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div data-composition-id="scripted-comp" data-width="1920" data-height="1080">
|
||||
<p>Content with script</p>
|
||||
<script>console.log("inline template script")</script>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div data-composition-id="dim-comp" data-width="1920" data-height="1080">
|
||||
<p>Dimensioned</p>
|
||||
</div>
|
||||
`;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,47 @@ async function mountCompositionContent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInlineTemplateCompositions(
|
||||
params: LoadExternalCompositionsParams,
|
||||
): Promise<void> {
|
||||
// Find all elements with data-composition-id but WITHOUT data-composition-src
|
||||
// that are empty (no children) and have a matching <template id="[compId]-template">
|
||||
const hosts = Array.from(
|
||||
document.querySelectorAll<Element>("[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<HTMLTemplateElement>(
|
||||
`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<void> {
|
||||
|
||||
@@ -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<string, string | number | boolean | null | string[]>;
|
||||
}) => {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user