diff --git a/packages/core/package.json b/packages/core/package.json index 93e333dee..53d7272b0 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", @@ -250,6 +256,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 000000000..1eb1ebd71 --- /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 a6592d696..933f69683 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 000000000..fa09d3ef8 --- /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 5717f703e..113683d01 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 636cc619b..1276f0c83 100644 --- a/packages/sdk/src/engine/variableModel.ts +++ b/packages/sdk/src/engine/variableModel.ts @@ -7,6 +7,11 @@ * (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"; + type VariableDecl = { id: string; default?: unknown; [key: string]: unknown }; function getHtmlEl(document: Document): Element | null { @@ -33,6 +38,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 731dc4514..925bf3a84 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -22,6 +22,14 @@ export type { 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 cbeb781ec..0c644c770 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -36,6 +36,9 @@ import type { ParsedDocument } from "./engine/model.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; import { getGsapScript, resolveScoped } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; +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"; @@ -149,6 +152,25 @@ class CompositionImpl implements Composition { this.dispatch({ type: "setVariableValue", id, value }); } + getVariableDeclarations(): CompositionVariable[] { + return readVariableDeclarations(this.parsed.document); + } + + getVariableValues(overrides?: Record): Record { + // Mirror the runtime's getVariables() exactly: loose defaults extraction + // (any entry with a string id and a `default` key — even ones the strict + // declaration parser drops) spread under the overrides. See + // core/runtime/getVariables.ts. + 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 000000000..05880900c --- /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 59476fae3..490715dd1 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,3 +1,5 @@ +import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; + // ─── Document model ─────────────────────────────────────────────────────────── /** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */ @@ -405,6 +407,26 @@ export interface Composition { */ addElement(parent: HfId | null, index: number, html: string): HfId; setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): 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 variable values the way the runtime's `getVariables()` does: + * declared defaults merged with `overrides` (overrides win, undeclared + * override keys pass through). Mirrors render-time semantics so a caller + * can predict exactly what a composition script will read for a given + * `--variables` payload. Read-only — does not dispatch. + */ + 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