Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). |
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. |
| `data-var-src` | `"heroImage"` | Binds the element's `src` to a declared variable — the runtime substitutes the value (URL string or image `{url}`) in preview and render; the authored `src` stays as the fallback. |
| `data-var-text` | `"title"` | Binds the element's own text to a scalar variable. Element children (nested clips, animated spans) are preserved. Scalar variables are also applied as `--{id}` CSS custom properties on the composition root, so `color: var(--accent)` responds to overrides. |

## Element Visibility

Expand Down
45 changes: 45 additions & 0 deletions docs/concepts/variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,51 @@ Inside any composition script, call `window.__hyperframes.getVariables()` to get

`__hyperframes.getVariables()` is a shorthand for `window.__hyperframes.getVariables()` and works in both top-level and sub-composition scripts. The runtime automatically scopes sub-compositions so each instance sees its own resolved values.

## Declarative Bindings (No Script Required)

For the common cases — replaceable media, dynamic text, and CSS-driven styling — you don't need a script at all. The runtime resolves these bindings once at load, identically in preview and render:

- **`data-var-src="id"`** — sets the element's `src` from the variable value (a URL string, or an image value's `{url}`). The authored `src` stays as the fallback when the variable resolves to nothing:

```html
<img class="clip" data-start="0" data-duration="5"
data-var-src="heroImage" src="fallback.jpg" />
```

<Note>
`data-var-src` is only honored on media elements (`img`, `video`, `audio`,
`source`) and only for safe URL protocols (`http(s):`, `blob:`, relative
paths, and `data:image/…`). A binding on a script-executing tag such as
`<iframe>`/`<script>`, or a value using `javascript:`/`data:text/html`, is
ignored — variable values may be attacker-influenced, so this prevents them
from becoming a script-injection sink. Scalar values applied as CSS custom
properties are likewise stripped of declaration-smuggling characters
(`; { } < >`).
</Note>

- **`data-var-text="id"`** — sets the element's text content from a scalar variable:

```html
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback title</h1>
```

- **CSS custom properties** — every scalar variable is applied as `--{id}` on its composition root (font values apply their family name), so plain CSS bindings respond to render/preview overrides:

```css
.card-title { color: var(--accent); font-family: var(--brandFont), sans-serif; }
```

Bindings resolve against the element's owning composition, so sub-composition instances see their own per-instance values. The Studio Variables panel counts these bindings as usage. Use `getVariables()` in a script only when you need logic beyond direct substitution (loops, conditionals, derived values).

<Note>
**Content Security Policy.** The preview server injects override values via an
inline `<script>window.__hfVariables=…</script>` tag. If you embed the preview
behind a strict CSP (`script-src 'self'` with no `'unsafe-inline'`), that tag is
blocked and the preview silently falls back to declared defaults — allow it with
a nonce or hash. The declarative-binding runtime itself emits no inline scripts,
and the final rendered output is unaffected.
</Note>

## Per-instance Overrides (Sub-compositions)

When embedding a composition inside another, use `data-variable-values` on the host element to pass a JSON object of override values for that particular instance:
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Common sizes:
| `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. |
| `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. |
| `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). |
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. The framework carries the values through, but your composition script must read and apply them manually. |
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. Read via `getVariables()` in scripts, or consumed automatically by declarative bindings. |
| `data-var-src` | img, video, audio | No | Binds the element's `src` to a declared variable id — the runtime substitutes the value (URL string or image `{url}`); the authored `src` is the fallback. |
| `data-var-text` | any | No | Binds the element's own text to a scalar variable id. Element children are preserved. |
| `data-width` | div | On compositions | Composition width in pixels. |
| `data-height` | div | On compositions | Composition height in pixels. |

Expand Down Expand Up @@ -152,7 +154,7 @@ Common sizes:
- Each nested composition has its own `window.__timelines` entry, registered by its own `<script>` block
- The framework automatically nests sub-timelines — do not manually add them to the parent timeline
- Any composition can be nested inside any other — there is no special "root" type
- Per-instance values can be passed with `data-variable-values`, but the nested composition must read and apply those values itself
- Per-instance values can be passed with `data-variable-values`; sub-composition scripts read them via `getVariables()`, and `data-var-*` bindings / `var(--id)` CSS resolve them automatically

For more on how compositions work, see [Compositions](/concepts/compositions).
</Accordion>
Expand Down
178 changes: 178 additions & 0 deletions packages/core/src/runtime/applyVariableBindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { applyVariableBindings } from "./applyVariableBindings";
import { getVariables } from "./getVariables";

type TestWindow = Window & {
__hfVariables?: unknown;
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
__hyperframes?: { getVariables?: () => Record<string, unknown> };
};

const win = window as TestWindow;

beforeEach(() => {
win.__hyperframes = { getVariables };
});

afterEach(() => {
delete win.__hfVariables;
delete win.__hfVariablesByComp;
delete win.__hyperframes;
document.documentElement.removeAttribute("data-composition-variables");
document.body.innerHTML = "";
});

function setDeclared(decls: unknown[]): void {
document.documentElement.setAttribute("data-composition-variables", JSON.stringify(decls));
}

describe("applyVariableBindings", () => {
it("sets src from a string variable via data-var-src", () => {
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
document.body.innerHTML = `
<div data-hf-root data-composition-id="c1">
<img id="img" data-var-src="hero" src="fallback.jpg" />
</div>`;
applyVariableBindings(document);
expect(document.getElementById("img")?.getAttribute("src")).toBe("default.jpg");
});

it("render-time overrides win, and {url} image values resolve", () => {
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
win.__hfVariables = { hero: { url: "https://cdn/override.png" } };
document.body.innerHTML = `
<div data-hf-root><video data-var-src="hero" src="fallback.mp4"></video></div>`;
applyVariableBindings(document);
expect(document.querySelector("video")?.getAttribute("src")).toBe("https://cdn/override.png");
});

it("keeps the authored src when the variable resolves to nothing", () => {
document.body.innerHTML = `<div data-hf-root><img data-var-src="ghost" src="keep.jpg" /></div>`;
applyVariableBindings(document);
expect(document.querySelector("img")?.getAttribute("src")).toBe("keep.jpg");
});

it("sets text content from a scalar via data-var-text", () => {
setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]);
win.__hfVariables = { title: "Overridden" };
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">Authored</h1></div>`;
applyVariableBindings(document);
expect(document.querySelector("h1")?.textContent).toBe("Overridden");
});

