feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props

This commit is contained in:
James
2026-07-09 13:31:03 -07:00
parent bc0e0b314b
commit f7ee0768ae
17 changed files with 643 additions and 34 deletions
+2
View File
@@ -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
+45
View File
@@ -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:
+4 -2
View File
@@ -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. |
@@ -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>
@@ -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("<");
});
});
});
@@ -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
// (`<iframe src="javascript:…">`, `<script src="data:…">`, `<embed>`), so the
// binding is scoped to elements where `src` is purely a media reference.
const VAR_SRC_TAGS = new Set(["img", "video", "audio", "source"]);
function resolveUrl(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
if (value !== null && typeof value === "object") {
const url = (value as { url?: unknown }).url;
if (typeof url === "string" && url.length > 0) return url;
}
return null;
}
/**
* Protocol allowlist for a resolved media URL. Relative URLs (no scheme) resolve
* against the page origin and are always safe. Absolute URLs are restricted to
* http(s)/blob and image data: URIs — defense-in-depth alongside VAR_SRC_TAGS,
* blocking `javascript:`, `data:text/html`, `file:`, etc. even if a future tag
* slips past the element guard. Control chars are stripped before the scheme
* test because browsers ignore them when parsing the URL (`java\tscript:`).
*/
function isSafeMediaUrl(url: string): boolean {
// Browsers ignore ASCII control chars/whitespace when parsing a URL, so strip
// them before reading the scheme (defeats `java\tscript:` style bypasses).
// oxlint-disable-next-line no-control-regex -- control chars are the target here
const normalized = url.replace(/[\u0000-\u0020]/g, "");
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
if (!scheme) return true;
const proto = scheme[1].toLowerCase();
if (proto === "https" || proto === "http" || proto === "blob") return true;
if (proto === "data") return /^data:image\//i.test(normalized);
return false;
}
/**
* Strip characters that could smuggle additional declarations or markup out of
* a var() substitution site. A scalar value folded into `background: var(--x)`
* or `background-image: url(var(--x))` must not be able to close the declaration
* and inject a new one (`red; background: url(//evil?data=…)`) — none of these
* characters is legal in a scalar variable value (string, number, color, font
* family), so removing them is lossless for real inputs and neutralizes the
* declaration/URL-exfiltration channel.
*/
function sanitizeCssValue(value: string): string {
return value.replace(/[;{}<>\r\n]/g, "");
}
/** CSS custom-property value for a variable, or null when not CSS-applicable. */
function cssValueFor(value: unknown): string | null {
if (isScalar(value)) return String(value);
if (value !== null && typeof value === "object") {
// Font values apply their family name; the face itself must be loaded by
// the composition (or the media pipeline).
const name = (value as { name?: unknown }).name;
if (typeof name === "string" && name.length > 0) return name;
}
return null;
}
/**
* Per-run memo of scope element → resolved values, so N bound elements in
* one scope pay for one resolution (the top-level path re-parses the
* declarations attribute on every getVariables() call).
*/
type ScopeValuesCache = Map<Element | null, Record<string, unknown>>;
function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string, unknown> {
const scope = el.closest("[data-composition-id]");
const cached = cache.get(scope);
if (cached) return cached;
const values = readVariablesForElement(el);
cache.set(scope, values);
return values;
}
/**
* Replace the element's own text while preserving element children (nested
* clips, animation-target spans). Mirrors the SDK's setOwnText: write the
* first direct text node, clear the others; append when none exists.
*/
function setOwnTextPreservingChildren(el: Element, text: string): void {
if (el.childElementCount === 0) {
el.textContent = text;
return;
}
let written = false;
for (const node of Array.from(el.childNodes)) {
if (node.nodeType !== Node.TEXT_NODE) continue;
node.nodeValue = written ? "" : text;
written = true;
}
if (!written) {
el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
}
}
/**
* Composition root, matching the SDK's findRoot chain exactly — the SDK
* persists `--{id}` defaults on this element, so the runtime must write
* overrides to the SAME element or an inline default on a descendant would
* shadow an override applied higher up.
*/
function findTopRoot(doc: Document): Element | null {
return (
doc.querySelector("[data-hf-root]") ??
doc.getElementById("stage") ??
doc.body?.firstElementChild ??
doc.body
);
}
function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void {
// Top-level root plus every inlined sub-composition root; custom props
// inherit, so descendants of each root see its scope's values.
const roots = new Set<Element>();
const topRoot = findTopRoot(doc);
if (topRoot) roots.add(topRoot);
for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) {
roots.add(el);
}
for (const root of roots) {
const values = valuesForElement(root, cache);
for (const [id, value] of Object.entries(values)) {
const css = cssValueFor(value);
if (css !== null && root instanceof HTMLElement) {
root.style.setProperty(`--${id}`, sanitizeCssValue(css));
}
}
}
}
export function applyVariableBindings(doc: Document): void {
const cache: ScopeValuesCache = new Map();
applyCssCustomProperties(doc, cache);
for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) {
const id = el.getAttribute("data-var-src")?.trim();
if (!id) continue;
// Only media elements may take a variable-driven src (see VAR_SRC_TAGS) — a
// src on <iframe>/<script>/<embed> is a code-execution sink, not a media ref.
if (!VAR_SRC_TAGS.has(el.tagName.toLowerCase())) {
console.warn(
`[hyperframes] Ignoring data-var-src on <${el.tagName.toLowerCase()}>: variable-bound src is only allowed on ${Array.from(VAR_SRC_TAGS).join("/")}.`,
);
continue;
}
const url = resolveUrl(valuesForElement(el, cache)[id]);
if (url === null) continue;
if (!isSafeMediaUrl(url)) {
console.warn(`[hyperframes] Ignoring data-var-src="${id}": unsafe URL protocol.`);
continue;
}
el.setAttribute("src", url);
}
for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) {
const id = el.getAttribute("data-var-text")?.trim();
if (!id) continue;
const value = valuesForElement(el, cache)[id];
if (isScalar(value)) setOwnTextPreservingChildren(el, String(value));
}
}
+1 -15
View File
@@ -4,7 +4,6 @@ import {
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
type HfColorGradingVariableMap,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "../colorGrading";
@@ -16,6 +15,7 @@ import {
type CubeLutVec3,
} from "../colorLuts";
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
import { readVariablesForElement } from "./variableScope";
import { swallow } from "./diagnostics";
type ColorGradingMediaElement = HTMLVideoElement | HTMLImageElement;
@@ -207,20 +207,6 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
lineWidth: 2,
};
function readVariablesForElement(element: Element): HfColorGradingVariableMap {
const win = window as WindowWithColorGrading;
const scope = element.closest("[data-composition-id]");
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
if (scoped) return scoped;
const fromHelper = win.__hyperframes?.getVariables?.();
if (fromHelper && typeof fromHelper === "object") {
return fromHelper;
}
return win.__hfVariables ?? {};
}
function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null {
const raw = element.getAttribute(HF_COLOR_GRADING_ATTR);
if (raw == null) return null;
+9
View File
@@ -30,6 +30,7 @@ import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import { applyPositionEdits } from "./positionEdits";
import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
@@ -85,6 +86,10 @@ export function initSandboxRuntimeModular(): void {
// parsed their tweens, so GSAP (when present) won't fold the translate.
// Re-applied on every timeline bind for the rebind/soft-reload paths.
applyPositionEdits(document);
// Declarative variable bindings (data-var-src / data-var-text / --{id} CSS
// custom props) — values are fixed for the page's lifetime, so applying
// once at init keeps renders deterministic and seeks safe.
applyVariableBindings(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
@@ -2015,6 +2020,10 @@ export function initSandboxRuntimeModular(): void {
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
// Runtime-loaded sub-compositions (and their per-instance scoped
// values) don't exist at the init-time binding pass — re-apply so
// data-var-* / --{id} bindings inside them resolve. Idempotent.
applyVariableBindings(document);
maybePublishRenderReady();
});
} else {
@@ -0,0 +1,26 @@
/**
* Resolve the composition-variable values an element should see: the scoped
* per-instance table for inlined sub-compositions, then the top-level merged
* getVariables(), then the raw render-injection global. Shared by every
* runtime consumer of variables (color grading, declarative bindings) so the
* scope chain can never diverge between channels.
*/
type VariablesWindow = Window & {
__hfVariables?: Record<string, unknown>;
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
__hyperframes?: { getVariables?: () => Record<string, unknown> };
};
export function readVariablesForElement(element: Element): Record<string, unknown> {
const win = window as VariablesWindow;
const scope = element.closest("[data-composition-id]");
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
if (scoped) return scoped;
const fromHelper = win.__hyperframes?.getVariables?.();
if (fromHelper && typeof fromHelper === "object") {
return fromHelper;
}
return win.__hfVariables ?? {};
}
@@ -753,6 +753,37 @@ describe("composition rules", () => {
});
});
describe("unknown_variable_binding", () => {
it("warns when data-var-src references an undeclared variable", async () => {
const html = `<html data-composition-variables='[{"id":"hero","type":"image","label":"Hero","default":"a.jpg"}]'><body>
<img id="i" data-start="0" data-duration="2" data-var-src="heroImge" src="a.jpg" />
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "unknown_variable_binding");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toMatch(/heroImge/);
});
it("stays quiet for declared binding ids and for fragment files", async () => {
const declared = `<html data-composition-variables='[{"id":"title","type":"string","label":"T","default":"x"}]'><body>
<h1 data-var-text="title">x</h1>
</body></html>`;
expect(
(await lintHyperframeHtml(declared)).findings.some(
(f) => f.code === "unknown_variable_binding",
),
).toBe(false);
const fragment = `<div class="clip" data-start="0" data-duration="2" data-var-text="hostProvided">x</div>`;
expect(
(await lintHyperframeHtml(fragment)).findings.some(
(f) => f.code === "unknown_variable_binding",
),
).toBe(false);
});
});
describe("invalid_variable_values_json", () => {
it("warns when data-variable-values is unparseable JSON", async () => {
const html = `<html><body>
+50
View File
@@ -107,6 +107,25 @@ function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[
return offenders;
}
/** Declared variable ids from an <html> tag's raw text; null when the JSON is unparseable. */
function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
const declared = new Set<string>();
const raw = readJsonAttr(htmlTagRaw, "data-composition-variables");
if (!raw) return declared;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(parsed)) return declared;
for (const entry of parsed) {
const id = (entry as { id?: unknown } | null)?.id;
if (typeof id === "string") declared.add(id);
}
return declared;
}
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
// href, inline-style url(), and <style> url() asset references on
@@ -597,6 +616,37 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// unknown_variable_binding
// data-var-src / data-var-text bind an element to a declared variable id;
// the runtime silently keeps the authored fallback when the id resolves to
// nothing, so a typo'd binding is invisible until a customer's override
// does nothing. Skipped for fragment files (no <html>): their values come
// from a host's data-variable-values, which this file can't see.
({ source, tags }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const declared = collectDeclaredVariableIds(htmlTag.raw);
// null = unparseable declarations; invalid_composition_variables_declaration
// reports that failure, so this rule stays quiet.
if (declared === null) return [];
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
for (const attr of ["data-var-src", "data-var-text"]) {
const id = readAttr(tag.raw, attr)?.trim();
if (!id || declared.has(id)) continue;
findings.push({
code: "unknown_variable_binding",
severity: "warning",
message: `<${tag.name}> binds ${attr}="${id}" but no variable "${id}" is declared in data-composition-variables — the binding will silently keep the authored fallback.`,
fixHint: `Declare the variable on <html>: data-composition-variables='[{"id":"${id}","type":"${attr === "data-var-src" ? "image" : "string"}","label":"${id}","default":"..."}]', or fix the binding id.`,
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// invalid_composition_variables_declaration
// The runtime parses `data-composition-variables` and silently returns []
// on any structural problem. Surface JSON / shape failures so authors
+21
View File
@@ -296,3 +296,24 @@ describe("media rules", () => {
expect(finding).toBeUndefined();
});
});
describe("media_variable_src_no_fallback", () => {
it("downgrades missing src to a warning when data-var-src is present", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2" data-var-src="media"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
const finding = result.findings.find((f) => f.code === "media_variable_src_no_fallback");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("keeps the hard error when neither src nor data-var-src exists", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(true);
});
});
+24 -8
View File
@@ -460,14 +460,30 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
});
}
if (hasDataStart && hasId && !hasSrc) {
findings.push({
code: "media_missing_src",
severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
snippet: truncateSnippet(tag.raw),
});
const varSrc = readAttr(tag.raw, "data-var-src");
if (varSrc) {
// Variable-bound media without a fallback still renders when the
// variable resolves, but a render without a value can't load the
// media, and the audio pipeline discovers tracks from the AUTHORED
// src — warn instead of hard-failing the binding pattern.
findings.push({
code: "media_variable_src_no_fallback",
severity: "warning",
message: `<${tag.name} id="${hasId}"> relies on data-var-src="${varSrc}" with no fallback src. Renders without a "${varSrc}" value cannot load this media, and audio extraction reads the authored src.`,
elementId: hasId,
fixHint: `Add a fallback src the composition can render with when the variable is not provided.`,
snippet: truncateSnippet(tag.raw),
});
} else {
findings.push({
code: "media_missing_src",
severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
snippet: truncateSnippet(tag.raw),
});
}
}
if (readAttr(tag.raw, "preload") === "none") {
findings.push({
+12
View File
@@ -264,6 +264,18 @@ class CompositionImpl implements Composition {
}
}
}
// Declarative bindings (data-var-src / data-var-text) are direct reads.
for (const el of Array.from(
this.parsed.document.querySelectorAll("[data-var-src], [data-var-text]"),
)) {
for (const attr of ["data-var-src", "data-var-text"]) {
const id = el.getAttribute(attr)?.trim();
if (id && !seen.has(id)) {
seen.add(id);
usedIds.push(id);
}
}
}
this._variableUsageScanCache = freshCache;
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
// The CSS compat channel counts as usage: a variable consumed only via
@@ -82,6 +82,22 @@ describe("getVariableUsage", () => {
expect(usage.unusedDeclarations).toContain("accent");
});
it("counts declarative data-var-src / data-var-text bindings as usage", async () => {
const comp = await openComposition(`<!DOCTYPE html>
<html data-composition-variables='${DECLS}'>
<body>
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
<img data-hf-id="hf-img" data-var-src="accent" src="x.jpg" />
<h1 data-hf-id="hf-h" data-var-text="title">t</h1>
</div>
</body>
</html>`);
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual(expect.arrayContaining(["accent", "title"]));
expect(usage.unusedDeclarations).toEqual(["orphan"]);
expect(usage.scanIncomplete).toBe(false);
});
it("handles compositions with no declarations and no scripts", async () => {
const comp = await openComposition(
`<!DOCTYPE html><html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div></body></html>`,
+1 -1
View File
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-core": {
"hash": "2b1fb57fc6964e76",
"hash": "511a6283a5dbd7ea",
"files": 14
},
"hyperframes-creative": {
@@ -40,12 +40,14 @@ Timed child elements are clips. **`class="clip"` is required on visible timed el
When a clip is a sub-composition host (loads another composition file):
| Attribute | Required | Meaning |
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
| `data-composition-id` | Yes | The internal composition ID of the loaded file. |
| `data-composition-src` | Yes | Path to the sub-composition HTML file. |
| `data-width` / `data-height` | Yes | Render dimensions for the sub-composition instance. |
| `data-variable-values` | No | Per-instance variable overrides as JSON. See `variables-and-media.md`. |
| Attribute | Required | Meaning |
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `data-composition-id` | Yes | The internal composition ID of the loaded file. |
| `data-composition-src` | Yes | Path to the sub-composition HTML file. |
| `data-width` / `data-height` | Yes | Render dimensions for the sub-composition instance. |
| `data-variable-values` | No | Per-instance variable overrides as JSON. See `variables-and-media.md`. |
| `data-var-src` | No | Binds the element's `src` to a declared variable id (media/image substitution, authored src = fallback). |
| `data-var-text` | No | Binds the element's own text to a scalar variable id; children are preserved. |
See `sub-compositions.md` for the full wiring pattern.
@@ -15,12 +15,29 @@ Declare variables on the `<html>` element with `data-composition-variables`. Eac
></html>
```
Read resolved values once during initialization:
**Prefer declarative bindings — no script needed** for direct substitution:
```html
<img class="clip" data-start="0" data-duration="5" data-var-src="heroImage" src="fallback.jpg" />
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback</h1>
<style>
.card {
color: var(--accent);
}
</style>
```
- `data-var-src="id"` substitutes the element's `src` (URL string or image `{url}`); the authored `src` is the fallback.
- `data-var-text="id"` substitutes the element's own text; element children (nested clips, animated spans) are preserved.
- Every scalar variable is applied automatically as a `--{id}` CSS custom property on the composition root, so `var(--id)` CSS responds to overrides — no `setProperty` boilerplate.
- Bindings resolve identically in preview and render, and per-instance for sub-compositions.
- Caveat: media with audio should keep a real fallback `src` — render audio extraction reads the authored attribute (lint: `media_variable_src_no_fallback`).
For logic beyond direct substitution (loops, conditionals, derived values), read values once during initialization:
```js
const { title, accent } = window.__hyperframes.getVariables();
document.getElementById("title").textContent = title;
document.documentElement.style.setProperty("--accent", accent);
```
### Variable Rules