Skip to content

Commit b114b42

Browse files
committed
feat(cli): zod-validate Edit JSON before render and studio
1 parent b5fea34 commit b114b42

6 files changed

Lines changed: 199 additions & 8 deletions

File tree

bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
"test": "bun test"
2929
},
3030
"dependencies": {
31-
"commander": "^12.1.0"
31+
"@shotstack/schemas": "^1.10.10",
32+
"commander": "^12.1.0",
33+
"zod": "^4.4.3"
3234
},
3335
"devDependencies": {
3436
"@types/bun": "latest",

src/commands/render.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { resolveEnv, ENV_NAMES } from "../http/env.ts";
77
import { emit, parseOutputFormat } from "../output.ts";
88
import { withRecording, commandArgv } from "../recorder.ts";
99
import { pollStatus } from "./status.ts";
10+
import { validateEdit, formatIssues } from "../lib/validate.ts";
1011

1112
interface RenderResponse {
1213
success: boolean;
@@ -30,6 +31,13 @@ export const renderCommand = new Command("render")
3031
const raw = await readFile(path, "utf8");
3132
const template = JSON.parse(raw) as unknown;
3233

34+
const validation = validateEdit(template);
35+
if (!validation.ok) {
36+
if (format === "json") console.log(JSON.stringify({ ok: false, issues: validation.issues }));
37+
else console.error(formatIssues(validation.issues));
38+
return { exitCode: 1, validation };
39+
}
40+
3341
const client = createClient({ apiKey, env });
3442
const result = await client.post<RenderResponse>("/render", template);
3543
const id = result.response.id;

src/commands/studio.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
33
import { spawn, execSync } from "node:child_process";
44
import { Command } from "commander";
55
import { emit, parseOutputFormat } from "../output.ts";
6+
import { validateEdit, formatIssues } from "../lib/validate.ts";
67

78
const STUDIO_URL = "https://shotstack.studio";
89
const SHARE_API_URL = `${STUDIO_URL}/api/share`;
@@ -24,7 +25,13 @@ export const studioCommand = new Command("studio")
2425
const path = resolve(process.cwd(), file);
2526
const raw = await readFile(path, "utf8");
2627
const template = JSON.parse(raw) as unknown;
27-
assertTemplateShape(template);
28+
29+
const validation = validateEdit(template);
30+
if (!validation.ok) {
31+
if (format === "json") console.log(JSON.stringify({ ok: false, issues: validation.issues }));
32+
else console.error(formatIssues(validation.issues));
33+
process.exit(1);
34+
}
2835

2936
const { url, shortened } = await buildStudioUrl(template, { shorten: options.shorten });
3037

@@ -82,12 +89,6 @@ async function tryShortenViaStudio(template: unknown): Promise<string | null> {
8289
}
8390
}
8491

85-
function assertTemplateShape(t: unknown): asserts t is { timeline: unknown; output: unknown } {
86-
if (!t || typeof t !== "object") throw new Error("Template must be a JSON object.");
87-
if (!("timeline" in t)) throw new Error("Template missing required field: timeline.");
88-
if (!("output" in t)) throw new Error("Template missing required field: output.");
89-
}
90-
9192
async function copyToClipboard(text: string): Promise<void> {
9293
const { cmd, args } = clipboardCommand();
9394
await new Promise<void>((res, rej) => {

src/lib/validate.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { editSchema } from "@shotstack/schemas/zod";
2+
import type { ZodError, ZodIssue } from "zod";
3+
4+
export interface ValidationIssue {
5+
path: string;
6+
code: string;
7+
message: string;
8+
suggestion?: string;
9+
}
10+
11+
export type ValidationResult =
12+
| { ok: true }
13+
| { ok: false; issues: ValidationIssue[] };
14+
15+
const KEY_SUGGESTIONS: Record<string, string> = {
16+
alignment: "align",
17+
duration: "length",
18+
transitions: "transition",
19+
name: "family",
20+
};
21+
22+
const VALUE_SUGGESTIONS: Record<string, Record<string, string>> = {
23+
"align.vertical": { center: "middle" },
24+
fit: { cover: "crop" },
25+
};
26+
27+
export function validateEdit(input: unknown): ValidationResult {
28+
const result = editSchema.safeParse(input);
29+
if (result.success) return { ok: true };
30+
return { ok: false, issues: toIssues(result.error) };
31+
}
32+
33+
export function formatIssues(issues: ValidationIssue[]): string {
34+
const lines = ["✗ Edit JSON failed validation:"];
35+
for (const issue of issues) {
36+
const hint = issue.suggestion ? ` (did you mean '${issue.suggestion}'?)` : "";
37+
lines.push(` ${issue.path || "<root>"}: ${issue.message}${hint}`);
38+
}
39+
return lines.join("\n");
40+
}
41+
42+
function toIssues(error: ZodError): ValidationIssue[] {
43+
return error.issues.flatMap((issue) => expandIssue(issue));
44+
}
45+
46+
function expandIssue(issue: ZodIssue): ValidationIssue[] {
47+
const basePath = formatPath(issue.path);
48+
49+
if (issue.code === "unrecognized_keys" && "keys" in issue) {
50+
return issue.keys.map((key) => ({
51+
path: appendKey(basePath, key),
52+
code: issue.code,
53+
message: `unrecognized key '${key}'`,
54+
suggestion: KEY_SUGGESTIONS[key],
55+
}));
56+
}
57+
58+
const suggestion = lookupValueSuggestion(basePath, issue);
59+
60+
return [
61+
{
62+
path: basePath,
63+
code: issue.code,
64+
message: issue.message,
65+
...(suggestion ? { suggestion } : {}),
66+
},
67+
];
68+
}
69+
70+
function lookupValueSuggestion(path: string, issue: ZodIssue): string | undefined {
71+
const semanticPath = path.replace(/\[\d+\]/g, "");
72+
const tail = semanticPath.split(".").slice(-2).join(".");
73+
if (issue.code === "invalid_value" && "received" in issue) {
74+
const received = String(issue.received);
75+
return VALUE_SUGGESTIONS[tail]?.[received] ?? VALUE_SUGGESTIONS[semanticPath]?.[received];
76+
}
77+
return undefined;
78+
}
79+
80+
function formatPath(parts: ReadonlyArray<string | number>): string {
81+
return parts.reduce<string>((acc, part) => {
82+
if (typeof part === "number") return `${acc}[${part}]`;
83+
return acc ? `${acc}.${part}` : part;
84+
}, "");
85+
}
86+
87+
function appendKey(base: string, key: string): string {
88+
return base ? `${base}.${key}` : key;
89+
}

tests/validate.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { validateEdit, formatIssues } from "../src/lib/validate.ts";
3+
4+
const validEdit = {
5+
timeline: {
6+
tracks: [
7+
{
8+
clips: [
9+
{
10+
asset: { type: "rich-text", text: "hi", font: { family: "Roboto", size: 22 } },
11+
start: 0,
12+
length: 1,
13+
},
14+
],
15+
},
16+
],
17+
},
18+
output: { format: "mp4", size: { width: 1280, height: 720 } },
19+
};
20+
21+
describe("validateEdit", () => {
22+
test("accepts a minimal valid edit", () => {
23+
expect(validateEdit(validEdit)).toEqual({ ok: true });
24+
});
25+
26+
test("rejects unrecognized keys with did-you-mean for CSS-instinct names", () => {
27+
const bad = structuredClone(validEdit);
28+
(bad.timeline.tracks[0]!.clips[0]!.asset as Record<string, unknown>).alignment = "center";
29+
30+
const result = validateEdit(bad);
31+
expect(result.ok).toBe(false);
32+
if (result.ok) return;
33+
34+
const issue = result.issues.find((i) => i.path.endsWith("alignment"));
35+
expect(issue).toBeDefined();
36+
expect(issue!.code).toBe("unrecognized_keys");
37+
expect(issue!.suggestion).toBe("align");
38+
expect(issue!.path).toBe("timeline.tracks[0].clips[0].asset.alignment");
39+
});
40+
41+
test("rejects wrong types with descriptive messages", () => {
42+
const result = validateEdit({ timeline: "nope", output: { format: "mp4" } });
43+
expect(result.ok).toBe(false);
44+
if (result.ok) return;
45+
46+
const timelineIssue = result.issues.find((i) => i.path === "timeline");
47+
expect(timelineIssue).toBeDefined();
48+
expect(timelineIssue!.message).toMatch(/expected object/i);
49+
});
50+
51+
test("rejects missing required fields", () => {
52+
const result = validateEdit({ timeline: { tracks: [] } });
53+
expect(result.ok).toBe(false);
54+
if (result.ok) return;
55+
56+
const outputIssue = result.issues.find((i) => i.path === "output");
57+
expect(outputIssue).toBeDefined();
58+
});
59+
60+
test("formats array indices with bracket notation", () => {
61+
const bad = structuredClone(validEdit);
62+
(bad.timeline.tracks[0]!.clips[0]!.asset as Record<string, unknown>).duration = 5;
63+
64+
const result = validateEdit(bad);
65+
expect(result.ok).toBe(false);
66+
if (result.ok) return;
67+
68+
const issue = result.issues[0]!;
69+
expect(issue.path).toMatch(/tracks\[0\]\.clips\[0\]/);
70+
expect(issue.suggestion).toBe("length");
71+
});
72+
});
73+
74+
describe("formatIssues", () => {
75+
test("renders a multi-line stderr-friendly summary", () => {
76+
const issues = [
77+
{ path: "timeline.tracks[0]", code: "x", message: "bad", suggestion: "good" },
78+
{ path: "output.format", code: "y", message: "wrong" },
79+
];
80+
const formatted = formatIssues(issues);
81+
expect(formatted).toContain("✗ Edit JSON failed validation:");
82+
expect(formatted).toContain("timeline.tracks[0]: bad (did you mean 'good'?)");
83+
expect(formatted).toContain("output.format: wrong");
84+
});
85+
});

0 commit comments

Comments
 (0)