Skip to content

Commit 0e3d59c

Browse files
jrusso1020claude
andcommitted
feat(sdk): variable declaration read APIs + browser-safe variables entry
First PR of the template-variables Studio stack — the SDK read surface. - @hyperframes/core/variables: new browser-safe subpath bundling the full composition-variables surface in one import: schema types, parseCompositionVariables, getVariables/readDeclaredDefaults, validateVariables. The core/parsers root barrels pull Node-only modules and break browser bundles (verified against the studio build). - parsers: move parseCompositionVariables out of htmlParser.ts into a browser-safe module; re-export from the root and ./composition entries. - sdk: new read-only Composition methods: - getVariableDeclarations() — strict canonical schema parse - getVariableValues(overrides?) — mirrors the runtime getVariables() merge exactly (loose defaults + overrides, undeclared keys pass through) - validateVariableValues(values) — same checks as --strict-variables - re-export CompositionVariable/CompositionVariableType/ VariableValidationIssue types from the SDK for consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 38b27c4 commit 0e3d59c

10 files changed

Lines changed: 327 additions & 43 deletions

File tree

packages/core/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
"import": "./src/editing/affordances.ts",
4747
"types": "./src/editing/affordances.ts"
4848
},
49+
"./variables": {
50+
"bun": "./src/variables.ts",
51+
"node": "./dist/variables.js",
52+
"import": "./src/variables.ts",
53+
"types": "./src/variables.ts"
54+
},
4955
"./slideshow": {
5056
"bun": "./src/slideshow/index.ts",
5157
"node": "./dist/slideshow/index.js",
@@ -250,6 +256,10 @@
250256
"import": "./dist/lint/index.js",
251257
"types": "./dist/lint/index.d.ts"
252258
},
259+
"./variables": {
260+
"import": "./dist/variables.js",
261+
"types": "./dist/variables.d.ts"
262+
},
253263
"./slideshow": {
254264
"import": "./dist/slideshow/index.js",
255265
"types": "./dist/slideshow/index.d.ts"

packages/core/src/variables.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Browser-safe variables entry (`@hyperframes/core/variables`) — the complete
3+
* composition-variables surface in one import: schema types, the declaration
4+
* parser, runtime value resolution, and value validation. Deliberately free of
5+
* the Node-only modules reachable from the core/parsers root entries, so
6+
* browser consumers (SDK, Studio, embedders) can bundle it.
7+
*/
8+
9+
export type {
10+
CompositionVariable,
11+
CompositionVariableType,
12+
CompositionVariableBase,
13+
StringVariable,
14+
NumberVariable,
15+
ColorVariable,
16+
BooleanVariable,
17+
EnumVariable,
18+
FontVariable,
19+
ImageVariable,
20+
} from "@hyperframes/parsers/composition";
21+
export {
22+
COMPOSITION_VARIABLE_TYPES,
23+
parseCompositionVariables,
24+
isScalarVariableValue,
25+
} from "@hyperframes/parsers/composition";
26+
27+
export { getVariables, readDeclaredDefaults } from "./runtime/getVariables.js";
28+
export {
29+
validateVariables,
30+
formatVariableValidationIssue,
31+
type VariableValidationIssue,
32+
} from "./runtime/validateVariables.js";

packages/parsers/src/composition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export {
1010
resolveAliasDisplayName,
1111
} from "./fontAliases.js";
1212
export { decodeUrlPathVariants } from "./utils/urlPath.js";
13+
export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Browser-safe parser for the `data-composition-variables` schema attribute.
3+
* Lives outside htmlParser.ts so browser consumers (SDK, Studio, lint) can
4+
* import it via `@hyperframes/parsers/composition` without pulling the
5+
* linkedom/Node HTML-parser machinery from the main entry.
6+
*/
7+
8+
import type { CompositionVariable, CompositionVariableType } from "./types.js";
9+
10+
/**
11+
* Required typeof for each variable type's `default`. For font the default is
12+
* the font-family name string; for image it is the fallback URL string —
13+
* extra metadata fields on both are optional and not validated here.
14+
*/
15+
const DEFAULT_TYPEOF: Record<CompositionVariableType, "string" | "number" | "boolean"> = {
16+
string: "string",
17+
number: "number",
18+
color: "string",
19+
boolean: "boolean",
20+
enum: "string",
21+
font: "string",
22+
image: "string",
23+
};
24+
25+
/**
26+
* Scalar variable values (string/number/boolean) are the ones that flow into
27+
* CSS custom props and text bindings; font/image values are object-shaped.
28+
* Shared so the SDK's CSS-compat writes, the runtime bindings, and Studio's
29+
* display logic can never disagree on what "scalar" means.
30+
*/
31+
export function isScalarVariableValue(value: unknown): value is string | number | boolean {
32+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
33+
}
34+
35+
function isRecord(v: unknown): v is Record<string, unknown> {
36+
return typeof v === "object" && v !== null;
37+
}
38+
39+
function isVariableType(t: unknown): t is CompositionVariableType {
40+
return typeof t === "string" && t in DEFAULT_TYPEOF;
41+
}
42+
43+
function isCompositionVariable(v: unknown): v is CompositionVariable {
44+
if (!isRecord(v)) return false;
45+
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
46+
if (!isVariableType(v.type)) return false;
47+
if (typeof v.default !== DEFAULT_TYPEOF[v.type]) return false;
48+
if (v.type === "enum" && !Array.isArray(v.options)) return false;
49+
return true;
50+
}
51+
52+
/**
53+
* Parse the typed variable declarations from an element's
54+
* `data-composition-variables` attribute. Malformed entries (wrong shape,
55+
* unknown type, default not matching the declared type) are dropped; an
56+
* absent attribute, invalid JSON, or a non-array payload yields `[]`.
57+
*/
58+
export function parseCompositionVariables(htmlEl: Element): CompositionVariable[] {
59+
const variablesAttr = htmlEl.getAttribute("data-composition-variables");
60+
if (!variablesAttr) {
61+
return [];
62+
}
63+
64+
try {
65+
const parsed = JSON.parse(variablesAttr);
66+
if (!Array.isArray(parsed)) {
67+
return [];
68+
}
69+
return parsed.filter(isCompositionVariable);
70+
} catch {
71+
return [];
72+
}
73+
}

packages/parsers/src/htmlParser.ts

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ValidationResult,
1313
} from "./types.js";
1414
import { validateCompositionGsap } from "./gsapSerialize";
15+
import { parseCompositionVariables } from "./compositionVariables.js";
1516
import { ensureHfIds } from "./hfIds.js";
1617
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
1718
import { queryByAttr } from "./utils/cssSelector.js";
@@ -791,49 +792,7 @@ export function extractCompositionMetadata(html: string): CompositionMetadata {
791792
};
792793
}
793794

