Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions packages/cli/src/docs/compositions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,35 @@ Embed one composition inside another:
<div data-composition-src="./intro.html" data-start="0" data-duration="5"></div>
```

## Listing Compositions

Use `npx hyperframes compositions` to see all compositions in a project.
## Parameterized Compositions

## Variables

Compositions can expose variables for dynamic content:
Reuse a composition with different data using `data-props`:

```html
<div data-composition-id="card" data-var-title="string" data-var-color="color"></div>
<!-- Root: same component, different data -->
<div class="clip"
data-composition-src="compositions/card.html"
data-props='{"title":"Pro","price":"$29","color":"#ec4899"}'
data-start="0" data-duration="5" data-track-index="0">
</div>

<!-- Sub-composition: uses {{key}} placeholders -->
<!-- compositions/card.html -->
<template id="card-template">
<div data-composition-id="card" data-width="1920" data-height="1080">
<style>.card { background: {{color:#6366f1}}; }</style>
<h2>{{title:Card Title}}</h2>
<p>{{price:$0}}/mo</p>
</div>
</template>
```

- `data-props` accepts a JSON object
- `{{key}}` — replaced with value, left as-is if unmatched
- `{{key:default}}` — replaced with value, or default if unmatched (use this so compositions render standalone)
- Works in HTML, CSS, and scripts
- Values are HTML-escaped in content, raw in CSS, JS-escaped in scripts

## Listing Compositions

Use `npx hyperframes compositions` to see all compositions in a project.
127 changes: 127 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,131 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('url("fonts/brand.woff2")');
expect(bundled).not.toContain('url("../fonts/brand.woff2")');
});

it("interpolates {{key}} placeholders in sub-compositions using data-props", 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>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="card1"
data-composition-id="card1"
data-composition-src="compositions/card.html"
data-props='{"title":"Pro Plan","price":"$19/mo","featured":true}'
data-start="0" data-duration="5" data-track-index="0" class="clip"></div>
<div id="card2"
data-composition-id="card2"
data-composition-src="compositions/card.html"
data-props='{"title":"Enterprise","price":"$49/mo","featured":false}'
data-start="5" data-duration="5" data-track-index="0" class="clip"></div>
</div>
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
</body></html>`,
"compositions/card.html": `<template id="card1-template">
<div data-composition-id="card1" data-width="1920" data-height="1080">
<h2 class="card-title">{{title}}</h2>
<p class="card-price">{{price}}</p>
<span class="badge">Featured: {{featured}}</span>
<script>
window.__timelines = window.__timelines || {};
const cardTl = gsap.timeline({ paused: true });
window.__timelines["card1"] = cardTl;
</script>
</div>
</template>`,
});

const bundled = await bundleToSingleHtml(dir);

// Card 1 should have interpolated values
expect(bundled).toContain("Pro Plan");
expect(bundled).toContain("$19/mo");

// Card 2 should have its own interpolated values
expect(bundled).toContain("Enterprise");
expect(bundled).toContain("$49/mo");

// No raw mustache placeholders should remain for resolved keys
expect(bundled).not.toContain("{{title}}");
expect(bundled).not.toContain("{{price}}");
});

it("interpolates {{key}} in inline template compositions", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<template id="badge-template">
<div data-composition-id="badge" data-width="1920" data-height="1080">
<span class="label">{{label}}</span>
<style>.label { color: {{color}}; }</style>
</div>
</template>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="badge"
data-props='{"label":"BEST VALUE","color":"#ff0000"}'
data-start="0" data-duration="5" data-track-index="0" class="clip"></div>
</div>
</body></html>`,
});

const bundled = await bundleToSingleHtml(dir);

expect(bundled).toContain("BEST VALUE");
expect(bundled).toContain("#ff0000");
expect(bundled).not.toContain("{{label}}");
expect(bundled).not.toContain("{{color}}");
});

it("HTML-escapes interpolated values to prevent XSS", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="xss-test"
data-composition-id="xss-test"
data-composition-src="compositions/unsafe.html"
data-props='{"name":"<script>alert(1)</script>"}'
data-start="0" data-duration="5" data-track-index="0" class="clip"></div>
</div>
</body></html>`,
"compositions/unsafe.html": `<template id="xss-test-template">
<div data-composition-id="xss-test" data-width="1920" data-height="1080">
<p>Hello, {{name}}</p>
</div>
</template>`,
});

const bundled = await bundleToSingleHtml(dir);

// The interpolated content inside <p> should be escaped
expect(bundled).toContain("Hello, &lt;script&gt;alert(1)&lt;/script&gt;");
// The raw script tag should NOT appear as actual HTML content (only in the JSON attribute)
expect(bundled).not.toContain("<p>Hello, <script>alert(1)</script></p>");
});

