diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 8e48e82af..7ed8e5197 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -608,6 +608,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quiet` | — | off | Suppress verbose output |
| `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |
+ | `--strict-variables` | — | off | Fail render if any `--variables` key is undeclared or has a wrong type vs the composition's `data-composition-variables`. Without this flag, mismatches print as warnings and the render continues. |
CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically.
diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts
index 08ce30c4f..4b7ec4d87 100644
--- a/packages/cli/src/commands/render.test.ts
+++ b/packages/cli/src/commands/render.test.ts
@@ -205,3 +205,70 @@ describe("parseVariablesArg", () => {
expect(err.cause).toMatch(/ENOENT/);
});
});
+
+describe("validateVariablesAgainstProject", () => {
+ let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject;
+ let tmpDir: string;
+ let mkdtempSync: typeof import("node:fs").mkdtempSync;
+ let writeFileSync: typeof import("node:fs").writeFileSync;
+ let rmSync: typeof import("node:fs").rmSync;
+ let join: typeof import("node:path").join;
+ let tmpdir: typeof import("node:os").tmpdir;
+
+ beforeAll(async () => {
+ ({ validateVariablesAgainstProject } = await import("./render.js"));
+ ({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs"));
+ ({ join } = await import("node:path"));
+ ({ tmpdir } = await import("node:os"));
+ });
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-"));
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ function writeIndex(html: string): string {
+ const path = join(tmpDir, "index.html");
+ writeFileSync(path, html);
+ return path;
+ }
+
+ it("returns [] when the project has no data-composition-variables declarations", () => {
+ const indexPath = writeIndex(`
`);
+ expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
+ });
+
+ it("returns [] when every value matches its declaration", () => {
+ const indexPath = writeIndex(
+ ``,
+ );
+ expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
+ });
+
+ it("flags undeclared keys", () => {
+ const indexPath = writeIndex(
+ ``,
+ );
+ expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([
+ { kind: "undeclared", variableId: "extra" },
+ ]);
+ });
+
+ it("flags type mismatches", () => {
+ const indexPath = writeIndex(
+ ``,
+ );
+ expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([
+ { kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" },
+ ]);
+ });
+
+ it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => {
+ expect(
+ validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts
index 46ac96260..fb78ee1de 100644
--- a/packages/cli/src/commands/render.ts
+++ b/packages/cli/src/commands/render.ts
@@ -35,7 +35,14 @@ import { bytesToMb } from "../telemetry/system.js";
import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
+import { ensureDOMParser } from "../utils/dom.js";
import type { RenderJob } from "@hyperframes/producer";
+import {
+ extractCompositionMetadata,
+ validateVariables,
+ formatVariableValidationIssue,
+ type VariableValidationIssue,
+} from "@hyperframes/core";
const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
@@ -142,6 +149,12 @@ export default defineCommand({
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
+ "strict-variables": {
+ type: "boolean",
+ description:
+ "Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
+ default: false,
+ },
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
@@ -349,6 +362,33 @@ export default defineCommand({
// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
+ // ── Validate --variables against data-composition-variables ─────────
+ const strictVariables = args["strict-variables"] ?? false;
+ if (variables && Object.keys(variables).length > 0) {
+ const issues = validateVariablesAgainstProject(project.indexPath, variables);
+ if (issues.length > 0) {
+ if (!quiet) {
+ console.log("");
+ console.log(
+ c.warn(
+ `Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`,
+ ),
+ );
+ for (const issue of issues) {
+ console.log(" " + c.dim(formatVariableValidationIssue(issue)));
+ }
+ console.log("");
+ }
+ if (strictVariables) {
+ console.log(
+ c.error(" Aborting render due to variable issues (--strict-variables mode)."),
+ );
+ console.log("");
+ process.exit(1);
+ }
+ }
+ }
+
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
@@ -509,6 +549,33 @@ export function resolveVariablesArg(
return result.value;
}
+/**
+ * Validate `--variables` values against the project's top-level
+ * `data-composition-variables` declarations. Returns an empty array when
+ * the index has no declarations or when every key is declared with a
+ * matching type. Errors reading the index are silently treated as "no
+ * declarations" — the lint pass owns malformed-HTML diagnostics, render
+ * shouldn't fail just because the schema is unreadable.
+ */
+export function validateVariablesAgainstProject(
+ indexPath: string,
+ values: Record,
+): VariableValidationIssue[] {
+ let html: string;
+ try {
+ html = readFileSync(indexPath, "utf8");
+ } catch {
+ return [];
+ }
+ // extractCompositionMetadata uses DOMParser, which Node doesn't ship.
+ // Same pattern as `compositions.ts` and other CLI commands that touch
+ // @hyperframes/core's HTML parsers.
+ ensureDOMParser();
+ const meta = extractCompositionMetadata(html);
+ if (meta.variables.length === 0) return [];
+ return validateVariables(values, meta.variables);
+}
+
export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts
index 36f3c2dc7..929fef6de 100644
--- a/packages/core/src/core.types.ts
+++ b/packages/core/src/core.types.ts
@@ -88,6 +88,20 @@ export interface TimelineCompositionElement extends TimelineElementBase {
// Composition Variable Types
export type CompositionVariableType = "string" | "number" | "color" | "boolean" | "enum";
+/**
+ * Runtime list of every valid `CompositionVariableType`. Use this anywhere
+ * a Set/array of valid type strings is needed (lint rules, validators).
+ * The `satisfies` guard turns adding a new variant to the union without
+ * also adding it here into a compile error.
+ */
+export const COMPOSITION_VARIABLE_TYPES = [
+ "string",
+ "number",
+ "color",
+ "boolean",
+ "enum",
+] as const satisfies readonly CompositionVariableType[];
+
export interface CompositionVariableBase {
id: string;
type: CompositionVariableType;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9480b6614..8a0906552 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -38,6 +38,7 @@ export {
CANVAS_DIMENSIONS,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
+ COMPOSITION_VARIABLE_TYPES,
isTextElement,
isMediaElement,
isCompositionElement,
@@ -169,6 +170,13 @@ export type { FitTextOptions, FitTextResult } from "./text/index.js";
// Runtime helpers (composition-side)
export { getVariables } from "./runtime/getVariables.js";
+// Variable validation (CLI / tooling-side)
+export {
+ validateVariables,
+ formatVariableValidationIssue,
+ type VariableValidationIssue,
+} from "./runtime/validateVariables.js";
+
// Registry
export type {
ItemType,
diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts
index ad98d3fde..f94de67fd 100644
--- a/packages/core/src/lint/rules/composition.test.ts
+++ b/packages/core/src/lint/rules/composition.test.ts
@@ -644,4 +644,117 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});
+
+ describe("invalid_variable_values_json", () => {
+ it("warns when data-variable-values is unparseable JSON", () => {
+ const html = `
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("warns when data-variable-values is a JSON array (must be an object)", () => {
+ const html = `
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
+ expect(finding).toBeDefined();
+ expect(finding?.message).toMatch(/must be a JSON object/);
+ });
+
+ it("warns when data-variable-values is a JSON string (must be an object)", () => {
+ const html = `
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
+ expect(finding).toBeDefined();
+ });
+
+ it("does not warn for a valid JSON object", () => {
+ const html = `
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn when data-variable-values is absent", () => {
+ const html = `
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
+ expect(finding).toBeUndefined();
+ });
+ });
+
+ describe("invalid_composition_variables_declaration", () => {
+ it("warns when data-composition-variables is unparseable JSON", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(finding).toBeDefined();
+ });
+
+ it("warns when data-composition-variables is not an array", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.message).toMatch(/array of variable declarations/);
+ });
+
+ it("warns per-entry when an entry is missing required fields", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const findings = result.findings.filter(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(findings.length).toBe(1);
+ expect(findings[0]?.message).toMatch(/\[1\]/);
+ expect(findings[0]?.message).toMatch(/type|label|default/);
+ });
+
+ it("warns when a declaration uses an unknown type", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.message).toMatch(/type/);
+ });
+
+ it("does not warn for a fully valid declarations array", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn when data-composition-variables is absent", () => {
+ const html = ``;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "invalid_composition_variables_declaration",
+ );
+ expect(finding).toBeUndefined();
+ });
+ });
});
diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index 354dbd366..0f02b1a4a 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -1,5 +1,6 @@
import type { LintContext, HyperframeLintFinding } from "../context";
-import { readAttr, truncateSnippet } from "../utils";
+import { findHtmlTag, readAttr, readJsonAttr, truncateSnippet } from "../utils";
+import { COMPOSITION_VARIABLE_TYPES } from "../../core.types";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
@@ -388,4 +389,120 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
}
return findings;
},
+
+ // invalid_variable_values_json
+ // Host elements (`[data-composition-src]`) carry per-instance values via
+ // `data-variable-values`. The runtime swallows JSON errors silently and
+ // falls back to declared defaults, which masks typos. This rule surfaces
+ // the parse failure so authors notice before render time.
+ ({ tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ for (const tag of tags) {
+ const raw = readJsonAttr(tag.raw, "data-variable-values");
+ if (!raw) continue;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : "unknown";
+ findings.push({
+ code: "invalid_variable_values_json",
+ severity: "warning",
+ message: `data-variable-values is not valid JSON (${reason}).`,
+ fixHint:
+ 'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
+ elementId: readAttr(tag.raw, "id") || undefined,
+ snippet: truncateSnippet(tag.raw),
+ });
+ continue;
+ }
+
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ findings.push({
+ code: "invalid_variable_values_json",
+ severity: "warning",
+ message:
+ 'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
+ fixHint:
+ "Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
+ elementId: readAttr(tag.raw, "id") || undefined,
+ snippet: truncateSnippet(tag.raw),
+ });
+ }
+ }
+ return findings;
+ },
+
+ // invalid_composition_variables_declaration
+ // The runtime parses `data-composition-variables` and silently returns []
+ // on any structural problem. Surface JSON / shape failures so authors
+ // catch them at lint time rather than wondering why their `getVariables()`
+ // defaults aren't applied.
+ ({ source }) => {
+ const htmlTag = findHtmlTag(source);
+ if (!htmlTag) return [];
+ const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
+ if (!raw) return [];
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : "unknown";
+ return [
+ {
+ code: "invalid_composition_variables_declaration",
+ severity: "warning",
+ message: `data-composition-variables is not valid JSON (${reason}).`,
+ fixHint:
+ 'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
+ snippet: truncateSnippet(htmlTag.raw),
+ },
+ ];
+ }
+
+ if (!Array.isArray(parsed)) {
+ return [
+ {
+ code: "invalid_composition_variables_declaration",
+ severity: "warning",
+ message: "data-composition-variables must be a JSON array of variable declarations.",
+ fixHint:
+ 'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
+ snippet: truncateSnippet(htmlTag.raw),
+ },
+ ];
+ }
+
+ const findings: HyperframeLintFinding[] = [];
+ const knownTypes = new Set(COMPOSITION_VARIABLE_TYPES);
+ for (let i = 0; i < parsed.length; i += 1) {
+ const entry = parsed[i];
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
+ findings.push({
+ code: "invalid_composition_variables_declaration",
+ severity: "warning",
+ message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
+ snippet: truncateSnippet(htmlTag.raw),
+ });
+ continue;
+ }
+ const e = entry as Record;
+ const missing: string[] = [];
+ if (typeof e.id !== "string") missing.push("id");
+ if (typeof e.type !== "string" || !knownTypes.has(e.type as string)) missing.push("type");
+ if (typeof e.label !== "string") missing.push("label");
+ if (!("default" in e)) missing.push("default");
+ if (missing.length > 0) {
+ findings.push({
+ code: "invalid_composition_variables_declaration",
+ severity: "warning",
+ message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum.`,
+ snippet: truncateSnippet(htmlTag.raw),
+ });
+ }
+ }
+ return findings;
+ },
];
diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts
index cf8a7b121..9090b36a4 100644
--- a/packages/core/src/lint/utils.ts
+++ b/packages/core/src/lint/utils.ts
@@ -58,6 +58,23 @@ export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[]
return blocks;
}
+/**
+ * Find the `` open tag in the source. Distinct from `findRootTag`,
+ * which returns the first element inside `` — the latter is "the
+ * composition's visible root", whereas `` is where document-level
+ * metadata like `data-composition-variables` lives.
+ */
+export function findHtmlTag(source: string): OpenTag | null {
+ const match = /]*)>/i.exec(source);
+ if (!match) return null;
+ return {
+ raw: match[0],
+ name: "html",
+ attrs: match[1] ?? "",
+ index: match.index,
+ };
+}
+
export function findRootTag(source: string): OpenTag | null {
const bodyOpenMatch = /]*>/i.exec(source);
const bodyCloseMatch = /<\/body>/i.exec(source);
@@ -82,6 +99,26 @@ export function readAttr(tagSource: string, attr: string): string | null {
return match?.[1] || null;
}
+/**
+ * Read an attribute that may legitimately contain the opposite quote
+ * character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
+ * at the first internal `"` because its `[^"']+` class excludes both quote
+ * types. This variant alternates: a double-quoted value never contains an
+ * unescaped `"`, and a single-quoted value never contains an unescaped `'`,
+ * so each branch can use a quote-specific class.
+ *
+ * Use for attributes whose values are JSON or otherwise carry the opposite
+ * quote character. Existing single-token attributes (`id`, `class`, etc.)
+ * stick with `readAttr` for consistency with the rest of the lint code.
+ */
+export function readJsonAttr(tagSource: string, attr: string): string | null {
+ if (!tagSource) return null;
+ const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
+ if (!match) return null;
+ return match[1] ?? match[2] ?? null;
+}
+
export function collectCompositionIds(tags: OpenTag[]): Set {
const ids = new Set();
for (const tag of tags) {
diff --git a/packages/core/src/runtime/validateVariables.test.ts b/packages/core/src/runtime/validateVariables.test.ts
new file mode 100644
index 000000000..3d2fb60ad
--- /dev/null
+++ b/packages/core/src/runtime/validateVariables.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect } from "vitest";
+import { validateVariables, formatVariableValidationIssue } from "./validateVariables";
+import type { CompositionVariable } from "../core.types";
+
+const DECLS: readonly CompositionVariable[] = [
+ { id: "title", type: "string", label: "Title", default: "Hello" },
+ { id: "count", type: "number", label: "Count", default: 0 },
+ { id: "active", type: "boolean", label: "Active", default: true },
+ { id: "color", type: "color", label: "Color", default: "#000000" },
+ {
+ id: "theme",
+ type: "enum",
+ label: "Theme",
+ default: "light",
+ options: [
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+ ],
+ },
+];
+
+describe("validateVariables", () => {
+ it("returns no issues when every value matches its declaration", () => {
+ expect(
+ validateVariables(
+ { title: "Q4", count: 3, active: false, color: "#abcdef", theme: "dark" },
+ DECLS,
+ ),
+ ).toEqual([]);
+ });
+
+ it("returns no issues for an empty values map", () => {
+ expect(validateVariables({}, DECLS)).toEqual([]);
+ });
+
+ it("flags undeclared keys", () => {
+ expect(validateVariables({ title: "x", extra: 1 }, DECLS)).toEqual([
+ { kind: "undeclared", variableId: "extra" },
+ ]);
+ });
+
+ it("flags string vs number mismatches", () => {
+ expect(validateVariables({ count: "three" }, DECLS)).toEqual([
+ { kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" },
+ ]);
+ });
+
+ it("flags non-finite numbers as type mismatches", () => {
+ expect(validateVariables({ count: Number.NaN }, DECLS)).toEqual([
+ { kind: "type-mismatch", variableId: "count", expected: "number", actual: "number" },
+ ]);
+ });
+
+ it("flags boolean mismatches", () => {
+ expect(validateVariables({ active: "true" }, DECLS)).toEqual([
+ { kind: "type-mismatch", variableId: "active", expected: "boolean", actual: "string" },
+ ]);
+ });
+
+ it("flags non-string color values", () => {
+ expect(validateVariables({ color: 0xff0000 }, DECLS)).toEqual([
+ { kind: "type-mismatch", variableId: "color", expected: "color", actual: "number" },
+ ]);
+ });
+
+ it("flags enum values not in the allowed set", () => {
+ expect(validateVariables({ theme: "midnight" }, DECLS)).toEqual([
+ {
+ kind: "enum-out-of-range",
+ variableId: "theme",
+ allowed: ["light", "dark"],
+ actual: "midnight",
+ },
+ ]);
+ });
+
+ it("flags non-string enum values as type mismatches", () => {
+ expect(validateVariables({ theme: 1 }, DECLS)).toEqual([
+ { kind: "type-mismatch", variableId: "theme", expected: "enum (string)", actual: "number" },
+ ]);
+ });
+
+ it("returns multiple issues at once", () => {
+ const issues = validateVariables({ title: 42, theme: "neon", extra: true }, DECLS);
+ expect(issues).toContainEqual({
+ kind: "type-mismatch",
+ variableId: "title",
+ expected: "string",
+ actual: "number",
+ });
+ expect(issues).toContainEqual({
+ kind: "enum-out-of-range",
+ variableId: "theme",
+ allowed: ["light", "dark"],
+ actual: "neon",
+ });
+ expect(issues).toContainEqual({ kind: "undeclared", variableId: "extra" });
+ });
+});
+
+describe("formatVariableValidationIssue", () => {
+ it("formats undeclared issues", () => {
+ expect(formatVariableValidationIssue({ kind: "undeclared", variableId: "extra" })).toBe(
+ 'Variable "extra" is not declared in data-composition-variables.',
+ );
+ });
+
+ it("formats type-mismatch issues", () => {
+ expect(
+ formatVariableValidationIssue({
+ kind: "type-mismatch",
+ variableId: "count",
+ expected: "number",
+ actual: "string",
+ }),
+ ).toBe('Variable "count" expected number, got string.');
+ });
+
+ it("formats enum-out-of-range issues", () => {
+ expect(
+ formatVariableValidationIssue({
+ kind: "enum-out-of-range",
+ variableId: "theme",
+ allowed: ["light", "dark"],
+ actual: "neon",
+ }),
+ ).toBe('Variable "theme" must be one of "light", "dark" (got "neon").');
+ });
+});
diff --git a/packages/core/src/runtime/validateVariables.ts b/packages/core/src/runtime/validateVariables.ts
new file mode 100644
index 000000000..5ef2e9f09
--- /dev/null
+++ b/packages/core/src/runtime/validateVariables.ts
@@ -0,0 +1,101 @@
+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,
+ declarations: readonly CompositionVariable[],
+): VariableValidationIssue[] {
+ const decls = new Map();
+ 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 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;
+ }
+ }
+}
+
+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}").`;
+ }
+}