From cf629bba29255101cee49ab5ba00db804ce5e780 Mon Sep 17 00:00:00 2001 From: Mauro Santos Date: Fri, 24 Jul 2026 15:21:24 +0200 Subject: [PATCH] feat: add cli json output --- src/cli/index.ts | 169 +++++++++++++++++++++++++++++++++++++++------ src/cli/presets.ts | 99 +++++++++++++++++++++++--- test/cli.test.ts | 62 +++++++++++++++++ 3 files changed, 300 insertions(+), 30 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index aa4e2a8..d6e6e77 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -36,6 +36,7 @@ program .option("--cache", "Reuse cached output for identical deterministic input") .option("--cache-dir ", "Cache directory", ".clickclick-cache") .option("--cache-info", "Print cache hit/miss information") + .option("--json", "Print a JSON summary") .option("--strict", "Exit non-zero when renderer warnings are produced") .action(async (htmlFile: string, options) => { const htmlPath = resolve(htmlFile); @@ -57,10 +58,10 @@ program }; const sizes = parseSizeOptions(options); if (sizes.length > 0) { - await runMultiSizeRender(input, sizes, multiSizeOutputOptions(options, basenameWithoutExtension(htmlPath)), Boolean(options.strict), parseCacheOptions(options)); + await runMultiSizeRender(input, sizes, multiSizeOutputOptions(options, basenameWithoutExtension(htmlPath)), renderReportOptions(options), parseCacheOptions(options)); return; } - await runRender(input, Boolean(options.strict), parseCacheOptions(options)); + await runRender(input, renderReportOptions(options), parseCacheOptions(options)); }); program @@ -118,6 +119,7 @@ program .option("--cache", "Reuse cached output for identical deterministic input") .option("--cache-dir ", "Cache directory", ".clickclick-cache") .option("--cache-info", "Print cache hit/miss information") + .option("--json", "Print a JSON summary") .option("--strict", "Exit non-zero when renderer warnings are produced") .action(async (htmlFile: string, options) => { const htmlPath = resolve(htmlFile); @@ -141,10 +143,10 @@ program }; const sizes = parseSizeOptions(options); if (sizes.length > 0) { - await runMultiSizeTemplate(input, sizes, multiSizeOutputOptions(options, basenameWithoutExtension(htmlPath)), Boolean(options.strict)); + await runMultiSizeTemplate(input, sizes, multiSizeOutputOptions(options, basenameWithoutExtension(htmlPath)), renderReportOptions(options)); return; } - await runTemplate(input, Boolean(options.strict)); + await runTemplate(input, renderReportOptions(options)); }); program @@ -399,6 +401,7 @@ composition .option("--cache", "Reuse cached output for identical deterministic input") .option("--cache-dir ", "Cache directory", ".clickclick-cache") .option("--cache-info", "Print cache hit/miss information") + .option("--json", "Print a JSON summary") .option("--strict", "Exit non-zero when renderer warnings are produced") .action(async (options) => { await runRender({ @@ -586,11 +589,12 @@ config }; const sizes = parseSizeOptions(options); if (sizes.length > 0) { - await runMultiSizeRecipe(resolve(configFile), name, input, sizes, multiSizeOutputOptions(options, name), Boolean(options.strict)); + await runMultiSizeRecipe(resolve(configFile), name, input, sizes, multiSizeOutputOptions(options, name), renderReportOptions(options)); return; } + const startedAt = performance.now(); const result = await renderRecipe(resolve(configFile), name, input); - reportResult(result, Boolean(options.strict), Boolean(options.cacheInfo)); + reportRenderResult(result, renderReportOptions(options), startedAt); }); config @@ -602,17 +606,31 @@ config .option("--cache", "Reuse cached output for identical deterministic input") .option("--cache-dir ", "Cache directory", ".clickclick-cache") .option("--cache-info", "Print cache hit/miss information") + .option("--json", "Print a JSON summary") .option("--strict", "Exit non-zero when renderer warnings are produced") .action(async (configFile: string, name: string, options) => { + const startedAt = performance.now(); const outDir = typeof options.outDir === "string" ? resolve(options.outDir) : undefined; if (outDir) await mkdir(outDir, { recursive: true }); const results = await renderTemplateSet(resolve(configFile), name, outDir, { cache: parseCacheOptions(options), brand: await parseBrandKitOption(options), }); - for (const result of results) { - reportResult(result, Boolean(options.strict), Boolean(options.cacheInfo)); - if (result.path) console.log(result.path); + const reportOptions = renderReportOptions(options); + if (reportOptions.json) { + writeJson({ + ok: results.every((result) => result.warnings.length === 0), + outputs: results.map((result) => renderJsonSummary(result, startedAt)), + durationMs: durationMs(startedAt), + }); + } else { + for (const result of results) { + reportResult(result, reportOptions.strict, reportOptions.cacheInfo); + if (result.path) console.log(result.path); + } + } + if (reportOptions.strict && results.some((result) => result.warnings.length > 0)) { + process.exitCode = 1; } }); @@ -658,18 +676,28 @@ program.parseAsync().catch((error: unknown) => { process.exitCode = 1; }); -async function runRender(input: RenderImageInput, strict: boolean, cache?: RenderCacheOptions) { +interface RenderReportOptions { + strict: boolean; + cacheInfo: boolean; + json: boolean; +} + +async function runRender(input: RenderImageInput, options: RenderReportOptions | boolean, cache?: RenderCacheOptions) { + const startedAt = performance.now(); const result = await renderImage(input, { cache }); - reportResult(result, strict, isCacheInfoEnabled(cache)); + reportRenderResult(result, normalizeReportOptions(options, cache), startedAt); } -async function runTemplate(input: TemplateInput, strict: boolean) { +async function runTemplate(input: TemplateInput, options: RenderReportOptions | boolean) { + const startedAt = performance.now(); const result = await renderTemplate(input); - reportResult(result, strict, isCacheInfoEnabled(input.cache)); + reportRenderResult(result, normalizeReportOptions(options, input.cache), startedAt); } -async function runMultiSizeRender(input: RenderImageInput, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, strict: boolean, cache?: RenderCacheOptions) { +async function runMultiSizeRender(input: RenderImageInput, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, options: RenderReportOptions, cache?: RenderCacheOptions) { + const startedAt = performance.now(); await mkdir(output.dir, { recursive: true }); + const results: RenderImageResult[] = []; for (const size of sizes) { const path = multiSizePath(output, size); const result = await renderImage({ @@ -677,13 +705,21 @@ async function runMultiSizeRender(input: RenderImageInput, sizes: ParsedRenderSi viewport: { width: size.width, height: size.height }, output: { ...input.output, path }, }, { cache }); - reportResult(result, strict, isCacheInfoEnabled(cache)); - console.log(path); + results.push(result); + if (!options.json) { + reportResult(result, options.strict, isCacheInfoEnabled(cache)); + console.log(path); + } + } + if (options.json) { + reportRenderResults(results, options, startedAt); } } -async function runMultiSizeTemplate(input: TemplateInput, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, strict: boolean) { +async function runMultiSizeTemplate(input: TemplateInput, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, options: RenderReportOptions) { + const startedAt = performance.now(); await mkdir(output.dir, { recursive: true }); + const results: RenderImageResult[] = []; for (const size of sizes) { const path = multiSizePath(output, size); const result = await renderTemplate({ @@ -691,13 +727,21 @@ async function runMultiSizeTemplate(input: TemplateInput, sizes: ParsedRenderSiz viewport: { width: size.width, height: size.height }, output: { ...input.output, path }, }); - reportResult(result, strict, isCacheInfoEnabled(input.cache)); - console.log(path); + results.push(result); + if (!options.json) { + reportResult(result, options.strict, isCacheInfoEnabled(input.cache)); + console.log(path); + } + } + if (options.json) { + reportRenderResults(results, options, startedAt); } } -async function runMultiSizeRecipe(configPath: string, name: string, input: Partial, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, strict: boolean) { +async function runMultiSizeRecipe(configPath: string, name: string, input: Partial, sizes: ParsedRenderSize[], output: MultiSizeOutputOptions, options: RenderReportOptions) { + const startedAt = performance.now(); await mkdir(output.dir, { recursive: true }); + const results: RenderImageResult[] = []; for (const size of sizes) { const path = multiSizePath(output, size); const result = await renderRecipe(configPath, name, { @@ -705,8 +749,14 @@ async function runMultiSizeRecipe(configPath: string, name: string, input: Parti viewport: { width: size.width, height: size.height }, output: { ...input.output, path }, }); - reportResult(result, strict, isCacheInfoEnabled(input.cache)); - console.log(path); + results.push(result); + if (!options.json) { + reportResult(result, options.strict, isCacheInfoEnabled(input.cache)); + console.log(path); + } + } + if (options.json) { + reportRenderResults(results, options, startedAt); } } @@ -936,6 +986,70 @@ function reportResult(result: RenderImageResult, strict: boolean, cacheInfo: boo reportWarnings(result, strict); } +function reportRenderResult(result: RenderImageResult, options: RenderReportOptions, startedAt: number) { + if (options.json) { + writeJson(renderJsonSummary(result, startedAt)); + } else { + reportResult(result, options.strict, options.cacheInfo); + } + if (options.strict && result.warnings.length > 0) { + process.exitCode = 1; + } +} + +function reportRenderResults(results: RenderImageResult[], options: RenderReportOptions, startedAt: number) { + writeJson({ + ok: results.every((result) => result.warnings.length === 0), + outputs: results.map((result) => renderJsonSummary(result, startedAt)), + durationMs: durationMs(startedAt), + }); + if (options.strict && results.some((result) => result.warnings.length > 0)) { + process.exitCode = 1; + } +} + +function renderJsonSummary(result: RenderImageResult, startedAt: number) { + return { + path: result.path, + format: result.format, + width: result.width, + height: result.height, + warnings: result.warnings, + cache: result.cache + ? { + hit: result.cache.hit, + status: result.cache.hit ? "hit" : "miss", + key: result.cache.key, + dir: result.cache.dir, + skippedReason: result.cache.skippedReason, + } + : undefined, + durationMs: durationMs(startedAt), + }; +} + +function renderReportOptions(options: Record): RenderReportOptions { + return { + strict: Boolean(options.strict), + cacheInfo: Boolean(options.cacheInfo), + json: Boolean(options.json), + }; +} + +function normalizeReportOptions(options: RenderReportOptions | boolean, cache: RenderCacheOptions | undefined): RenderReportOptions { + return typeof options === "boolean" + ? { strict: options, cacheInfo: isCacheInfoEnabled(cache), json: false } + : { ...options, cacheInfo: options.cacheInfo || isCacheInfoEnabled(cache) }; +} + +function durationMs(startedAt: number): number { + return Math.round((performance.now() - startedAt) * 100) / 100; +} + +function writeJson(value: unknown) { + console.log(JSON.stringify(value, null, 2)); +} + function isCacheInfoEnabled(cache: RenderCacheOptions | undefined): boolean { return Boolean(cache && cache !== true && cache.info); } @@ -1184,6 +1298,17 @@ function parseSafeArea(value: unknown): QualitySafeArea | undefined { } function reportError(error: unknown) { + if (process.argv.includes("--json")) { + const code = error instanceof ClickClickError ? error.code : "ERROR"; + writeJson({ + ok: false, + error: { + code, + message: error instanceof Error ? error.message : String(error), + }, + }); + return; + } if (error instanceof ClickClickError) { console.error(`${error.code}: ${error.message}`); return; diff --git a/src/cli/presets.ts b/src/cli/presets.ts index 25131f2..62873f5 100644 --- a/src/cli/presets.ts +++ b/src/cli/presets.ts @@ -1,16 +1,22 @@ import { join, resolve } from "node:path"; import type { Command } from "commander"; -import { ClickClickError, loadBrandKit, presets, renderTemplate } from "../index.js"; +import { ClickClickError, loadBrandKit, presets, renderImage, renderTemplate } from "../index.js"; import { presetMetadata } from "../presets/index.js"; import { coercePresetOption, loadLocalPresetConfig, loadLocalPresetModule, renderLocalPreset, resolvePresetValues, validatePresetSchema } from "../presets/schema.js"; import type { LocalPresetSchema, PresetModuleDefinition, PresetOptionSchema, PresetSchema } from "../presets/schema.js"; import type { PresetLogoOptions, PresetWatermarkOptions } from "../presets/index.js"; -import type { RenderImageInput } from "../types.js"; +import type { RenderImageInput, RenderImageResult } from "../types.js"; import { collectOption, parseCacheOptions, parseInteger, parseNumber, parseOutputOptions, parseSizeOptions } from "./options.js"; import type { ParsedRenderSize } from "./options.js"; interface PresetCliDependencies { - runRender: (input: RenderImageInput, strict: boolean, cache?: ReturnType) => Promise; + runRender: (input: RenderImageInput, options: RenderReportOptions | boolean, cache?: ReturnType) => Promise; +} + +interface RenderReportOptions { + strict: boolean; + cacheInfo: boolean; + json: boolean; } interface PresetCommandOption { @@ -528,18 +534,32 @@ async function runPresetCommand(definition: PresetCommandDefinition, options: Re await dependencies.runRender({ ...definition.render(brandedOptions), output: parseOutputOptions(options), - }, Boolean(options.strict), parseCacheOptions(options)); + }, renderReportOptions(options), parseCacheOptions(options)); return; } const outDir = multiSizeOutDir(options); + if (options.json) { + const startedAt = performance.now(); + const results: RenderImageResult[] = []; + for (const size of requestedSizes) { + const renderOptions = { ...brandedOptions, width: size.width, height: size.height, output: multiSizeOutputPath(outDir, definition.command, size, options) }; + results.push(await renderImage({ + ...definition.render(renderOptions), + output: parseOutputOptions(renderOptions), + }, { cache: parseCacheOptions(options) })); + } + reportJsonResults(results, startedAt, Boolean(options.strict)); + return; + } + for (const size of requestedSizes) { const renderOptions = { ...brandedOptions, width: size.width, height: size.height, output: multiSizeOutputPath(outDir, definition.command, size, options) }; await dependencies.runRender({ ...definition.render(renderOptions), output: parseOutputOptions(renderOptions), - }, Boolean(options.strict), parseCacheOptions(options)); - console.log(renderOptions.output); + }, renderReportOptions(options), parseCacheOptions(options)); + if (!options.json) console.log(renderOptions.output); } } @@ -564,6 +584,24 @@ function registerLocalPresetCommand(parent: Command, dependencies: PresetCliDepe } const outDir = multiSizeOutDir(options); + if (options.json) { + const startedAt = performance.now(); + const results: RenderImageResult[] = []; + for (const size of requestedSizes) { + const output = multiSizeOutputPath(outDir, name, size, options); + const input = renderLocalPreset({ + ...schema, + viewport: { ...schema.viewport, width: size.width, height: size.height }, + }, values, parseOutputOptions({ ...options, output })); + results.push(await renderTemplate({ + ...input, + cache: parseCacheOptions(options), + })); + } + reportJsonResults(results, startedAt, Boolean(options.strict)); + return; + } + for (const size of requestedSizes) { const output = multiSizeOutputPath(outDir, name, size, options); const input = renderLocalPreset({ @@ -571,7 +609,7 @@ function registerLocalPresetCommand(parent: Command, dependencies: PresetCliDepe viewport: { ...schema.viewport, width: size.width, height: size.height }, }, values, parseOutputOptions({ ...options, output })); await runLocalPreset(input, options, dependencies); - console.log(output); + if (!options.json) console.log(output); } }); } @@ -609,10 +647,15 @@ function schemaWithViewportOverrides(schema: LocalPresetSchema, options: Record< } async function runLocalPreset(input: ReturnType, options: Record, dependencies: PresetCliDependencies) { + const startedAt = performance.now(); const result = await renderTemplate({ ...input, cache: parseCacheOptions(options), }); + if (options.json) { + console.log(JSON.stringify(renderJsonSummary(result, startedAt), null, 2)); + return; + } for (const warning of result.warnings) { console.warn(warning.message); } @@ -625,7 +668,7 @@ async function runModulePreset(definition: PresetModuleDefinition, values: Recor await dependencies.runRender({ ...definition.render(values), output: parseOutputOptions(options), - }, Boolean(options.strict), parseCacheOptions(options)); + }, renderReportOptions(options), parseCacheOptions(options)); } function parseModulePresetArgs(schema: PresetModuleDefinition, args: string[]): Record { @@ -771,9 +814,49 @@ function addPresetRenderOptions(command: Command): Command { .option("--cache", "Reuse cached output for identical deterministic input") .option("--cache-dir ", "Cache directory", ".clickclick-cache") .option("--cache-info", "Print cache hit/miss information") + .option("--json", "Print a JSON summary") .option("--strict", "Exit non-zero when renderer warnings are produced"); } +function renderReportOptions(options: Record): RenderReportOptions { + return { + strict: Boolean(options.strict), + cacheInfo: Boolean(options.cacheInfo), + json: Boolean(options.json), + }; +} + +function renderJsonSummary(result: RenderImageResult, startedAt: number) { + return { + path: result.path, + format: result.format, + width: result.width, + height: result.height, + warnings: result.warnings, + cache: result.cache + ? { + hit: result.cache.hit, + status: result.cache.hit ? "hit" : "miss", + key: result.cache.key, + dir: result.cache.dir, + skippedReason: result.cache.skippedReason, + } + : undefined, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + }; +} + +function reportJsonResults(results: RenderImageResult[], startedAt: number, strict: boolean) { + console.log(JSON.stringify({ + ok: results.every((result) => result.warnings.length === 0), + outputs: results.map((result) => renderJsonSummary(result, startedAt)), + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + }, null, 2)); + if (strict && results.some((result) => result.warnings.length > 0)) { + process.exitCode = 1; + } +} + function multiSizeOutDir(options: Record): string { if (typeof options.output === "string") { throw new ClickClickError("INVALID_INPUT", "--out cannot be combined with --size or --sizes. Use --out-dir for multi-size output."); diff --git a/test/cli.test.ts b/test/cli.test.ts index e5004fc..f90bd4f 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -56,6 +56,34 @@ describe("CLI", () => { await expect(readFile(presetOut)).resolves.toHaveProperty("length"); }); + it("prints parseable JSON for render and preset outputs", async () => { + const htmlPath = join(tempDir, "json-card.html"); + const renderOut = join(tempDir, "json-render.png"); + const presetOut = join(tempDir, "json-preset.png"); + + await writeFile(htmlPath, "
JSON
"); + + const render = await runCli(["render", htmlPath, "--out", renderOut, "--width", "64", "--height", "64", "--json"]); + const preset = await runCli(["preset", "solid", "--title", "JSON", "--out", presetOut, "--width", "64", "--height", "64", "--json"]); + + expect(JSON.parse(render.stdout)).toMatchObject({ + path: renderOut, + format: "png", + width: 64, + height: 64, + warnings: [], + durationMs: expect.any(Number), + }); + expect(JSON.parse(preset.stdout)).toMatchObject({ + path: presetOut, + format: "png", + width: 64, + height: 64, + warnings: expect.any(Array), + durationMs: expect.any(Number), + }); + }, 60000); + it("generates a contact sheet from local image paths", async () => { const pngPath = join(tempDir, "contact-source.png"); const jpegPath = join(tempDir, "contact-source.jpg"); @@ -605,6 +633,40 @@ describe("CLI", () => { expect(PNG.sync.read(await readFile(join(multiOutDir, "card-96x48.png")))).toMatchObject({ width: 96, height: 48 }); }, 60000); + it("prints all config set outputs as JSON", async () => { + const htmlPath = join(tempDir, "config-set-json-template.html"); + const configPath = join(tempDir, "clickclick-set-json.config.json"); + const outDir = join(tempDir, "config-set-json-out"); + + await writeFile(htmlPath, '
Set
'); + await writeFile(configPath, JSON.stringify({ + templates: { + card: { + htmlPath, + css: "html,body,main{margin:0;width:100%;height:100%;background:#ffffff}", + }, + }, + templateSets: { + social: [ + { name: "square", template: "card", output: { width: 32, height: 32 }, modifications: [{ name: "card", background: "#ff0000" }] }, + { name: "wide", template: "card", output: { width: 64, height: 32 }, modifications: [{ name: "card", background: "#00ff00" }] }, + ], + }, + })); + + const result = await runCli(["config", "set", configPath, "social", "--out-dir", outDir, "--json"]); + const summary = JSON.parse(result.stdout); + + expect(summary).toMatchObject({ + ok: true, + durationMs: expect.any(Number), + outputs: [ + { path: join(outDir, "square.png"), format: "png", width: 32, height: 32, warnings: [] }, + { path: join(outDir, "wide.png"), format: "png", width: 64, height: 32, warnings: [] }, + ], + }); + }, 60000); + it("screenshots a URL", async () => { const out = join(tempDir, "url.png"); const url = `data:text/html,${encodeURIComponent("
URL
")}`;