it("resolves {{key:default}} when no data-props provided", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<template id="card-template">
<div data-composition-id="card" data-width="1920" data-height="1080">
<style>.card { background: {{bgColor:#6366f1}}; }</style>
<h2>{{title:Default Title}}</h2>
</div>
</template>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="card"
data-start="0" data-duration="5" data-track-index="0" class="clip"></div>
</div>
</body></html>`,
});

const bundled = await bundleToSingleHtml(dir);

expect(bundled).toContain("Default Title");
expect(bundled).toContain("#6366f1");
expect(bundled).not.toContain("{{title");
expect(bundled).not.toContain("{{bgColor");
});
});
54 changes: 44 additions & 10 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
import {
parseVariableValues,
interpolateProps,
interpolateScriptProps,
interpolateCssProps,
} from "./interpolateProps";

/**
* Parse an HTML string into a document. Fragments (without a full document
Expand Down Expand Up @@ -431,6 +437,11 @@ export async function bundleToSingleHtml(
continue;
}

// Parse variable values from host element for per-context interpolation
const variableValues = parseVariableValues(hostEl.getAttribute("data-props"));

// Parse first — interpolation happens per-context below (HTML-escaped for
// text/attributes, raw for CSS, JS-escaped for scripts).
const compDoc = parseHTMLContent(compHtml);
const compId = hostEl.getAttribute("data-composition-id");
const contentRoot = compDoc.querySelector("template");
Expand All @@ -441,7 +452,10 @@ export async function bundleToSingleHtml(
: contentDoc.querySelector("[data-composition-id]");

for (const s of [...contentDoc.querySelectorAll("style")]) {
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
const cssText = s.textContent || "";
// Interpolate props in CSS with raw replacement (HTML entities are invalid in CSS)
const interpolatedCss = interpolateCssProps(cssText, variableValues);
compStyleChunks.push(rewriteCssAssetUrls(interpolatedCss, src));
s.remove();
}
for (const s of [...contentDoc.querySelectorAll("script")]) {
Expand All @@ -453,8 +467,11 @@ export async function bundleToSingleHtml(
compExternalScriptSrcs.push(externalSrc);
}
} else {
const scriptText = s.textContent || "";
// Interpolate props in script content (e.g., {{duration}} in GSAP timelines)
const interpolatedScript = interpolateScriptProps(scriptText, variableValues);
compScriptChunks.push(
`(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
`(function(){ try { ${interpolatedScript} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
s.remove();
Expand All @@ -474,6 +491,15 @@ export async function bundleToSingleHtml(
},
);

// Interpolate {{key}} and {{key:default}} in remaining HTML content (always run for defaults)
{
const targetRoot = innerRoot ?? contentDoc.body;
if (targetRoot) {
const rawInner = targetRoot.innerHTML || "";
targetRoot.innerHTML = interpolateProps(rawInner, variableValues);
}
}

if (innerRoot) {
const innerCompId = innerRoot.getAttribute("data-composition-id");
const innerW = innerRoot.getAttribute("data-width");
Expand Down Expand Up @@ -508,27 +534,31 @@ export async function bundleToSingleHtml(
if (!host) continue;
if (host.children.length > 0) continue; // already has content

// Get template content and inject into host
// Parse first (raw), then interpolate per-context — matching the external
// composition path so CSS gets raw values and scripts get JS-escaped values.
const templateHtml = templateEl.innerHTML || "";
const templateVarValues = parseVariableValues(host.getAttribute("data-props"));

const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);

if (innerRoot) {
// Hoist styles into the collected style chunks
// Hoist styles (CSS: raw interpolation)
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
compStyleChunks.push(styleEl.textContent || "");
compStyleChunks.push(interpolateCssProps(styleEl.textContent || "", templateVarValues));
styleEl.remove();
}
// Hoist scripts into the collected script chunks
// Hoist scripts (JS: JS-escaped interpolation)
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
const scriptText = interpolateScriptProps(scriptEl.textContent || "", templateVarValues);
compScriptChunks.push(
`(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
`(function(){ try { ${scriptText} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
scriptEl.remove();
Expand All @@ -540,12 +570,13 @@ export async function bundleToSingleHtml(
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);

// Set host content from inner root
// Interpolate remaining HTML content (HTML-escaped via innerHTML serialization)
innerRoot.innerHTML = interpolateProps(innerRoot.innerHTML || "", templateVarValues);
host.innerHTML = innerRoot.innerHTML || "";
} else {
// No matching inner root — inject all template content directly
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
compStyleChunks.push(styleEl.textContent || "");
compStyleChunks.push(interpolateCssProps(styleEl.textContent || "", templateVarValues));
styleEl.remove();
}
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
Expand All @@ -555,12 +586,15 @@ export async function bundleToSingleHtml(
compExternalScriptSrcs.push(externalSrc);
}
} else {
const scriptText = interpolateScriptProps(scriptEl.textContent || "", templateVarValues);
compScriptChunks.push(
`(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
`(function(){ try { ${scriptText} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
scriptEl.remove();
}
// Interpolate remaining HTML content
innerDoc.body.innerHTML = interpolateProps(innerDoc.body.innerHTML || "", templateVarValues);
host.innerHTML = innerDoc.body.innerHTML || "";
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ export {
type HyperframeStaticFailureReason,
type HyperframeStaticGuardResult,
} from "./staticGuard";

// Prop interpolation
export {
interpolateProps,
interpolateScriptProps,
interpolateCssProps,
parseVariableValues,
} from "./interpolateProps";
Loading
Loading