mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): hyperframes lambda render --variables / --variables-file / --strict-variables
Mirror the local hyperframes render variables UX on the Lambda CLI: - --variables '<json>' inline JSON object of variable values - --variables-file <path> path to a JSON file with variable values - --strict-variables fail on type/declared-mismatch (warn by default) Resolution + validation logic is hoisted to packages/cli/src/utils/variables.ts so both surfaces share one parser. The new reportVariableIssues helper formats the warning block + handles --strict-variables exit, deduping the per-CLI issue-handling block. Variables flow into SerializableDistributedRenderConfig.variables and reach every chunk worker via the path PR 9.1 + 9.2 wired up (plan() → meta/encoder.json → renderChunk() → window.__hfVariables). Pre-validation against the composition's data-composition-variables declaration runs only when the project's index.html is on disk — --site-id pointing at a pre-uploaded site that was packaged elsewhere skips the check, matching how the local CLI treats unreadable index files. The render.ts re-exports of parseVariablesArg / resolveVariablesArg / validateVariablesAgainstProject are dropped; the matching tests move to packages/cli/src/utils/variables.test.ts where the implementations now live. Docs: docs/packages/cli.mdx adds a section on --variables / --variables-file / --strict-variables for lambda render, including the 256 KiB Step Functions execution-input cap and a pointer to the upcoming templates-on-lambda guide (PR 9.5). Phase 9 PR 9.3 of the distributed rendering plan.
This commit is contained in:
@@ -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 <renderId | executionArn>`
|
||||
|
||||
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.
|
||||
|
||||
@@ -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/<exec>/output.<ext>)",
|
||||
},
|
||||
// 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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
chunkSize: args.chunkSize,
|
||||
maxParallelChunks: args.maxParallelChunks,
|
||||
runtimeCap: "lambda",
|
||||
variables,
|
||||
};
|
||||
|
||||
// When the caller passes only --site-id, synthesise the minimum-shape
|
||||
|
||||
@@ -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<T extends { kind: string }>(
|
||||
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(`<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([]);
|
||||
});
|
||||
});
|
||||
// Variables-helper tests live in `../utils/variables.test.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<string, unknown> | 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<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the browser-GPU mode for a CLI render invocation.
|
||||
*
|
||||
|
||||
@@ -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<T extends { kind: string }>(
|
||||
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(`<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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<string, unknown>,
|
||||
): VariableValidationIssue[] {
|
||||
const schema = loadProjectVariableSchema(indexPath);
|
||||
return validateVariablesAgainstSchema(values, schema);
|
||||
}
|
||||
|
||||
/** Cached schema returned by {@link loadProjectVariableSchema}. */
|
||||
export type ProjectVariableSchema = ReturnType<typeof extractCompositionMetadata>["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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user