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}").`; + } +}