Files
hyperframes/packages/core/src/runtime/validateVariables.ts
T
Vance IngallsandClaude Opus 4.8 dd6fad6bb2 fix(sdk): code-review follow-ups (WS-B/C/3.C, #1569/#1570/#1572) (#1588)
Addresses the still-outstanding review concerns from merged PRs #1569 / #1570 /
#1572 not already hoisted into #1573.

WS-B (#1569):
- validateVariables requires discriminant fields for object-valued font/image
  ({name,source} / {url}); a {name:42} font or {foo:42} image previously passed
  runtime validation and surfaced as a bogus font-family / missing image.
- Dropped ImageValue's [key:string]:unknown index signature (let any {url}-shaped
  object through, swallowed typos); explicit alt?/fit? instead.
- Documented the OverrideSet widening for SDK consumers.

WS-C (#1570):
- getElementTimings caches parsed GSAP labels by exact script text (avoids a full
  acorn re-parse per read; content-key invalidates on edit).
- Documented end-inclusive label window + best-effort extractGsapLabels catch.

WS-3.C (#1572):
- Added typed Composition.addWithKeyframes / replaceWithKeyframes (was asymmetric
  with addGsapTween; Studio had to use raw dispatch).
- Extracted shared KeyframeSpec type; documented position as seconds/number-only.

Gates: build + core 18/18 + sdk 19/19 + oxlint + oxfmt + fallow + typecheck all green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:07:53 -07:00

153 lines
5.0 KiB
TypeScript

import type { CompositionVariable } from "../core.types";
export type VariableValidationIssue =
| { kind: "undeclared"; variableId: string }
| { kind: "type-mismatch"; variableId: string; expected: string; actual: string }
| { kind: "enum-out-of-range"; variableId: string; allowed: string[]; actual: string };
/**
* Compare a flat values map (from `--variables` / `data-variable-values`) to
* the declared schema (`data-composition-variables`). Returns issues for keys
* that aren't declared, plus per-key type mismatches against the declared
* type. Pure / sync — caller decides how to surface them (warning vs render
* failure under `--strict-variables`).
*/
export function validateVariables(
values: Record<string, unknown>,
declarations: readonly CompositionVariable[],
): VariableValidationIssue[] {
const decls = new Map<string, CompositionVariable>();
for (const decl of declarations) decls.set(decl.id, decl);
const issues: VariableValidationIssue[] = [];
for (const [id, value] of Object.entries(values)) {
const decl = decls.get(id);
if (!decl) {
issues.push({ kind: "undeclared", variableId: id });
continue;
}
const mismatch = checkType(value, decl);
if (mismatch) issues.push(mismatch);
}
return issues;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// fallow-ignore-next-line complexity
function checkType(value: unknown, decl: CompositionVariable): VariableValidationIssue | null {
switch (decl.type) {
case "string":
case "color":
if (typeof value !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: decl.type,
actual: jsTypeOf(value),
};
}
return null;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "number",
actual: jsTypeOf(value),
};
}
return null;
case "boolean":
if (typeof value !== "boolean") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "boolean",
actual: jsTypeOf(value),
};
}
return null;
case "enum": {
if (typeof value !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "enum (string)",
actual: jsTypeOf(value),
};
}
const allowed = decl.options.map((o) => o.value);
if (!allowed.includes(value)) {
return { kind: "enum-out-of-range", variableId: decl.id, allowed, actual: value };
}
return null;
}
case "font": {
// Font value is an object {name: string, source: string} OR a fallback string.
if (typeof value === "string") return null;
if (!isPlainObject(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "font (object {name, source} or string)",
actual: jsTypeOf(value),
};
}
// Object form: require the discriminant fields so a malformed brand-kit
// value ({name: 42} / {}) is caught here rather than surfacing as a bogus
// font-family at render time.
if (typeof value.name !== "string" || typeof value.source !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "font object {name: string, source: string}",
actual: "object missing string name/source",
};
}
return null;
}
case "image": {
// Image value is an object {url: string} OR a fallback string.
if (typeof value === "string") return null;
if (!isPlainObject(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "image (object {url} or string)",
actual: jsTypeOf(value),
};
}
// Object form: require the discriminant field.
if (typeof value.url !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "image object {url: string}",
actual: "object missing string url",
};
}
return null;
}
}
}
function jsTypeOf(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
export function formatVariableValidationIssue(issue: VariableValidationIssue): string {
switch (issue.kind) {
case "undeclared":
return `Variable "${issue.variableId}" is not declared in data-composition-variables.`;
case "type-mismatch":
return `Variable "${issue.variableId}" expected ${issue.expected}, got ${issue.actual}.`;
case "enum-out-of-range":
return `Variable "${issue.variableId}" must be one of ${issue.allowed.map((v) => `"${v}"`).join(", ")} (got "${issue.actual}").`;
}
}