Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/variables.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export {
resolveAliasDisplayName,
} from "./fontAliases.js";
export { decodeUrlPathVariants } from "./utils/urlPath.js";
export { parseCompositionVariables } from "./compositionVariables.js";
73 changes: 73 additions & 0 deletions packages/parsers/src/compositionVariables.ts
Original file line number Diff line number Diff line change
@@ -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<CompositionVariableType, "string" | "number" | "boolean"> = {
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<string, unknown> {
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 [];
}
}
45 changes: 2 additions & 43 deletions packages/parsers/src/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [];
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk/src/engine/variableModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,6 +152,25 @@ class CompositionImpl implements Composition {
this.dispatch({ type: "setVariableValue", id, value });
}

getVariableDeclarations(): CompositionVariable[] {
return readVariableDeclarations(this.parsed.document);
}

getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
// 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<string, unknown>): VariableValidationIssue[] {
return validateVariables(values, this.getVariableDeclarations());
}

// ── WS-C: timing accessors + typed setHold ───────────────────────────────────

/**
Expand Down
Loading
Loading