Files
hyperframes/packages/core/src/runtime/getVariables.ts
T
JamesandClaude Opus 4.7 484ab54442 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>
2026-05-04 19:41:26 +00:00

68 lines
2.6 KiB
TypeScript

/**
* Reads the resolved variables for the current composition.
*
* 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
* where strictness matters:
*
* const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
*/
export function getVariables<
T extends Record<string, unknown> = Record<string, unknown>,
>(): Partial<T> {
if (typeof document === "undefined") return {} as Partial<T>;
const declaredDefaults = readDeclaredDefaults(document.documentElement);
const overrides = readOverrides();
return { ...declaredDefaults, ...overrides } as Partial<T>;
}
/**
* 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 {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!Array.isArray(parsed)) return {};
const out: Record<string, unknown> = {};
for (const entry of parsed) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !("default" in e)) continue;
out[e.id] = e.default;
}
return out;
}
function readOverrides(): Record<string, unknown> {
if (typeof window === "undefined") return {};
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return raw as Record<string, unknown>;
}