From 9e9a12fa8727d4d845ce3fae1d3809d3a6c33311 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 7 Jul 2026 23:26:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20declarative=20variable=20bindings?= =?UTF-8?q?=20=E2=80=94=20data-var-src,=20data-var-text,=20css=20custom=20?= =?UTF-8?q?props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/concepts/data-attributes.mdx | 2 + docs/concepts/variables.mdx | 45 ++++ docs/reference/html-schema.mdx | 6 +- .../src/runtime/applyVariableBindings.test.ts | 178 ++++++++++++++++ .../core/src/runtime/applyVariableBindings.ts | 196 ++++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 16 +- packages/core/src/runtime/init.ts | 9 + packages/core/src/runtime/variableScope.ts | 26 +++ packages/lint/src/rules/composition.test.ts | 31 +++ packages/lint/src/rules/composition.ts | 50 +++++ packages/lint/src/rules/media.test.ts | 21 ++ packages/lint/src/rules/media.ts | 32 ++- packages/sdk/src/session.ts | 12 ++ .../sdk/src/session.variableusage.test.ts | 16 ++ skills-manifest.json | 2 +- .../references/data-attributes.md | 14 +- .../references/variables-and-media.md | 21 +- 17 files changed, 643 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/runtime/applyVariableBindings.test.ts create mode 100644 packages/core/src/runtime/applyVariableBindings.ts create mode 100644 packages/core/src/runtime/variableScope.ts diff --git a/docs/concepts/data-attributes.mdx b/docs/concepts/data-attributes.mdx index 70786ff1a1..f50a692b18 100644 --- a/docs/concepts/data-attributes.mdx +++ b/docs/concepts/data-attributes.mdx @@ -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 ''` 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 diff --git a/docs/concepts/variables.mdx b/docs/concepts/variables.mdx index 9d4fc0fd72..fd8fbb07be 100644 --- a/docs/concepts/variables.mdx +++ b/docs/concepts/variables.mdx @@ -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 + + ``` + + + `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 + ``; + 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,", + }; + document.body.innerHTML = ` +
+ + +
`; + 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 = ` +
+ + + +
`; + 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 = `
`; + applyVariableBindings(document); + const css = document.getElementById("root")?.style.getPropertyValue("--accent") ?? ""; + expect(css).not.toContain(";"); + expect(css).not.toContain("{"); + expect(css).not.toContain("<"); + }); + }); +}); diff --git a/packages/core/src/runtime/applyVariableBindings.ts b/packages/core/src/runtime/applyVariableBindings.ts new file mode 100644 index 0000000000..79a37211a7 --- /dev/null +++ b/packages/core/src/runtime/applyVariableBindings.ts @@ -0,0 +1,196 @@ +/** + * Declarative variable bindings — the no-script consumption channel for + * composition variables (values are fixed for the page's lifetime, so this is + * seek-safe and deterministic): + * + * - `data-var-src="id"` — sets the element's `src` from the variable value + * (a URL string or an image value `{url}`). Only allowed on media elements + * (img/video/audio/source) and only for safe URL protocols — a src on a + * script-executing tag or a `javascript:`/`data:text/html` value is refused. + * The authored src stays as the fallback when the variable resolves to nothing. + * - `data-var-text="id"` — sets the element's OWN text from a scalar variable + * value. Elements with element children keep them: only the direct text + * node is replaced, mirroring the SDK's setOwnText semantics — a text + * binding must never delete nested clips or animation targets. + * - Every scalar variable (and a font value's family name) is applied as a + * `--{id}` CSS custom property on its composition root, so CSS bindings + * like `color: var(--accent)` respond to render/preview overrides instead + * of only the persisted default. + * + * Values resolve against the element's owning composition — the same scope + * chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for + * inlined sub-compositions, then the top-level merged `getVariables()`. + * + * Applied at init AND re-applied after the composition loader inlines + * external / template sub-compositions (their DOM and per-instance scoped + * values don't exist at init). Idempotent: re-applying writes the same + * values. + */ + +import { readVariablesForElement } from "./variableScope"; +import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition"; + +// data-var-src only rebinds media `src` on media elements. A user-controlled +// variable value assigned to a src is an XSS surface on tags whose src executes +// (`