diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 18193617d..d072def67 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -918,6 +918,22 @@ hyperframes lambda render ./my-project \ `--json` swaps the human-readable output for a machine-parseable JSON snapshot. +The composition can be parameterised with `--variables` / `--variables-file`, mirroring the local `hyperframes render` flags. Variables flow into the Step Functions execution input and reach every chunk worker as `window.__hfVariables`. Mismatches against the composition's `data-composition-variables` declaration print as warnings; pass `--strict-variables` to fail the command instead. + +```bash +hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \ + --width=1920 --height=1080 \ + --variables '{"title":"Hello Alice","accent":"#ff0000"}' +``` + +```bash +hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \ + --width=1920 --height=1080 \ + --variables-file ./alice.json --strict-variables +``` + +Variables travel inside the Step Functions Standard execution input, which AWS caps at 256 KiB for the entire payload. Pass typed data (strings, numbers, structured records) through variables; URL-reference media assets (images, audio, video) the composition resolves at render time rather than inlining bytes. The SDK validates the size client-side and rejects oversize inputs with a clear error before any AWS call runs — see the [templates-on-lambda guide](/deploy/templates-on-lambda) for the URL-your-assets convention. + #### `lambda progress ` Prints one progress snapshot — overall percent, frames rendered, Lambda invocations, accrued cost, and any errors. Accepts either a bare `renderId` (resolved against the stack's state-machine ARN) or a full SFN execution ARN. diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index be1a7d192..e7aa8b8c3 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -23,6 +23,14 @@ export const examples: Example[] = [ "Render and stream progress until done", "hyperframes lambda render ./my-project --width 1920 --height 1080 --wait", ], + [ + "Render with composition variables (personalised template)", + 'hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables \'{"title":"Hello Alice","accent":"#ff0000"}\'', + ], + [ + "Render with variables from a JSON file", + "hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables-file ./alice.json", + ], ["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"], [ "Pre-upload a project so multiple renders share the upload", @@ -114,6 +122,25 @@ export default defineCommand({ type: "string", description: "Final output S3 key (default: renders//output.)", }, + // Variables — mirrors the local `hyperframes render` UX. Inline JSON or + // file path, plus --strict-variables for type-checked validation against + // the composition's `data-composition-variables` declaration. + variables: { + type: "string", + description: + 'JSON object of variable values for the composition. Example: --variables \'{"title":"Hello"}\'. Values flow into window.__hfVariables on the Lambda chunk workers.', + }, + "variables-file": { + type: "string", + 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 the render command 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, + }, wait: { type: "boolean", description: "Block until the render finishes" }, "wait-interval-ms": { type: "string", @@ -242,6 +269,9 @@ export default defineCommand({ maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), executionName: args["execution-name"] as string | undefined, outputKey: args["output-key"] as string | undefined, + variables: args.variables as string | undefined, + variablesFile: args["variables-file"] as string | undefined, + strictVariables: Boolean(args["strict-variables"]), json: Boolean(args.json), wait: Boolean(args.wait), waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000, diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index 9531a5e9a..4b6a88721 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -4,12 +4,18 @@ * poll — use `hyperframes lambda progress` for that. */ -import { resolve as resolvePath } from "node:path"; +import { existsSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; import type { DistributedFormat, SerializableDistributedRenderConfig, } from "@hyperframes/aws-lambda/sdk"; import { c } from "../../ui/colors.js"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../../utils/variables.js"; import { requireStack, stateFilePath } from "./state.js"; // Dynamic-import the SDK so tsup keeps it out of the static-import head of @@ -33,6 +39,17 @@ export interface RenderArgs { maxParallelChunks?: number; executionName?: string; outputKey?: string; + /** Inline JSON for `--variables '{...}'`. Mutually exclusive with `variablesFile`. */ + variables?: string; + /** Path to a JSON file for `--variables-file ./vars.json`. */ + variablesFile?: string; + /** + * Fail the command if any `--variables` key is undeclared or has a wrong + * type vs the composition's `data-composition-variables`. Without this + * flag, mismatches are warnings (matches the local `hyperframes render` + * behavior). + */ + strictVariables?: boolean; /** Print machine-readable JSON instead of the human-friendly summary. */ json: boolean; /** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */ @@ -41,10 +58,43 @@ export interface RenderArgs { waitIntervalMs: number; } +// fallow-ignore-next-line complexity export async function runRender(args: RenderArgs): Promise { const stack = requireStack(args.stackName); const projectDir = resolvePath(args.projectDir); + // Resolve --variables / --variables-file using the same parser the local + // `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly + // errorBox on parse errors so callers don't have to. + const variables = resolveVariablesArg(args.variables, args.variablesFile); + + // Validate against the composition's `data-composition-variables` + // declaration when present. The local CLI silently treats unreadable + // index.html as "no declarations" — mirror that. Skip validation + // entirely when the project dir is missing on disk (e.g. `--site-id` + // pointing at a pre-uploaded site that was packaged on another machine). + if (variables && Object.keys(variables).length > 0) { + const indexPath = join(projectDir, "index.html"); + if (existsSync(indexPath)) { + const issues = validateVariablesAgainstProject(indexPath, variables); + // Suppress the warning block when --json is set; stdout is reserved + // for the manifest. The strict-mode errorBox still prints to stderr + // and exits, so machine consumers still get a non-zero exit. + reportVariableIssues(issues, { strict: args.strictVariables ?? false, quiet: args.json }); + } else if (args.strictVariables && !args.json) { + // --strict-variables asks for typed checking but there's no + // index.html to check against (typical with --site-id pointing at a + // pre-uploaded site). Make that silent skip visible so the flag + // doesn't quietly become a no-op. + console.warn( + c.warn( + `--strict-variables: no ${indexPath} on disk — schema validation skipped. ` + + "Variables flow through unchecked. To enable strict checking, run from a project dir that contains the composition.", + ), + ); + } + } + const config: SerializableDistributedRenderConfig = { fps: args.fps, width: args.width, @@ -55,6 +105,7 @@ export async function runRender(args: RenderArgs): Promise { chunkSize: args.chunkSize, maxParallelChunks: args.maxParallelChunks, runtimeCap: "lambda", + variables, }; // When the caller passes only --site-id, synthesise the minimum-shape diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 57cc05425..a6dbb2c43 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -261,143 +261,4 @@ describe("renderLocal browser GPU config", () => { }); }); -describe("parseVariablesArg", () => { - let parseVariablesArg: typeof import("./render.js").parseVariablesArg; - - beforeAll(async () => { - ({ parseVariablesArg } = await import("./render.js")); - }); - - function expectErr( - result: import("./render.js").VariablesParseResult, - ): T { - if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`); - return result.error as T; - } - - it("returns undefined when neither flag is set", () => { - expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined }); - }); - - it("parses inline JSON object", () => { - expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({ - ok: true, - value: { title: "Hello", n: 3 }, - }); - }); - - it("parses file JSON via injected reader", () => { - const fakeReader = (path: string) => { - if (path === "vars.json") return '{"theme":"dark"}'; - throw new Error("unexpected path"); - }; - expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({ - ok: true, - value: { theme: "dark" }, - }); - }); - - it("rejects when both flags are set", () => { - const err = expectErr(parseVariablesArg('{"a":1}', "vars.json")); - expect(err).toEqual({ kind: "conflict" }); - }); - - it("rejects unparseable JSON with a source-aware kind", () => { - expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({ - kind: "parse-error", - source: "inline", - }); - expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({ - kind: "parse-error", - source: "file", - }); - }); - - it("rejects non-object payloads (array, string, null, number)", () => { - for (const payload of ["[1,2]", '"hello"', "null", "42"]) { - expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" }); - } - }); - - it("surfaces filesystem errors from --variables-file", () => { - const err = expectErr<{ - kind: "read-error"; - path: string; - cause: string; - }>( - parseVariablesArg(undefined, "missing.json", () => { - throw new Error("ENOENT: no such file"); - }), - ); - expect(err.kind).toBe("read-error"); - expect(err.path).toBe("missing.json"); - 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([]); - }); -}); +// Variables-helper tests live in `../utils/variables.test.ts`. diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 015559674..1916c0975 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,11 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../utils/variables.js"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -44,17 +49,12 @@ 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, normalizeResolutionFlag, parseFps, fpsToNumber, fpsToFfmpegArg, - type VariableValidationIssue, type CanvasResolution, type Fps, type FpsParseResult, @@ -513,27 +513,7 @@ export default defineCommand({ 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); - } - } + reportVariableIssues(issues, { strict: strictVariables, quiet }); } // ── Render ──────────────────────────────────────────────────────────── @@ -602,144 +582,6 @@ interface RenderOptions { pageSideCompositing?: boolean; } -export type VariablesParseError = - | { kind: "conflict" } - | { kind: "read-error"; path: string; cause: string } - | { kind: "parse-error"; source: "inline" | "file"; cause: string } - | { kind: "shape-error" }; - -export type VariablesParseResult = - | { ok: true; value: Record | undefined } - | { ok: false; error: VariablesParseError }; - -/** - * Pure parser for `--variables` / `--variables-file` flag pair. Splits out - * from `resolveVariablesArg` so validation paths are unit-testable without - * triggering `process.exit`. Reports failures via a structured `kind` - * discriminant so the side-effecting wrapper owns all UI strings. - */ -export function parseVariablesArg( - inline: string | undefined, - filePath: string | undefined, - readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"), -): VariablesParseResult { - if (inline != null && filePath != null) { - return { ok: false, error: { kind: "conflict" } }; - } - let raw: string | undefined; - let source: "inline" | "file" | undefined; - if (inline != null) { - raw = inline; - source = "inline"; - } else if (filePath != null) { - try { - raw = readFile(filePath); - source = "file"; - } catch (error: unknown) { - return { - ok: false, - error: { - kind: "read-error", - path: filePath, - cause: error instanceof Error ? error.message : String(error), - }, - }; - } - } - if (raw == null) return { ok: true, value: undefined }; - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error: unknown) { - return { - ok: false, - error: { - kind: "parse-error", - source: source ?? "inline", - cause: error instanceof Error ? error.message : String(error), - }, - }; - } - if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { - return { ok: false, error: { kind: "shape-error" } }; - } - return { ok: true, value: parsed as Record }; -} - -function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } { - switch (error.kind) { - case "conflict": - return { - title: "Conflicting variables flags", - message: "Use either --variables or --variables-file, not both.", - }; - case "read-error": - return { - title: "Could not read --variables-file", - message: `${error.path}: ${error.cause}`, - }; - case "parse-error": - return { - title: - error.source === "file" - ? "Invalid JSON in --variables-file" - : "Invalid JSON in --variables", - message: error.cause, - }; - case "shape-error": - return { - title: "Invalid variables payload", - message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).', - }; - } -} - -/** - * Resolve `--variables` / `--variables-file` into a plain object, or - * `undefined` when neither flag is set. Exits the process with a friendly - * error box on any validation failure. - */ -export function resolveVariablesArg( - inline: string | undefined, - filePath: string | undefined, -): Record | undefined { - const result = parseVariablesArg(inline, filePath); - if (!result.ok) { - const { title, message } = variablesErrorMessage(result.error); - errorBox(title, message); - process.exit(1); - } - 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); -} - /** * Resolve the browser-GPU mode for a CLI render invocation. * diff --git a/packages/cli/src/utils/variables.test.ts b/packages/cli/src/utils/variables.test.ts new file mode 100644 index 000000000..5961fd348 --- /dev/null +++ b/packages/cli/src/utils/variables.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("parseVariablesArg", () => { + let parseVariablesArg: typeof import("./variables.js").parseVariablesArg; + + beforeAll(async () => { + ({ parseVariablesArg } = await import("./variables.js")); + }); + + function expectErr( + result: import("./variables.js").VariablesParseResult, + ): T { + if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`); + return result.error as T; + } + + it("returns undefined when neither flag is set", () => { + expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined }); + }); + + it("parses inline JSON object", () => { + expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({ + ok: true, + value: { title: "Hello", n: 3 }, + }); + }); + + it("parses file JSON via injected reader", () => { + const fakeReader = (path: string) => { + if (path === "vars.json") return '{"theme":"dark"}'; + throw new Error("unexpected path"); + }; + expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({ + ok: true, + value: { theme: "dark" }, + }); + }); + + it("rejects when both flags are set", () => { + const err = expectErr(parseVariablesArg('{"a":1}', "vars.json")); + expect(err).toEqual({ kind: "conflict" }); + }); + + it("rejects unparseable JSON with a source-aware kind", () => { + expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({ + kind: "parse-error", + source: "inline", + }); + expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({ + kind: "parse-error", + source: "file", + }); + }); + + it("rejects non-object payloads (array, string, null, number)", () => { + for (const payload of ["[1,2]", '"hello"', "null", "42"]) { + expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" }); + } + }); + + it("surfaces filesystem errors from --variables-file", () => { + const err = expectErr<{ + kind: "read-error"; + path: string; + cause: string; + }>( + parseVariablesArg(undefined, "missing.json", () => { + throw new Error("ENOENT: no such file"); + }), + ); + expect(err.kind).toBe("read-error"); + expect(err.path).toBe("missing.json"); + expect(err.cause).toMatch(/ENOENT/); + }); +}); + +describe("validateVariablesAgainstProject", () => { + let validateVariablesAgainstProject: typeof import("./variables.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("./variables.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/utils/variables.ts b/packages/cli/src/utils/variables.ts new file mode 100644 index 000000000..c9dcbb00d --- /dev/null +++ b/packages/cli/src/utils/variables.ts @@ -0,0 +1,232 @@ +/** + * Shared `--variables` / `--variables-file` / `--strict-variables` parsing + * and validation helpers used by both `hyperframes render` (in-process) and + * `hyperframes lambda render` (distributed). The Lambda CLI mirrors the + * local UX exactly — same flag names, same parse-error messages, same + * strict-mode behavior — so users who learned the local flow can drive + * Lambda renders without re-learning the surface. + * + * Side-effecting wrappers (`resolveVariablesArg`) call `process.exit(1)` + * on validation failure after rendering an `errorBox`; the pure parsers + * (`parseVariablesArg`) return a discriminated result so unit tests can + * exercise the validation paths without process termination. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + extractCompositionMetadata, + formatVariableValidationIssue, + validateVariables, + type VariableValidationIssue, +} from "@hyperframes/core"; +import { ensureDOMParser } from "./dom.js"; +import { c } from "../ui/colors.js"; +import { errorBox } from "../ui/format.js"; + +export type VariablesParseError = + | { kind: "conflict" } + | { kind: "read-error"; path: string; cause: string } + | { kind: "parse-error"; source: "inline" | "file"; cause: string } + | { kind: "shape-error" }; + +export type VariablesParseResult = + | { ok: true; value: Record | undefined } + | { ok: false; error: VariablesParseError }; + +/** + * Pure parser for the `--variables` / `--variables-file` flag pair. Splits + * out from `resolveVariablesArg` so validation paths are unit-testable + * without triggering `process.exit`. Reports failures via a structured + * `kind` discriminant so the side-effecting wrapper owns all UI strings. + */ +// Exported for tests in `./variables.test.ts`; not consumed outside the +// package. Suppressed so fallow's unused-exports audit doesn't flag a +// type-discriminated parser whose value is exactly testability. +// fallow-ignore-next-line unused-export complexity +export function parseVariablesArg( + inline: string | undefined, + filePath: string | undefined, + readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"), +): VariablesParseResult { + if (inline != null && filePath != null) { + return { ok: false, error: { kind: "conflict" } }; + } + let raw: string | undefined; + let source: "inline" | "file" | undefined; + if (inline != null) { + raw = inline; + source = "inline"; + } else if (filePath != null) { + try { + raw = readFile(filePath); + source = "file"; + } catch (error: unknown) { + return { + ok: false, + error: { + kind: "read-error", + path: filePath, + cause: error instanceof Error ? error.message : String(error), + }, + }; + } + } + if (raw == null) return { ok: true, value: undefined }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + return { + ok: false, + error: { + kind: "parse-error", + source: source ?? "inline", + cause: error instanceof Error ? error.message : String(error), + }, + }; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: { kind: "shape-error" } }; + } + return { ok: true, value: parsed as Record }; +} + +function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } { + switch (error.kind) { + case "conflict": + return { + title: "Conflicting variables flags", + message: "Use either --variables or --variables-file, not both.", + }; + case "read-error": + return { + title: "Could not read --variables-file", + message: `${error.path}: ${error.cause}`, + }; + case "parse-error": + return { + title: + error.source === "file" + ? "Invalid JSON in --variables-file" + : "Invalid JSON in --variables", + message: error.cause, + }; + case "shape-error": + return { + title: "Invalid variables payload", + message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).', + }; + } +} + +/** + * Resolve `--variables` / `--variables-file` into a plain object, or + * `undefined` when neither flag is set. Exits the process with a friendly + * error box on any validation failure. + */ +export function resolveVariablesArg( + inline: string | undefined, + filePath: string | undefined, +): Record | undefined { + const result = parseVariablesArg(inline, filePath); + if (!result.ok) { + const { title, message } = variablesErrorMessage(result.error); + errorBox(title, message); + process.exit(1); + } + 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. + * + * One-shot variant: parses the index every call. Batch callers that + * pre-validate N entries against the same project should reuse + * {@link loadProjectVariableSchema} + {@link validateVariablesAgainstSchema} + * to amortise the read + DOM parse. + */ +export function validateVariablesAgainstProject( + indexPath: string, + values: Record, +): VariableValidationIssue[] { + const schema = loadProjectVariableSchema(indexPath); + return validateVariablesAgainstSchema(values, schema); +} + +/** Cached schema returned by {@link loadProjectVariableSchema}. */ +export type ProjectVariableSchema = ReturnType["variables"]; + +/** + * Read + parse the composition's `data-composition-variables` declaration + * once. Returns an empty array on missing/unreadable index — the lint + * pass owns malformed-HTML diagnostics. + * + * Batch callers pair this with {@link validateVariablesAgainstSchema} to + * avoid the per-entry file read + DOMParser cost. + */ +export function loadProjectVariableSchema(indexPath: string): ProjectVariableSchema { + 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(); + return extractCompositionMetadata(html).variables; +} + +/** + * Validate `values` against a pre-loaded schema. Empty schema means the + * project didn't declare variables — return no issues. + */ +export function validateVariablesAgainstSchema( + values: Record, + schema: ProjectVariableSchema, +): VariableValidationIssue[] { + if (schema.length === 0) return []; + return validateVariables(values, schema); +} + +/** + * Print a uniform warning block for variable validation issues; in + * `strict` mode, render an errorBox and exit(1). Used by both + * `hyperframes render` and `hyperframes lambda render` so the UX is + * identical across the two surfaces. Pass `quiet: true` to suppress the + * warning block (the errorBox in strict mode still prints). + */ +export function reportVariableIssues( + issues: readonly VariableValidationIssue[], + options: { strict: boolean; quiet?: boolean }, +): void { + if (issues.length === 0) return; + const { strict, quiet } = options; + 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 (strict) { + errorBox( + "Variable validation failed", + "Aborting render due to variable issues (--strict-variables mode).", + ); + process.exit(1); + } +}