From 24c19827790010771ad660280acf8483d7122785 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 7 Jul 2026 21:22:16 -0700 Subject: [PATCH] feat(sdk): variable declaration read apis + browser-safe variables entry --- packages/core/package.json | 10 ++ packages/core/src/variables.ts | 32 +++++ packages/parsers/src/composition.ts | 1 + packages/parsers/src/compositionVariables.ts | 73 ++++++++++ packages/parsers/src/htmlParser.ts | 45 +----- packages/sdk/src/engine/variableModel.ts | 18 +++ packages/sdk/src/index.ts | 10 +- packages/sdk/src/session.ts | 29 +++- packages/sdk/src/session.variables.test.ts | 140 +++++++++++++++++++ packages/sdk/src/types.ts | 29 +++- 10 files changed, 340 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/variables.ts create mode 100644 packages/parsers/src/compositionVariables.ts create mode 100644 packages/sdk/src/session.variables.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 546488c093..86cd54745d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,12 @@ "import": "./src/editing/affordances.ts", "types": "./src/editing/affordances.ts" }, + "./variables": { + "bun": "./src/variables.ts", + "node": "./dist/variables.js", + "import": "./src/variables.ts", + "types": "./src/variables.ts" + }, "./slideshow": { "bun": "./src/slideshow/index.ts", "node": "./dist/slideshow/index.js", @@ -262,6 +268,10 @@ "import": "./dist/lint/index.js", "types": "./dist/lint/index.d.ts" }, + "./variables": { + "import": "./dist/variables.js", + "types": "./dist/variables.d.ts" + }, "./slideshow": { "import": "./dist/slideshow/index.js", "types": "./dist/slideshow/index.d.ts" diff --git a/packages/core/src/variables.ts b/packages/core/src/variables.ts new file mode 100644 index 0000000000..1eb1ebd71b --- /dev/null +++ b/packages/core/src/variables.ts @@ -0,0 +1,32 @@ +/** + * Browser-safe variables entry (`@hyperframes/core/variables`) — the complete + * composition-variables surface in one import: schema types, the declaration + * parser, runtime value resolution, and value validation. Deliberately free of + * the Node-only modules reachable from the core/parsers root entries, so + * browser consumers (SDK, Studio, embedders) can bundle it. + */ + +export type { + CompositionVariable, + CompositionVariableType, + CompositionVariableBase, + StringVariable, + NumberVariable, + ColorVariable, + BooleanVariable, + EnumVariable, + FontVariable, + ImageVariable, +} from "@hyperframes/parsers/composition"; +export { + COMPOSITION_VARIABLE_TYPES, + parseCompositionVariables, + isScalarVariableValue, +} from "@hyperframes/parsers/composition"; + +export { getVariables, readDeclaredDefaults } from "./runtime/getVariables.js"; +export { + validateVariables, + formatVariableValidationIssue, + type VariableValidationIssue, +} from "./runtime/validateVariables.js"; diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts index a6592d6968..933f696837 100644 --- a/packages/parsers/src/composition.ts +++ b/packages/parsers/src/composition.ts @@ -10,3 +10,4 @@ export { resolveAliasDisplayName, } from "./fontAliases.js"; export { decodeUrlPathVariants } from "./utils/urlPath.js"; +export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js"; diff --git a/packages/parsers/src/compositionVariables.ts b/packages/parsers/src/compositionVariables.ts new file mode 100644 index 0000000000..fa09d3ef8f --- /dev/null +++ b/packages/parsers/src/compositionVariables.ts @@ -0,0 +1,73 @@ +/** + * Browser-safe parser for the `data-composition-variables` schema attribute. + * Lives outside htmlParser.ts so browser consumers (SDK, Studio, lint) can + * import it via `@hyperframes/parsers/composition` without pulling the + * linkedom/Node HTML-parser machinery from the main entry. + */ + +import type { CompositionVariable, CompositionVariableType } from "./types.js"; + +/** + * Required typeof for each variable type's `default`. For font the default is + * the font-family name string; for image it is the fallback URL string — + * extra metadata fields on both are optional and not validated here. + */ +const DEFAULT_TYPEOF: Record = { + string: "string", + number: "number", + color: "string", + boolean: "boolean", + enum: "string", + font: "string", + image: "string", +}; + +/** + * Scalar variable values (string/number/boolean) are the ones that flow into + * CSS custom props and text bindings; font/image values are object-shaped. + * Shared so the SDK's CSS-compat writes, the runtime bindings, and Studio's + * display logic can never disagree on what "scalar" means. + */ +export function isScalarVariableValue(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function isVariableType(t: unknown): t is CompositionVariableType { + return typeof t === "string" && t in DEFAULT_TYPEOF; +} + +function isCompositionVariable(v: unknown): v is CompositionVariable { + if (!isRecord(v)) return false; + if (typeof v.id !== "string" || typeof v.label !== "string") return false; + if (!isVariableType(v.type)) return false; + if (typeof v.default !== DEFAULT_TYPEOF[v.type]) return false; + if (v.type === "enum" && !Array.isArray(v.options)) return false; + return true; +} + +/** + * Parse the typed variable declarations from an element's + * `data-composition-variables` attribute. Malformed entries (wrong shape, + * unknown type, default not matching the declared type) are dropped; an + * absent attribute, invalid JSON, or a non-array payload yields `[]`. + */ +export function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { + const variablesAttr = htmlEl.getAttribute("data-composition-variables"); + if (!variablesAttr) { + return []; + } + + try { + const parsed = JSON.parse(variablesAttr); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter(isCompositionVariable); + } catch { + return []; + } +} diff --git a/packages/parsers/src/htmlParser.ts b/packages/parsers/src/htmlParser.ts index 5717f703e4..113683d01e 100644 --- a/packages/parsers/src/htmlParser.ts +++ b/packages/parsers/src/htmlParser.ts @@ -12,6 +12,7 @@ import type { ValidationResult, } from "./types.js"; import { validateCompositionGsap } from "./gsapSerialize"; +import { parseCompositionVariables } from "./compositionVariables.js"; import { ensureHfIds } from "./hfIds.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; import { queryByAttr } from "./utils/cssSelector.js"; @@ -791,49 +792,7 @@ export function extractCompositionMetadata(html: string): CompositionMetadata { }; } -function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { - const variablesAttr = htmlEl.getAttribute("data-composition-variables"); - if (!variablesAttr) { - return []; - } - - try { - const parsed = JSON.parse(variablesAttr); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((v): v is CompositionVariable => { - if (typeof v !== "object" || v === null) return false; - if (typeof v.id !== "string" || typeof v.label !== "string") return false; - if (!["string", "number", "color", "boolean", "enum", "font", "image"].includes(v.type)) - return false; - - switch (v.type) { - case "string": - return typeof v.default === "string"; - case "number": - return typeof v.default === "number"; - case "color": - return typeof v.default === "string"; - case "boolean": - return typeof v.default === "boolean"; - case "enum": - return typeof v.default === "string" && Array.isArray(v.options); - case "font": - // default is the font-family name string; extra metadata fields are optional - return typeof v.default === "string"; - case "image": - // default is the fallback image URL string; extra metadata fields are optional - return typeof v.default === "string"; - default: - return false; - } - }); - } catch { - return []; - } -} +export { parseCompositionVariables }; export function validateCompositionHtml(html: string): ValidationResult { const errors: string[] = []; diff --git a/packages/sdk/src/engine/variableModel.ts b/packages/sdk/src/engine/variableModel.ts index c1caece489..346666ae41 100644 --- a/packages/sdk/src/engine/variableModel.ts +++ b/packages/sdk/src/engine/variableModel.ts @@ -7,6 +7,12 @@ * (engine/apply-patches.ts) can never disagree on the model's shape. */ +// Browser-safe subpath — the core/parsers root entries pull Node-only modules +// and would break browser bundles that include the SDK (e.g. Studio). +import { parseCompositionVariables } from "@hyperframes/core/variables"; +import type { CompositionVariable } from "@hyperframes/core/variables"; + +// Exported so the SDK index can re-export it (kept from #2098's surface). export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown }; function getHtmlEl(document: Document): Element | null { @@ -33,6 +39,18 @@ function indexOfId(arr: VariableDecl[], id: string): number { return arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id); } +/** + * Read the typed variable declarations from `data-composition-variables`. + * Delegates to the canonical parser (same filter the render pipeline uses), + * so malformed entries are dropped rather than surfaced. Returns `[]` when + * the document has no root element or no declarations. + */ +export function readVariableDeclarations(document: Document): CompositionVariable[] { + const htmlEl = getHtmlEl(document); + if (!htmlEl) return []; + return parseCompositionVariables(htmlEl); +} + /** * Read the current `default` value for a variable id. Returns undefined when * the attribute is absent, the JSON is invalid, or no entry matches the id. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 139030d7c3..e6712c2888 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,10 +20,16 @@ export type { CanResult, } from "./types.js"; -export type { CompositionVariable } from "@hyperframes/core"; - export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; +// Variable schema types — re-exported so SDK consumers (Studio, embedders) +// can type declarations without a direct @hyperframes/core dependency. +export type { + CompositionVariable, + CompositionVariableType, + VariableValidationIssue, +} from "@hyperframes/core/variables"; + export { UnsupportedOpError } from "./engine/mutate.js"; export { buildDocument, buildRoots, flatElements } from "./document.js"; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 5c9b432f17..9a46a3035e 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -36,10 +36,12 @@ import type { ParsedDocument } from "./engine/model.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; import { getGsapScript, resolveScoped } from "./engine/model.js"; import { readVariableDefault, listVariableDecls } from "./engine/variableModel.js"; -import type { CompositionVariable } from "@hyperframes/core"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; +import { readDeclaredDefaults, validateVariables } from "@hyperframes/core/variables"; +import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; +import { readVariableDeclarations } from "./engine/variableModel.js"; import { serializeDocument } from "./engine/serialize.js"; import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js"; import { buildPatchEvent, pathToKey } from "./engine/patches.js"; @@ -155,6 +157,7 @@ class CompositionImpl implements Composition { this.dispatch({ type: "setVariableValue", id, value }); } + // ── #2098 CRUD surface (kept; superseded by the richer API below in #2047+) ── getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined { // readVariableDefault genuinely can't narrow beyond unknown — the schema // isn't validated at read time — so the cast lives here at the SDK @@ -183,6 +186,30 @@ class CompositionImpl implements Composition { this.dispatch({ type: "removeVariable", id }); } + // ── Canonical read surface (this stack) ── + getVariableDeclarations(): CompositionVariable[] { + return readVariableDeclarations(this.parsed.document); + } + + getVariableValues(overrides?: Record): Record { + // THIS composition's own declared defaults (loose extraction: any entry with + // a string id + a `default` key, even ones the strict declaration parser + // drops) spread under the overrides. Scope note: this reads the composition's + // single declaration element only — NOT a union of every `[data-composition- + // variables]` in the document. The runtime's getVariables() + // (core/runtime/getVariables.ts) additionally walks inlined sub-composition + // declarers because it operates on the bundled multi-composition document; + // the SDK models one composition file, so per-file scope is intended. + const documentEl = + (this.parsed.document as Document & { documentElement?: Element }).documentElement ?? null; + const defaults = readDeclaredDefaults(documentEl); + return { ...defaults, ...(overrides ?? {}) }; + } + + validateVariableValues(values: Record): VariableValidationIssue[] { + return validateVariables(values, this.getVariableDeclarations()); + } + // ── WS-C: timing accessors + typed setHold ─────────────────────────────────── /** diff --git a/packages/sdk/src/session.variables.test.ts b/packages/sdk/src/session.variables.test.ts new file mode 100644 index 0000000000..05880900cf --- /dev/null +++ b/packages/sdk/src/session.variables.test.ts @@ -0,0 +1,140 @@ +/** + * Variable read APIs: getVariableDeclarations / getVariableValues / + * validateVariableValues. Semantics contract: declarations use the strict + * canonical parser; values mirror the runtime's loose defaults + overrides + * merge; validation matches --strict-variables. + */ + +import { describe, it, expect } from "vitest"; +import { openComposition } from "./session.js"; + +const DECLS = [ + { id: "title", type: "string", label: "Title", default: "Hello" }, + { id: "accent", type: "color", label: "Accent", default: "#00C3FF" }, + { id: "count", type: "number", label: "Count", default: 3, min: 0, max: 10 }, + { id: "dark", type: "boolean", label: "Dark mode", default: false }, + { + id: "layout", + type: "enum", + label: "Layout", + default: "wide", + options: [ + { value: "wide", label: "Wide" }, + { value: "tall", label: "Tall" }, + ], + }, +]; + +function htmlWithVariables(attr: string): string { + return ` + + +
+

Hello

+
+ +`; +} + +const BASE_HTML = htmlWithVariables(JSON.stringify(DECLS)); + +describe("getVariableDeclarations", () => { + it("returns the declared schema with per-type metadata intact", async () => { + const comp = await openComposition(BASE_HTML); + const decls = comp.getVariableDeclarations(); + expect(decls.map((d) => d.id)).toEqual(["title", "accent", "count", "dark", "layout"]); + const count = decls.find((d) => d.id === "count"); + expect(count).toMatchObject({ type: "number", min: 0, max: 10, default: 3 }); + const layout = decls.find((d) => d.id === "layout"); + expect(layout).toMatchObject({ type: "enum", default: "wide" }); + }); + + it("returns [] when the attribute is absent", async () => { + const comp = await openComposition( + `

x

`, + ); + expect(comp.getVariableDeclarations()).toEqual([]); + }); + + it("returns [] for invalid JSON and drops malformed entries", async () => { + const invalid = await openComposition(htmlWithVariables("{not json")); + expect(invalid.getVariableDeclarations()).toEqual([]); + + const mixed = JSON.stringify([ + { id: "ok", type: "string", label: "Ok", default: "yes" }, + { id: "bad-type", type: "gradient", label: "Nope", default: "x" }, + { id: "bad-default", type: "number", label: "Nope", default: "not-a-number" }, + "not-an-object", + ]); + const mixedComp = await openComposition(htmlWithVariables(mixed)); + const decls = mixedComp.getVariableDeclarations(); + expect(decls.map((d) => d.id)).toEqual(["ok"]); + }); +}); + +describe("getVariableValues", () => { + it("returns declared defaults when no overrides given", async () => { + const comp = await openComposition(BASE_HTML); + expect(comp.getVariableValues()).toEqual({ + title: "Hello", + accent: "#00C3FF", + count: 3, + dark: false, + layout: "wide", + }); + }); + + it("overrides win and undeclared override keys pass through (runtime parity)", async () => { + const comp = await openComposition(BASE_HTML); + const values = comp.getVariableValues({ title: "Custom", extra: 42 }); + expect(values.title).toBe("Custom"); + expect(values.accent).toBe("#00C3FF"); + expect(values.extra).toBe(42); + }); + + it("uses the loose runtime defaults filter, not the strict declaration parser", async () => { + // A number variable with a string default is dropped by the strict parser + // but its default still flows through the runtime's readDeclaredDefaults — + // getVariableValues must match what a composition script actually reads. + const attr = JSON.stringify([ + { id: "loose", type: "number", label: "Loose", default: "not-a-number" }, + ]); + const comp = await openComposition(htmlWithVariables(attr)); + expect(comp.getVariableDeclarations()).toEqual([]); + expect(comp.getVariableValues()).toEqual({ loose: "not-a-number" }); + }); + + it("returns {} for a composition with no declarations", async () => { + const comp = await openComposition( + `

x

`, + ); + expect(comp.getVariableValues()).toEqual({}); + expect(comp.getVariableValues({ a: 1 })).toEqual({ a: 1 }); + }); +}); + +describe("validateVariableValues", () => { + it("returns [] for values conforming to the schema", async () => { + const comp = await openComposition(BASE_HTML); + expect(comp.validateVariableValues({ title: "x", count: 5, dark: true })).toEqual([]); + }); + + it("flags undeclared keys, type mismatches, and enum violations", async () => { + const comp = await openComposition(BASE_HTML); + const issues = comp.validateVariableValues({ + ghost: "boo", + count: "five", + layout: "diagonal", + }); + const kinds = issues.map((i) => `${i.kind}:${i.variableId}`).sort(); + expect(kinds).toEqual(["enum-out-of-range:layout", "type-mismatch:count", "undeclared:ghost"]); + }); + + it("stays consistent after setVariableValue edits the default", async () => { + const comp = await openComposition(BASE_HTML); + comp.setVariableValue("title", "Edited"); + expect(comp.getVariableValues().title).toBe("Edited"); + expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Edited"); + expect(comp.validateVariableValues({ title: "still-a-string" })).toEqual([]); + }); +}); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 79b2dd6e5c..dc2baaaa64 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,4 +1,4 @@ -import type { CompositionVariable } from "@hyperframes/core"; +import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; // ─── Document model ─────────────────────────────────────────────────────────── @@ -422,6 +422,33 @@ export interface Composition { declareVariable(decl: CompositionVariable): void; /** Remove a variable's declaration. Live `var.{id}` overrides are untouched. */ removeVariable(id: string): void; + /** + * Read the typed variable declarations from `data-composition-variables` + * (the canonical schema — same filter the render pipeline uses; malformed + * entries are dropped). Read-only — does not dispatch. + */ + getVariableDeclarations(): CompositionVariable[]; + /** + * Resolve this composition's variable values: its declared defaults merged + * with `overrides` (overrides win, undeclared override keys pass through). + * Read-only — does not dispatch. + * + * Scope: reads THIS composition file's own declaration element, not a union of + * every `[data-composition-variables]` in a bundled document. The runtime's + * `getVariables()` additionally walks inlined sub-composition declarers because + * it runs on the fully-bundled document; the SDK models one composition file, + * so per-file scope is intentional (and is what Studio needs to predict a + * single file's `--variables` payload). For the common single-`` + * composition the two agree; they diverge only when sub-comp declarers are + * inlined into one document. + */ + getVariableValues(overrides?: Record): Record; + /** + * Validate a values map against the declared schema. Returns undeclared / + * type-mismatch / enum-out-of-range issues (same checks as the CLI's + * `--strict-variables`). Read-only — does not dispatch. + */ + validateVariableValues(values: Record): VariableValidationIssue[]; /** * Read enter/exit times and GSAP labels for every timed element (WS-C). * Derives enterAt/exitAt using the same data-duration vs data-end preference