mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(core): scope getVariables() per sub-comp instance (PR 2/4) (#601)
## What Building on **PR #600** (`getVariables()` helper + `--variables` flag), this PR scopes the helper so embedded sub-compositions see their own per-instance values. The same composition source can now be embedded N times with different content via `data-variable-values` on each host. This is **PR 2 of a 4-PR stack** — based on `feat/get-variables`. Will retarget to `main` after PR #600 merges. ## Why `data-variable-values` was already documented as the per-instance attribute for nested compositions, but readers had to hand-roll `JSON.parse(host.dataset.variableValues)` and there was no scoping system — every sub-comp script had to re-implement the same pattern. This PR routes the values to a scoped `getVariables()` so authors use one API in both top-level and sub-comp contexts. ## How ``` [host element] [sub-comp source HTML] data-variable-values='{...}' + <html data-composition-variables='[...]'> ^ declared defaults ↓ ↓ └──────────── compositionLoader ────────────────────┘ ↓ window.__hfVariablesByComp[compId] = merged ↓ scripts wrapped by compositionScoping ↓ __hyperframes.getVariables() → scoped lookup ``` Three small surgical changes: - **`compositionLoader.ts`** — before injecting wrapped scripts, parse the host's `data-variable-values` JSON, merge it over `readDeclaredDefaults(doc.documentElement)` (reused from PR 1's runtime helper), and write the result to `window.__hfVariablesByComp[compositionId]`. Skipped when both sides are empty so the table only grows when there's actual data. Inline templates (no separate `<html>` root) get host overrides only. - **`compositionScoping.ts`** — wrapper IIFE now takes a fourth parameter `__hyperframes` alongside the existing scoped `document` / `gsap` / `window`. Same shadowing pattern that already works for the other three. The scoped `__hyperframes` overrides `getVariables` to read from `__hfVariablesByComp[__hfCompId]` and returns a fresh object each call so script mutations don't leak into the shared table. - **`getVariables.ts`** — `readDeclaredDefaults` becomes a named export (was a private helper) so the loader reuses the exact same defaults-extraction logic the top-level helper uses. Top-level scripts that aren't wrapped by `compositionScoping` keep calling `window.__hyperframes.getVariables()` and get the unscoped path from PR 1 (declared defaults + CLI `--variables` override). ## Test plan - [x] Unit tests added/updated - **3 new `compositionScoping.test.ts`** — scoped `getVariables` invocation routes to `__hfVariablesByComp[compId]`; missing-entry returns `{}`; mutation of the returned object doesn't leak into the shared table. - **5 new `compositionLoader.test.ts`** — merge order (host overrides win); declared-only path when host has no `data-variable-values`; empty-skip when neither side has data; invalid-host-JSON falls through to declared defaults; per-instance scoping across two hosts that share a source. - **3 new `getVariables.test.ts`** — covering the newly-public `readDeclaredDefaults` export (null root, valid attribute, invalid JSON / non-array). - All 622 existing core tests green. - [x] Manual flow walkthrough — host with `data-variable-values='{"title":"Pro"}'` → sub-comp's `__hyperframes.getVariables()` returns `{title:"Pro", ...declaredDefaults}`. Two hosts pointing at the same source with different overrides → each script sees its own values. - [x] Documentation updated - `docs/concepts/compositions.mdx` — switched the sub-comp example from `JSON.parse(host.dataset.variableValues)` to `__hyperframes.getVariables()`; added declared-defaults pattern and per-instance scoping note. - `docs/concepts/data-attributes.mdx` — clarified per-instance behavior on `data-variable-values`. ## Backwards compatibility Fully backwards compatible. Compositions that read `host.dataset.variableValues` directly keep working — the host attribute is still set as before. The new path is a strict addition. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -129,52 +129,62 @@ Every composition has two layers:
|
||||
|
||||
HyperFrames does not automatically bind `data-var-*` attributes into your composition DOM or CSS.
|
||||
|
||||
Today, the supported pattern is:
|
||||
The supported pattern is:
|
||||
|
||||
1. Pass per-instance values on the composition host with `data-variable-values`
|
||||
2. Read those values inside the composition and apply them in your own script
|
||||
1. Declare the variables once on the sub-comp's `<html>` root with `data-composition-variables` (id + type + default).
|
||||
2. Pass per-instance values on each composition host with `data-variable-values`.
|
||||
3. Read the resolved values inside the composition with `window.__hyperframes.getVariables()`. The runtime layers the host's `data-variable-values` over the declared defaults on a per-instance basis, so the same source can be embedded multiple times with different values.
|
||||
|
||||
```html index.html
|
||||
<div
|
||||
data-composition-id="card"
|
||||
data-composition-id="card-pro"
|
||||
data-composition-src="compositions/card.html"
|
||||
data-start="0"
|
||||
data-track-index="1"
|
||||
data-variable-values='{"title":"Hello","color":"#ff4d4f"}'
|
||||
data-variable-values='{"title":"Pro","color":"#ff4d4f"}'
|
||||
></div>
|
||||
<div
|
||||
data-composition-id="card-enterprise"
|
||||
data-composition-src="compositions/card.html"
|
||||
data-start="card-pro"
|
||||
data-track-index="1"
|
||||
data-variable-values='{"title":"Enterprise","color":"#22c55e"}'
|
||||
></div>
|
||||
```
|
||||
|
||||
```html compositions/card.html
|
||||
<template id="card-template">
|
||||
<div data-composition-id="card" data-width="1920" data-height="1080">
|
||||
<h1 class="title">Fallback</h1>
|
||||
<html data-composition-variables='[
|
||||
{"id":"title","type":"string","label":"Title","default":"Fallback"},
|
||||
{"id":"color","type":"color","label":"Color","default":"#111827"}
|
||||
]'>
|
||||
<body>
|
||||
<div data-composition-id="card" data-width="1920" data-height="1080">
|
||||
<h1 class="title"></h1>
|
||||
|
||||
<style>
|
||||
[data-composition-id="card"] {
|
||||
--card-color: #111827;
|
||||
}
|
||||
<style>
|
||||
[data-composition-id="card"] {
|
||||
--card-color: #111827;
|
||||
}
|
||||
|
||||
[data-composition-id="card"] .title {
|
||||
color: var(--card-color);
|
||||
}
|
||||
</style>
|
||||
[data-composition-id="card"] .title {
|
||||
color: var(--card-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const root = document.querySelector('[data-composition-id="card"]');
|
||||
const vars = JSON.parse(root?.getAttribute("data-variable-values") ?? "{}");
|
||||
const titleEl = root?.querySelector(".title");
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = vars.title ?? "Fallback";
|
||||
}
|
||||
|
||||
root?.style.setProperty("--card-color", String(vars.color ?? "#111827"));
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// Inside a sub-comp script, getVariables() returns the per-instance
|
||||
// values: declared defaults < host data-variable-values overrides.
|
||||
const { title, color } = __hyperframes.getVariables();
|
||||
const root = document.querySelector('[data-composition-id="card"]');
|
||||
root.querySelector(".title").textContent = title;
|
||||
root.style.setProperty("--card-color", color);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
If you are building tooling on top of `@hyperframes/core`, you can also declare variable metadata separately with `data-composition-variables` and read it via `extractCompositionMetadata()`. That metadata is descriptive only; you still apply the actual values manually inside the composition.
|
||||
If you are building tooling on top of `@hyperframes/core`, the same `data-composition-variables` array is readable via `extractCompositionMetadata()` for Studio editing UI and analysis pipelines.
|
||||
|
||||
## Listing Compositions
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
|
||||
| `data-width` | `"1920"` | Composition width in pixels |
|
||||
| `data-height` | `"1080"` | Composition height in pixels |
|
||||
| `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. HyperFrames carries these values through, but your composition script must read and apply them manually. |
|
||||
| `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 render time. |
|
||||
| `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`. |
|
||||
|
||||
## Element Visibility
|
||||
|
||||
|
||||
@@ -51,6 +51,74 @@ body { margin: 0; }
|
||||
expect(scoped).not.toContain('[data-start="0"]');
|
||||
});
|
||||
|
||||
it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => {
|
||||
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
|
||||
const fakeWindow: Record<string, unknown> = {
|
||||
document,
|
||||
__timelines: {},
|
||||
__hfVariablesByComp: {
|
||||
"card-1": { title: "Pro", price: "$29" },
|
||||
"card-2": { title: "Enterprise", price: "Custom" },
|
||||
},
|
||||
__hyperframes: {
|
||||
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
|
||||
fitTextFontSize: () => undefined,
|
||||
},
|
||||
};
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
`window.__captured = __hyperframes.getVariables();`,
|
||||
"card-1",
|
||||
);
|
||||
|
||||
new Function("window", wrapped)(fakeWindow);
|
||||
|
||||
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
|
||||
});
|
||||
|
||||
it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => {
|
||||
const { document } = parseHTML(`<div data-composition-id="missing"></div>`);
|
||||
const fakeWindow: Record<string, unknown> = {
|
||||
document,
|
||||
__timelines: {},
|
||||
__hyperframes: {
|
||||
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
|
||||
fitTextFontSize: () => undefined,
|
||||
},
|
||||
};
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
`window.__captured = __hyperframes.getVariables();`,
|
||||
"missing",
|
||||
);
|
||||
|
||||
new Function("window", wrapped)(fakeWindow);
|
||||
|
||||
expect(fakeWindow.__captured).toEqual({});
|
||||
});
|
||||
|
||||
it("scoped getVariables returns a fresh object — mutations don't leak into the shared table", () => {
|
||||
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
|
||||
const variablesByComp: Record<string, Record<string, unknown>> = {
|
||||
"card-1": { title: "Pro" },
|
||||
};
|
||||
const fakeWindow: Record<string, unknown> = {
|
||||
document,
|
||||
__timelines: {},
|
||||
__hfVariablesByComp: variablesByComp,
|
||||
__hyperframes: {
|
||||
getVariables: () => ({}),
|
||||
fitTextFontSize: () => undefined,
|
||||
},
|
||||
};
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
`var v = __hyperframes.getVariables(); v.title = "MUTATED"; v.added = "extra";`,
|
||||
"card-1",
|
||||
);
|
||||
|
||||
new Function("window", wrapped)(fakeWindow);
|
||||
|
||||
expect(variablesByComp["card-1"]).toEqual({ title: "Pro" });
|
||||
});
|
||||
|
||||
it("executes document and GSAP selectors inside the composition root", () => {
|
||||
const { document } = parseHTML(`
|
||||
<div data-composition-id="scene" data-start="intro"><h1 class="title">Scene</h1></div>
|
||||
|
||||
@@ -261,11 +261,21 @@ export function wrapScopedCompositionScript(
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
var __hfBaseHyperframes = window.__hyperframes;
|
||||
var __hfScopedHyperframes = !__hfBaseHyperframes
|
||||
? __hfBaseHyperframes
|
||||
: Object.assign({}, __hfBaseHyperframes, {
|
||||
getVariables: function() {
|
||||
var byComp = window.__hfVariablesByComp;
|
||||
var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null;
|
||||
return scoped ? Object.assign({}, scoped) : {};
|
||||
},
|
||||
});
|
||||
var __hfRun = function() {
|
||||
try {
|
||||
(function(document, gsap, window) {
|
||||
(function(document, gsap, window, __hyperframes) {
|
||||
${source}
|
||||
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
|
||||
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
|
||||
} catch (_err) {
|
||||
console.error(__hfErrorLabel, __hfCompId, _err);
|
||||
}
|
||||
|
||||
@@ -264,6 +264,144 @@ describe("loadExternalCompositions", () => {
|
||||
expect(host1.querySelector("p")?.textContent).toBe("A");
|
||||
expect(host2.querySelector("p")?.textContent).toBe("B");
|
||||
});
|
||||
|
||||
describe("variable scoping (window.__hfVariablesByComp)", () => {
|
||||
type WindowWithScopedVars = Window & {
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as WindowWithScopedVars).__hfVariablesByComp;
|
||||
});
|
||||
|
||||
it("merges sub-comp declared defaults with host data-variable-values", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host.setAttribute("data-composition-id", "card-1");
|
||||
host.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html data-composition-variables='[
|
||||
{"id":"title","type":"string","label":"Title","default":"Default"},
|
||||
{"id":"price","type":"string","label":"Price","default":"$0"},
|
||||
{"id":"theme","type":"string","label":"Theme","default":"light"}
|
||||
]'>
|
||||
<body>
|
||||
<div data-composition-id="card-1"><p>card</p></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
|
||||
expect(byComp["card-1"]).toEqual({
|
||||
title: "Pro", // host wins over declared default
|
||||
price: "$29", // host wins
|
||||
theme: "light", // host omits → declared default falls through
|
||||
});
|
||||
});
|
||||
|
||||
it("uses declared defaults when host has no data-variable-values", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host.setAttribute("data-composition-id", "card-2");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html data-composition-variables='[
|
||||
{"id":"title","type":"string","label":"Title","default":"Default Title"}
|
||||
]'>
|
||||
<body><div data-composition-id="card-2"><p>x</p></div></body>
|
||||
</html>
|
||||
`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
|
||||
expect(byComp["card-2"]).toEqual({ title: "Default Title" });
|
||||
});
|
||||
|
||||
it("skips registration when neither declared defaults nor host overrides exist", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host.setAttribute("data-composition-id", "card-empty");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body><div data-composition-id="card-empty"><p>x</p></div></body></html>
|
||||
`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp;
|
||||
expect(byComp?.["card-empty"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores invalid JSON in host data-variable-values", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host.setAttribute("data-composition-id", "card-bad");
|
||||
host.setAttribute("data-variable-values", "{not json");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"OK"}]'>
|
||||
<body><div data-composition-id="card-bad"><p>x</p></div></body>
|
||||
</html>
|
||||
`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
|
||||
expect(byComp["card-bad"]).toEqual({ title: "OK" });
|
||||
});
|
||||
|
||||
it("registers per-instance entries for multiple sub-comps with the same source", async () => {
|
||||
const host1 = document.createElement("div");
|
||||
host1.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host1.setAttribute("data-composition-id", "card-A");
|
||||
host1.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
|
||||
document.body.appendChild(host1);
|
||||
|
||||
const host2 = document.createElement("div");
|
||||
host2.setAttribute("data-composition-src", "https://example.com/card.html");
|
||||
host2.setAttribute("data-composition-id", "card-B");
|
||||
host2.setAttribute("data-variable-values", '{"title":"Enterprise","price":"Custom"}');
|
||||
document.body.appendChild(host2);
|
||||
|
||||
const compositionHtml = `
|
||||
<html data-composition-variables='[
|
||||
{"id":"title","type":"string","label":"Title","default":"Default"},
|
||||
{"id":"price","type":"string","label":"Price","default":"$0"}
|
||||
]'>
|
||||
<body><div data-composition-id="card-A"><p>x</p></div></body>
|
||||
</html>
|
||||
`;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () => new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
|
||||
expect(byComp["card-A"]).toEqual({ title: "Pro", price: "$29" });
|
||||
expect(byComp["card-B"]).toEqual({ title: "Enterprise", price: "Custom" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadInlineTemplateCompositions", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
import { readDeclaredDefaults } from "./getVariables";
|
||||
|
||||
type LoadExternalCompositionsParams = {
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
@@ -73,6 +74,19 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
|
||||
}
|
||||
}
|
||||
|
||||
function parseHostVariableValues(host: Element): Record<string, unknown> {
|
||||
const raw = host.getAttribute("data-variable-values");
|
||||
if (!raw) return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function mountCompositionContent(params: {
|
||||
host: Element;
|
||||
hostCompositionId: string | null;
|
||||
@@ -88,6 +102,15 @@ async function mountCompositionContent(params: {
|
||||
headStyles?: HTMLStyleElement[];
|
||||
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headScripts?: HTMLScriptElement[];
|
||||
/**
|
||||
* Defaults extracted from the sub-composition's own
|
||||
* `<html data-composition-variables="...">` attribute. Layered under the
|
||||
* host element's `data-variable-values` to produce the per-instance
|
||||
* variables visible inside the sub-comp's scoped `getVariables()`.
|
||||
* Populated only by `loadExternalCompositions`; inline templates have no
|
||||
* separate document root so no declared defaults are passed.
|
||||
*/
|
||||
declaredVariableDefaults?: Record<string, unknown>;
|
||||
onDiagnostic?: (payload: {
|
||||
code: string;
|
||||
details: Record<string, string | number | boolean | null | string[]>;
|
||||
@@ -211,6 +234,21 @@ async function mountCompositionContent(params: {
|
||||
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
||||
}
|
||||
|
||||
// Stash the per-instance variables BEFORE running scripts. The scoped
|
||||
// `getVariables()` injected by `compositionScoping.ts` reads from
|
||||
// `window.__hfVariablesByComp[compId]`, so this table must be populated
|
||||
// before the wrapped IIFE evaluates.
|
||||
if (scopeCompositionId) {
|
||||
const merged = {
|
||||
...(params.declaredVariableDefaults ?? {}),
|
||||
...parseHostVariableValues(params.host),
|
||||
};
|
||||
if (Object.keys(merged).length > 0) {
|
||||
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
|
||||
window.__hfVariablesByComp[scopeCompositionId] = merged;
|
||||
}
|
||||
}
|
||||
|
||||
for (const scriptPayload of scriptPayloads) {
|
||||
const injectedScript = document.createElement("script");
|
||||
if (scriptPayload.type) {
|
||||
@@ -371,6 +409,7 @@ export async function loadExternalCompositions(
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
headStyles,
|
||||
headScripts,
|
||||
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
|
||||
onDiagnostic: params.onDiagnostic,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { getVariables } from "./getVariables";
|
||||
import { getVariables, readDeclaredDefaults } from "./getVariables";
|
||||
|
||||
const VARIABLES_ATTR = "data-composition-variables";
|
||||
|
||||
@@ -104,3 +104,30 @@ describe("getVariables", () => {
|
||||
expect(vars.missing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDeclaredDefaults", () => {
|
||||
it("returns {} for a null root", () => {
|
||||
expect(readDeclaredDefaults(null)).toEqual({});
|
||||
});
|
||||
|
||||
it("extracts {id: default} from an arbitrary element with the attribute", () => {
|
||||
const el = document.createElement("html");
|
||||
el.setAttribute(
|
||||
"data-composition-variables",
|
||||
JSON.stringify([
|
||||
{ id: "title", type: "string", label: "Title", default: "Hello" },
|
||||
{ id: "count", type: "number", label: "Count", default: 3 },
|
||||
]),
|
||||
);
|
||||
expect(readDeclaredDefaults(el)).toEqual({ title: "Hello", count: 3 });
|
||||
});
|
||||
|
||||
it("returns {} when the attribute is invalid JSON or non-array", () => {
|
||||
const a = document.createElement("html");
|
||||
a.setAttribute("data-composition-variables", "{not json");
|
||||
expect(readDeclaredDefaults(a)).toEqual({});
|
||||
const b = document.createElement("html");
|
||||
b.setAttribute("data-composition-variables", JSON.stringify({ title: "x" }));
|
||||
expect(readDeclaredDefaults(b)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* Reads the resolved variables for the current composition.
|
||||
*
|
||||
* Resolves to declared defaults from `<html data-composition-variables="...">`
|
||||
* Top-level path: declared defaults from `<html data-composition-variables="...">`
|
||||
* merged with `window.__hfVariables` (set at render time by the engine when
|
||||
* the user passes `hyperframes render --variables '<json>'`).
|
||||
*
|
||||
* Sub-comp path (per-instance scoping): when called inside a sub-composition
|
||||
* script wrapped by `compositionScoping.ts`, the wrapper shadows
|
||||
* `__hyperframes.getVariables` with a scoped variant that returns the
|
||||
* pre-merged values from `window.__hfVariablesByComp[compositionId]`. The
|
||||
* loader populates that table before running scripts, layering the host
|
||||
* element's `data-variable-values` over the sub-comp's declared defaults.
|
||||
*
|
||||
* Returns `Partial<T>` because not every declared variable is guaranteed to
|
||||
* have a default, and not every key in `__hfVariables` is guaranteed to be
|
||||
* declared. Callers are expected to destructure with their own fallbacks
|
||||
@@ -23,7 +30,13 @@ export function getVariables<
|
||||
return { ...declaredDefaults, ...overrides } as Partial<T>;
|
||||
}
|
||||
|
||||
function readDeclaredDefaults(root: Element | null): Record<string, unknown> {
|
||||
/**
|
||||
* Extract `{id: default}` map from an element's `data-composition-variables`
|
||||
* attribute. Returns an empty object when the attribute is missing, the JSON
|
||||
* is unparseable, or the payload isn't an array. Exported so the
|
||||
* compositionLoader can compute the same defaults map for sub-comp instances.
|
||||
*/
|
||||
export function readDeclaredDefaults(root: Element | null): Record<string, unknown> {
|
||||
if (!root) return {};
|
||||
const raw = root.getAttribute("data-composition-variables");
|
||||
if (!raw) return {};
|
||||
|
||||
+9
@@ -86,6 +86,15 @@ declare global {
|
||||
* declared defaults from `<html data-composition-variables="...">`.
|
||||
*/
|
||||
__hfVariables?: Record<string, unknown>;
|
||||
/**
|
||||
* Per-instance, pre-merged variables for sub-compositions. Keyed by the
|
||||
* sub-composition's `data-composition-id`. Populated by the runtime
|
||||
* composition loader at mount time: layers the host element's
|
||||
* `data-variable-values` over the sub-comp's declared defaults so the
|
||||
* scoped `getVariables()` exposed by `compositionScoping.ts` returns the
|
||||
* resolved values for the instance currently executing.
|
||||
*/
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user