794-
function parseCompositionVariables(htmlEl: Element): CompositionVariable[] {
795-
const variablesAttr = htmlEl.getAttribute("data-composition-variables");
796-
if (!variablesAttr) {
797-
return [];
798-
}
799-
800-
try {
801-
const parsed = JSON.parse(variablesAttr);
802-
if (!Array.isArray(parsed)) {
803-
return [];
804-
}
805-
806-
return parsed.filter((v): v is CompositionVariable => {
807-
if (typeof v !== "object" || v === null) return false;
808-
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
809-
if (!["string", "number", "color", "boolean", "enum", "font", "image"].includes(v.type))
810-
return false;
811-
812-
switch (v.type) {
813-
case "string":
814-
return typeof v.default === "string";
815-
case "number":
816-
return typeof v.default === "number";
817-
case "color":
818-
return typeof v.default === "string";
819-
case "boolean":
820-
return typeof v.default === "boolean";
821-
case "enum":
822-
return typeof v.default === "string" && Array.isArray(v.options);
823-
case "font":
824-
// default is the font-family name string; extra metadata fields are optional
825-
return typeof v.default === "string";
826-
case "image":
827-
// default is the fallback image URL string; extra metadata fields are optional
828-
return typeof v.default === "string";
829-
default:
830-
return false;
831-
}
832-
});
833-
} catch {
834-
return [];
835-
}
836-
}
795+
export { parseCompositionVariables };
837796

