mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(sdk): variable declaration read apis + browser-safe variables entry (#2046)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
@@ -10,3 +10,4 @@ export {
|
||||
resolveAliasDisplayName,
|
||||
} from "./fontAliases.js";
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath.js";
|
||||
export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js";
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
// 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<string, unknown>): VariableValidationIssue[] {
|
||||
return validateVariables(values, this.getVariableDeclarations());
|
||||
}
|
||||
|
||||
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html data-composition-variables='${attr}'>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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(
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
|
||||
);
|
||||
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(
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
|
||||
);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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-`<html>`
|
||||
* composition the two agree; they diverge only when sub-comp declarers are
|
||||
* inlined into one document.
|
||||
*/
|
||||
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown>;
|
||||
/**
|
||||
* 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<string, unknown>): 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
|
||||
|
||||
Reference in New Issue
Block a user