mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): scope getVariables() per sub-comp instance
Building on PR 1's getVariables() helper, this PR routes per-instance values into the correct sub-composition. Same composition source can now be embedded N times with different content via data-variable-values on each host element. How it works: - compositionLoader, before injecting wrapped scripts, layers the host element's data-variable-values JSON over the sub-comp's declared defaults (its own data-composition-variables) and writes the merged object to window.__hfVariablesByComp[compositionId]. Skipped when both sides are empty so the table only grows for instances that actually carry values. - compositionScoping's wrapper IIFE now takes a fourth parameter __hyperframes alongside the existing scoped document/gsap/window. The scoped __hyperframes shadows getVariables() to read from __hfVariablesByComp[__hfCompId], returning a fresh object each call so script mutations don't leak into the shared table. - Top-level scripts (not wrapped by compositionScoping) keep using the unscoped window.__hyperframes.getVariables(), which reads data-composition-variables defaults plus the CLI override (window.__hfVariables) — same path as PR 1. - readDeclaredDefaults is exported from getVariables.ts so the loader reuses the exact same defaults-extraction logic the helper uses for the top-level path. Inline templates (no separate <html> document root) get host overrides only — no declared defaults — since there's no separate <html> to read data-composition-variables from. External sub-comps fetched via data-composition-src get the full declared defaults + host overrides merge. Tests: 3 new compositionScoping tests covering scoped getVariables invocation, missing-entry fallback, and mutation isolation. 5 new compositionLoader tests covering merge order, declared-only path, empty-skip, invalid-host-JSON resilience, and per-instance scoping across two hosts sharing a source. 3 new getVariables tests covering the newly-public readDeclaredDefaults. All 622 core tests green. Docs: docs/concepts/compositions.mdx switched its sub-comp example from hand-rolled JSON.parse(host.dataset.variableValues) to the new __hyperframes.getVariables() pattern. data-attributes.mdx clarifies per-instance scoping behavior. This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint; PR 4 ships skill / scaffold updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
03b82e6ff8
commit
484ab54442
@@ -129,52 +129,62 @@ Every composition has two layers:
|
|||||||
|
|
||||||
HyperFrames does not automatically bind `data-var-*` attributes into your composition DOM or CSS.
|
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`
|
1. Declare the variables once on the sub-comp's `<html>` root with `data-composition-variables` (id + type + default).
|
||||||
2. Read those values inside the composition and apply them in your own script
|
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
|
```html index.html
|
||||||
<div
|
<div
|
||||||
data-composition-id="card"
|
data-composition-id="card-pro"
|
||||||
data-composition-src="compositions/card.html"
|
data-composition-src="compositions/card.html"
|
||||||
data-start="0"
|
data-start="0"
|
||||||
data-track-index="1"
|
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>
|
></div>
|
||||||
```
|
```
|
||||||
|
|
||||||
```html compositions/card.html
|
```html compositions/card.html
|
||||||
<template id="card-template">
|
<html data-composition-variables='[
|
||||||
<div data-composition-id="card" data-width="1920" data-height="1080">
|
{"id":"title","type":"string","label":"Title","default":"Fallback"},
|
||||||
<h1 class="title">Fallback</h1>
|
{"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>
|
<style>
|
||||||
[data-composition-id="card"] {
|
[data-composition-id="card"] {
|
||||||
--card-color: #111827;
|
--card-color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-composition-id="card"] .title {
|
[data-composition-id="card"] .title {
|
||||||
color: var(--card-color);
|
color: var(--card-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const root = document.querySelector('[data-composition-id="card"]');
|
// Inside a sub-comp script, getVariables() returns the per-instance
|
||||||
const vars = JSON.parse(root?.getAttribute("data-variable-values") ?? "{}");
|
// values: declared defaults < host data-variable-values overrides.
|
||||||
const titleEl = root?.querySelector(".title");
|
const { title, color } = __hyperframes.getVariables();
|
||||||
|
const root = document.querySelector('[data-composition-id="card"]');
|
||||||
if (titleEl) {
|
root.querySelector(".title").textContent = title;
|
||||||
titleEl.textContent = vars.title ?? "Fallback";
|
root.style.setProperty("--card-color", color);
|
||||||
}
|
</script>
|
||||||
|
</div>
|
||||||
root?.style.setProperty("--card-color", String(vars.color ?? "#111827"));
|
</body>
|
||||||
</script>
|
</html>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## 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-width` | `"1920"` | Composition width in pixels |
|
||||||
| `data-height` | `"1080"` | Composition height in pixels |
|
| `data-height` | `"1080"` | Composition height in pixels |
|
||||||
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
|
| `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-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 render time. |
|
| `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
|
## Element Visibility
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,76 @@ body { margin: 0; }
|
|||||||
expect(scoped).not.toContain('[data-start="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,
|
||||||
|
},
|
||||||
|
__captured: undefined as unknown,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
__captured: undefined as unknown,
|
||||||
|
};
|
||||||
|
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", () => {
|
it("executes document and GSAP selectors inside the composition root", () => {
|
||||||
const { document } = parseHTML(`
|
const { document } = parseHTML(`
|
||||||
<div data-composition-id="scene" data-start="intro"><h1 class="title">Scene</h1></div>
|
<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;
|
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() {
|
var __hfRun = function() {
|
||||||
try {
|
try {
|
||||||
(function(document, gsap, window) {
|
(function(document, gsap, window, __hyperframes) {
|
||||||
${source}
|
${source}
|
||||||
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
|
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error(__hfErrorLabel, __hfCompId, _err);
|
console.error(__hfErrorLabel, __hfCompId, _err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,144 @@ describe("loadExternalCompositions", () => {
|
|||||||
expect(host1.querySelector("p")?.textContent).toBe("A");
|
expect(host1.querySelector("p")?.textContent).toBe("A");
|
||||||
expect(host2.querySelector("p")?.textContent).toBe("B");
|
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", () => {
|
describe("loadInlineTemplateCompositions", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||||
|
import { readDeclaredDefaults } from "./getVariables";
|
||||||
|
|
||||||
type LoadExternalCompositionsParams = {
|
type LoadExternalCompositionsParams = {
|
||||||
injectedStyles: HTMLStyleElement[];
|
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: {
|
async function mountCompositionContent(params: {
|
||||||
host: Element;
|
host: Element;
|
||||||
hostCompositionId: string | null;
|
hostCompositionId: string | null;
|
||||||
@@ -88,6 +102,15 @@ async function mountCompositionContent(params: {
|
|||||||
headStyles?: HTMLStyleElement[];
|
headStyles?: HTMLStyleElement[];
|
||||||
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
||||||
headScripts?: HTMLScriptElement[];
|
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: {
|
onDiagnostic?: (payload: {
|
||||||
code: string;
|
code: string;
|
||||||
details: Record<string, string | number | boolean | null | string[]>;
|
details: Record<string, string | number | boolean | null | string[]>;
|
||||||
@@ -211,6 +234,24 @@ async function mountCompositionContent(params: {
|
|||||||
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
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) {
|
||||||
|
const w = window as Window & {
|
||||||
|
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
if (!w.__hfVariablesByComp) w.__hfVariablesByComp = {};
|
||||||
|
w.__hfVariablesByComp[scopeCompositionId] = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const scriptPayload of scriptPayloads) {
|
for (const scriptPayload of scriptPayloads) {
|
||||||
const injectedScript = document.createElement("script");
|
const injectedScript = document.createElement("script");
|
||||||
if (scriptPayload.type) {
|
if (scriptPayload.type) {
|
||||||
@@ -371,6 +412,7 @@ export async function loadExternalCompositions(
|
|||||||
parseDimensionPx: params.parseDimensionPx,
|
parseDimensionPx: params.parseDimensionPx,
|
||||||
headStyles,
|
headStyles,
|
||||||
headScripts,
|
headScripts,
|
||||||
|
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
|
||||||
onDiagnostic: params.onDiagnostic,
|
onDiagnostic: params.onDiagnostic,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { getVariables } from "./getVariables";
|
import { getVariables, readDeclaredDefaults } from "./getVariables";
|
||||||
|
|
||||||
const VARIABLES_ATTR = "data-composition-variables";
|
const VARIABLES_ATTR = "data-composition-variables";
|
||||||
|
|
||||||
@@ -104,3 +104,30 @@ describe("getVariables", () => {
|
|||||||
expect(vars.missing).toBeUndefined();
|
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.
|
* 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
|
* merged with `window.__hfVariables` (set at render time by the engine when
|
||||||
* the user passes `hyperframes render --variables '<json>'`).
|
* 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
|
* Returns `Partial<T>` because not every declared variable is guaranteed to
|
||||||
* have a default, and not every key in `__hfVariables` is guaranteed to be
|
* have a default, and not every key in `__hfVariables` is guaranteed to be
|
||||||
* declared. Callers are expected to destructure with their own fallbacks
|
* declared. Callers are expected to destructure with their own fallbacks
|
||||||
@@ -23,7 +30,13 @@ export function getVariables<
|
|||||||
return { ...declaredDefaults, ...overrides } as Partial<T>;
|
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 {};
|
if (!root) return {};
|
||||||
const raw = root.getAttribute("data-composition-variables");
|
const raw = root.getAttribute("data-composition-variables");
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
|
|||||||
+9
@@ -86,6 +86,15 @@ declare global {
|
|||||||
* declared defaults from `<html data-composition-variables="...">`.
|
* declared defaults from `<html data-composition-variables="...">`.
|
||||||
*/
|
*/
|
||||||
__hfVariables?: Record<string, unknown>;
|
__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