it("applies scalar variables as --{id} custom props on the root", () => {
setDeclared([
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "count", type: "number", label: "Count", default: 3 },
]);
win.__hfVariables = { accent: "#ff0000" };
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--accent")).toBe("#ff0000");
expect(root?.style.getPropertyValue("--count")).toBe("3");
});

it("applies a font value's family name, and skips other objects", () => {
win.__hfVariables = {
brandFont: { name: "Inter", source: "https://fonts" },
img: { url: "x" },
};
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--brandFont")).toBe("Inter");
expect(root?.style.getPropertyValue("--img")).toBe("");
});

it("preserves element children when binding text on a container", () => {
win.__hfVariables = { title: "Replaced" };
document.body.innerHTML = `
<div data-hf-root>
<h1 data-var-text="title">Hello <em id="kid" class="clip">world</em></h1>
</div>`;
applyVariableBindings(document);
const h1 = document.querySelector("h1");
expect(document.getElementById("kid")?.textContent).toBe("world");
expect(h1?.childNodes[0]?.nodeValue).toBe("Replaced");
});

it("is idempotent across re-application (loader re-apply path)", () => {
win.__hfVariables = { title: "Once" };
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">t</h1></div>`;
applyVariableBindings(document);
applyVariableBindings(document);
expect(document.querySelector("h1")?.textContent).toBe("Once");
});

it("resolves sub-composition elements against their scoped values", () => {
win.__hfVariablesByComp = { sub: { label: "Scoped" } };
win.__hfVariables = { label: "TopLevel" };
document.body.innerHTML = `
<div data-hf-root data-composition-id="main">
<p id="top" data-var-text="label">t</p>
<div data-composition-id="sub"><p id="inner" data-var-text="label">s</p></div>
</div>`;
applyVariableBindings(document);
expect(document.getElementById("inner")?.textContent).toBe("Scoped");
expect(document.getElementById("top")?.textContent).toBe("TopLevel");
});

describe("security", () => {
it("refuses data-var-src on a non-media tag (XSS sink)", () => {
win.__hfVariables = { evil: "javascript:alert(document.cookie)" };
document.body.innerHTML = `<div data-hf-root><iframe id="f" data-var-src="evil"></iframe></div>`;
applyVariableBindings(document);
// No src written — the iframe can't be turned into a javascript: executor.
expect(document.getElementById("f")?.hasAttribute("src")).toBe(false);
});

it("refuses an unsafe URL protocol even on an allowed media tag", () => {
win.__hfVariables = {
evil: "javascript:alert(1)",
data: "data:text/html,<script>x</script>",
};
document.body.innerHTML = `
<div data-hf-root>
<img id="a" data-var-src="evil" src="keep.jpg" />
<video id="b" data-var-src="data" src="keep.mp4"></video>
</div>`;
applyVariableBindings(document);
// Authored src preserved; the unsafe value is not applied.
expect(document.getElementById("a")?.getAttribute("src")).toBe("keep.jpg");
expect(document.getElementById("b")?.getAttribute("src")).toBe("keep.mp4");
});

it("allows https, blob, relative, and image data: URLs on media tags", () => {
win.__hfVariables = {
https: "https://cdn/x.png",
rel: "./local.png",
img: "data:image/png;base64,AAAA",
};
document.body.innerHTML = `
<div data-hf-root>
<img id="h" data-var-src="https" src="f.png" />
<img id="r" data-var-src="rel" src="f.png" />
<img id="d" data-var-src="img" src="f.png" />
</div>`;
applyVariableBindings(document);
expect(document.getElementById("h")?.getAttribute("src")).toBe("https://cdn/x.png");
expect(document.getElementById("r")?.getAttribute("src")).toBe("./local.png");
expect(document.getElementById("d")?.getAttribute("src")).toBe("data:image/png;base64,AAAA");
});

it("strips declaration-smuggling characters from a CSS custom property value", () => {
setDeclared([{ id: "accent", type: "string", label: "Accent", default: "red" }]);
win.__hfVariables = { accent: "red; background: url(//evil?data=secret)" };
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const css = document.getElementById("root")?.style.getPropertyValue("--accent") ?? "";
expect(css).not.toContain(";");
expect(css).not.toContain("{");
expect(css).not.toContain("<");
});
});
});
Loading
Loading