mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(core,cli): variable schema validation + lint rules
Two lint rules + render-time validation built on top of the existing
data-composition-variables schema.
Lint rules (packages/core/src/lint/rules/composition.ts):
- invalid_variable_values_json — host's data-variable-values must parse as
a JSON object. Today the runtime swallows parse failures silently and
falls back to declared defaults, masking typos.
- invalid_composition_variables_declaration — root <html>'s
data-composition-variables must parse as an array of objects with
`id` (string), `type` (one of string/number/color/boolean/enum), `label`
(string), and `default`. Per-entry findings report which fields are
missing or invalid.
Both rules read attributes via a new `readJsonAttr` helper in lint/utils.ts.
The existing `readAttr` regex `["']([^"']+)["']` truncates JSON-in-attribute
values at the first internal quote (e.g. `data-variable-values='{"x":"y"}'`
captures only `{`); `readJsonAttr` alternates double-vs-single-quoted
branches with quote-specific char classes so JSON values round-trip cleanly.
A second helper `findHtmlTag` returns the actual <html> open tag (where
data-composition-variables lives) — distinct from `findRootTag` which
returns the first in-body composition element.
Render-time validation (packages/core/src/runtime/validateVariables.ts):
- validateVariables(values, declarations) returns a structured array of
issues: undeclared keys, type mismatches, enum-out-of-range values.
Pure / sync; works in any environment.
- formatVariableValidationIssue(issue) renders a one-line user-facing
string for CLI output.
- Both exported from @hyperframes/core for studio/tooling reuse.
CLI integration (packages/cli/src/commands/render.ts):
- New --strict-variables flag. Default behavior: print warnings and
continue. With --strict-variables: print warnings then exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper:
reads the project's index.html, runs extractCompositionMetadata to
pull the declared schema, validates the CLI's --variables payload
against it. ensureDOMParser polyfill for Node-side parsing (same
pattern as compositions.ts).
Tests:
- 11 new validateVariables unit tests covering happy path, undeclared
keys, type mismatches (string/number/boolean/color/enum), enum range,
multiple-issue aggregation, and formatter output.
- 11 new composition.test.ts cases for both lint rules: parse errors,
shape errors, per-entry validation, unknown types, missing fields,
positive cases.
- 5 new render.test.ts cases for validateVariablesAgainstProject:
no-declarations, happy path, undeclared, type-mismatch, missing-file.
- All 646 core tests + 213 cli tests still green.
Docs:
- docs/packages/cli.mdx — added --strict-variables flag row.
This is PR 3 of a 4-PR stack. PR 4 ships skill/scaffold distribution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
da92a17754
commit
c1b6efd9c5
@@ -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.
|
||||
|
||||
|
||||
@@ -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(`<html><body><div data-composition-id="x"></div></body></html>`);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when every value matches its declaration", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags undeclared keys", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([
|
||||
{ kind: "undeclared", variableId: "extra" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags type mismatches", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"count","type":"number","label":"Count","default":0}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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,
|
||||
|
||||
@@ -169,6 +169,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,
|
||||
|
||||
@@ -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 = `<html><body>
|
||||
<div data-composition-id="card-1" data-composition-src="card.html" data-variable-values='{not json'></div>
|
||||
</body></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 = `<html><body>
|
||||
<div data-composition-src="card.html" data-variable-values='[1,2,3]'></div>
|
||||
</body></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 = `<html><body>
|
||||
<div data-composition-src="card.html" data-variable-values='"hello"'></div>
|
||||
</body></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 = `<html><body>
|
||||
<div data-composition-src="card.html" data-variable-values='{"title":"Hello","count":3}'></div>
|
||||
</body></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 = `<html><body>
|
||||
<div data-composition-src="card.html"></div>
|
||||
</body></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 = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></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 = `<html data-composition-variables='{"title":"Hello"}'><body><div data-composition-id="x"></div></body></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 = `<html data-composition-variables='[{"id":"ok","type":"string","label":"Ok","default":"x"},{"id":"bad"}]'><body><div data-composition-id="x"></div></body></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 = `<html data-composition-variables='[{"id":"x","type":"date","label":"X","default":"y"}]'><body><div data-composition-id="x"></div></body></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 = `<html data-composition-variables='[
|
||||
{"id":"title","type":"string","label":"Title","default":"Hello"},
|
||||
{"id":"count","type":"number","label":"Count","default":3},
|
||||
{"id":"theme","type":"enum","label":"Theme","default":"light","options":[{"value":"light","label":"Light"}]}
|
||||
]'><body><div data-composition-id="x"></div></body></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 = `<html><body><div data-composition-id="x"></div></body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "invalid_composition_variables_declaration",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, truncateSnippet } from "../utils";
|
||||
import { findHtmlTag, readAttr, readJsonAttr, truncateSnippet } from "../utils";
|
||||
|
||||
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
|
||||
// to inspect and revise reliably in a single composition.
|
||||
@@ -388,4 +388,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(["string", "number", "color", "boolean", "enum"]);
|
||||
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<string, unknown>;
|
||||
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;
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,6 +58,23 @@ export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[]
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the `<html>` open tag in the source. Distinct from `findRootTag`,
|
||||
* which returns the first element inside `<body>` — the latter is "the
|
||||
* composition's visible root", whereas `<html>` is where document-level
|
||||
* metadata like `data-composition-variables` lives.
|
||||
*/
|
||||
export function findHtmlTag(source: string): OpenTag | null {
|
||||
const match = /<html\b([^<>]*)>/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 = /<body\b[^>]*>/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<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
|
||||
@@ -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").');
|
||||
});
|
||||
});
|
||||
@@ -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<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 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}").`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user