838797
export function validateCompositionHtml(html: string): ValidationResult {
839798
const errors: string[] = [];

packages/sdk/src/engine/variableModel.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
* (engine/apply-patches.ts) can never disagree on the model's shape.
88
*/
99

10+
// Browser-safe subpath — the core/parsers root entries pull Node-only modules
11+
// and would break browser bundles that include the SDK (e.g. Studio).
12+
import { parseCompositionVariables } from "@hyperframes/core/variables";
13+
import type { CompositionVariable } from "@hyperframes/core/variables";
14+
1015
type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
1116

1217
function getHtmlEl(document: Document): Element | null {
@@ -33,6 +38,18 @@ function indexOfId(arr: VariableDecl[], id: string): number {
3338
return arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id);
3439
}
3540

41+
/**
42+
* Read the typed variable declarations from `data-composition-variables`.
43+
* Delegates to the canonical parser (same filter the render pipeline uses),
44+
* so malformed entries are dropped rather than surfaced. Returns `[]` when
45+
* the document has no root element or no declarations.
46+
*/
47+
export function readVariableDeclarations(document: Document): CompositionVariable[] {
48+
const htmlEl = getHtmlEl(document);
49+
if (!htmlEl) return [];
50+
return parseCompositionVariables(htmlEl);
51+
}
52+
3653
/**
3754
* Read the current `default` value for a variable id. Returns undefined when
3855
* the attribute is absent, the JSON is invalid, or no entry matches the id.

packages/sdk/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export type {
2222

2323
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
2424

25+
// Variable schema types — re-exported so SDK consumers (Studio, embedders)
26+
// can type declarations without a direct @hyperframes/core dependency.
27+
export type {
28+
CompositionVariable,
29+
CompositionVariableType,
30+
VariableValidationIssue,
31+
} from "@hyperframes/core/variables";
32+
2533
export { UnsupportedOpError } from "./engine/mutate.js";
2634

2735
export { buildDocument, buildRoots, flatElements } from "./document.js";

packages/sdk/src/session.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import type { ParsedDocument } from "./engine/model.js";
3636
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
3737
import { getGsapScript, resolveScoped } from "./engine/model.js";
3838
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
39+
import { readDeclaredDefaults, validateVariables } from "@hyperframes/core/variables";
40+
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
41+
import { readVariableDeclarations } from "./engine/variableModel.js";
3942
import { serializeDocument } from "./engine/serialize.js";
4043
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
4144
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
@@ -149,6 +152,25 @@ class CompositionImpl implements Composition {
149152
this.dispatch({ type: "setVariableValue", id, value });
150153
}
151154

155+
getVariableDeclarations(): CompositionVariable[] {
156+
return readVariableDeclarations(this.parsed.document);
157+
}
158+
159+
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
160+
// Mirror the runtime's getVariables() exactly: loose defaults extraction
161+
// (any entry with a string id and a `default` key — even ones the strict
162+
// declaration parser drops) spread under the overrides. See
163+
// core/runtime/getVariables.ts.
164+
const documentEl =
165+
(this.parsed.document as Document & { documentElement?: Element }).documentElement ?? null;
166+
const defaults = readDeclaredDefaults(documentEl);
167+
return { ...defaults, ...(overrides ?? {}) };
168+
}
169+
170+
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[] {
171+
return validateVariables(values, this.getVariableDeclarations());
172+
}
173+
152174
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
153175

154176
/**

0 commit comments

Comments
 (0)