import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import { c } from "../ui/colors.js"; import { errorBox } from "../ui/format.js"; import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js"; import { loadProjectVariableSchema, reportVariableIssues, validateVariablesAgainstSchema, } from "../utils/variables.js"; export class BatchRenderInputError extends Error { readonly title: string; readonly hint: string | undefined; constructor(title: string, message: string, hint?: string) { super(message); this.name = "BatchRenderInputError"; this.title = title; this.hint = hint; } } export interface PreparedBatchRow { index: number; variables: Record; outputPath: string; } export interface PreparedBatchRender { batchPath: string; manifestPath: string; variableIssueCount: number; rows: PreparedBatchRow[]; } export interface BatchRenderResult { durationMs?: number; renderTimeMs: number; } export interface BatchManifestRow { index: number; outputPath: string; status: "pending" | "running" | "completed" | "failed" | "skipped"; durationMs: number | null; renderTimeMs: number | null; error: string | null; startedAt: string | null; completedAt: string | null; variables: Record; } export interface BatchManifest { version: 1; batchPath: string; manifestPath: string; total: number; completed: number; failed: number; skipped: number; rows: BatchManifestRow[]; } interface PrepareBatchRenderOptions { batchPath: string; outputTemplate: string; indexPath: string; strictVariables: boolean; quiet: boolean; json: boolean; readFile?: (path: string) => string; } interface RunBatchRenderOptions { prepared: PreparedBatchRender; concurrency: number; failFast: boolean; quiet: boolean; json: boolean; renderOne: (row: PreparedBatchRow) => Promise; } const PLACEHOLDER_RE = /\{([A-Za-z0-9_.-]+)\}/g; function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function parseJson(raw: string, source: string): unknown { try { return JSON.parse(raw); } catch (error: unknown) { throw new BatchRenderInputError("Invalid JSON in --batch", `${source}: ${errorMessage(error)}`); } } export function parseBatchRows(raw: string, source: string): Record[] { const parsed = parseJson(raw, source); const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed.rows : undefined; if (!Array.isArray(rows)) { throw new BatchRenderInputError( "Invalid batch payload", '--batch must be a JSON array of objects, or an object with a "rows" array.', ); } if (rows.length === 0) { throw new BatchRenderInputError("Empty batch", `${source} contains zero rows.`); } return rows.map((row, index) => { if (!isRecord(row)) { throw new BatchRenderInputError( "Invalid batch row", `Row ${index} must be a JSON object of variable values.`, ); } return row; }); } function placeholderValue(row: Record, key: string, index: number): string { if (key === "index") return String(index); if (!Object.hasOwn(row, key)) { throw new BatchRenderInputError( "Invalid output template", `Missing value for placeholder {${key}} in row ${index}.`, ); } const value = row[key]; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } throw new BatchRenderInputError( "Invalid output template", `Placeholder {${key}} in row ${index} must resolve to a string, number, or boolean.`, ); } export function resolveOutputTemplate( template: string, row: Record, index: number, ): string { return template.replace(PLACEHOLDER_RE, (_match, key: string) => placeholderValue(row, key, index), ); } function isSameOrChildPath(path: string, parent: string): boolean { return path === parent || path.startsWith(parent.endsWith(sep) ? parent : parent + sep); } export function commonOutputDirectory(outputPaths: readonly string[]): string { const firstPath = outputPaths[0]; if (!firstPath) return resolve("renders"); let common = dirname(firstPath); for (const outputPath of outputPaths.slice(1)) { const dir = dirname(outputPath); while (!isSameOrChildPath(dir, common)) { const parent = dirname(common); if (parent === common) return common; common = parent; } } return common; } function checkOutputCollisions(rows: readonly PreparedBatchRow[], manifestPath: string): void { const seen = new Map(); for (const row of rows) { const previous = seen.get(row.outputPath); if (previous !== undefined) { throw new BatchRenderInputError( "Batch output collision", `Rows ${previous} and ${row.index} both resolve to ${row.outputPath}.`, "Use placeholders such as {index} or a unique row key in --output.", ); } if (row.outputPath === manifestPath) { throw new BatchRenderInputError( "Batch output collision", `Row ${row.index} resolves to the manifest path: ${manifestPath}.`, ); } seen.set(row.outputPath, row.index); } } function validateBatchVariables( rows: readonly Record[], indexPath: string, strictVariables: boolean, quiet: boolean, json: boolean, ): number { const schema = loadProjectVariableSchema(indexPath); let issueCount = 0; const strictRows: number[] = []; for (let index = 0; index < rows.length; index++) { const row = rows[index]; if (!row || Object.keys(row).length === 0) continue; const issues = validateVariablesAgainstSchema(row, schema); if (issues.length === 0) continue; issueCount += issues.length; if (!quiet && !json) { console.log(""); console.log(c.dim(`Batch row ${index}:`)); } reportVariableIssues(issues, { strict: false, quiet: quiet || json }); if (strictVariables) strictRows.push(index); } if (strictRows.length > 0) { throw new BatchRenderInputError( "Variable validation failed", `Aborting batch due to variable issues in row ${strictRows.join(", ")} (--strict-variables mode).`, ); } return issueCount; } export function prepareBatchRender(options: PrepareBatchRenderOptions): PreparedBatchRender { const batchPath = resolve(options.batchPath); const read = options.readFile ?? ((path: string) => readFileSync(path, "utf8")); let raw: string; try { raw = read(batchPath); } catch (error: unknown) { throw new BatchRenderInputError( "Could not read --batch", `${batchPath}: ${errorMessage(error)}`, ); } const variableRows = parseBatchRows(raw, batchPath); const rows = variableRows.map((variables, index) => ({ index, variables, outputPath: resolve(resolveOutputTemplate(options.outputTemplate, variables, index)), })); const manifestPath = join( commonOutputDirectory(rows.map((row) => row.outputPath)), "manifest.json", ); checkOutputCollisions(rows, manifestPath); const variableIssueCount = validateBatchVariables( variableRows, options.indexPath, options.strictVariables, options.quiet, options.json, ); return { batchPath, manifestPath, variableIssueCount, rows, }; } function makeInitialManifest(prepared: PreparedBatchRender): BatchManifest { return { version: 1, batchPath: prepared.batchPath, manifestPath: prepared.manifestPath, total: prepared.rows.length, completed: 0, failed: 0, skipped: 0, rows: prepared.rows.map((row) => ({ index: row.index, outputPath: row.outputPath, status: "pending", durationMs: null, renderTimeMs: null, error: null, startedAt: null, completedAt: null, variables: row.variables, })), }; } function summarizeManifest(manifest: BatchManifest): void { manifest.completed = manifest.rows.filter((row) => row.status === "completed").length; manifest.failed = manifest.rows.filter((row) => row.status === "failed").length; manifest.skipped = manifest.rows.filter((row) => row.status === "skipped").length; } function writeManifest(manifest: BatchManifest): void { summarizeManifest(manifest); mkdirSync(dirname(manifest.manifestPath), { recursive: true }); writeFileSync(manifest.manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); } function emitJsonEvent(event: Record, json: boolean): void { if (json) console.log(JSON.stringify(event)); } async function renderBatchRow( row: PreparedBatchRow, manifest: BatchManifest, options: RunBatchRenderOptions, ): Promise { const manifestRow = manifest.rows[row.index]; if (!manifestRow) { throw new Error(`Batch manifest is missing row ${row.index}`); } manifestRow.status = "running"; manifestRow.startedAt = new Date().toISOString(); writeManifest(manifest); emitJsonEvent( { type: "batch-row-start", index: row.index, outputPath: row.outputPath }, options.json, ); if (!options.quiet && !options.json) { console.log(c.dim(`Batch row ${row.index}: ${row.outputPath}`)); } try { mkdirSync(dirname(row.outputPath), { recursive: true }); const result = await options.renderOne(row); manifestRow.status = "completed"; manifestRow.durationMs = result.durationMs ?? null; manifestRow.renderTimeMs = result.renderTimeMs; manifestRow.completedAt = new Date().toISOString(); writeManifest(manifest); emitJsonEvent( { type: "batch-row-complete", index: row.index, outputPath: row.outputPath, durationMs: manifestRow.durationMs, renderTimeMs: manifestRow.renderTimeMs, }, options.json, ); return true; } catch (error: unknown) { manifestRow.status = "failed"; manifestRow.error = errorMessage(error); manifestRow.completedAt = new Date().toISOString(); writeManifest(manifest); emitJsonEvent( { type: "batch-row-error", index: row.index, outputPath: row.outputPath, error: manifestRow.error, }, options.json, ); if (!options.quiet && !options.json) { console.log(c.error(` Row ${row.index} failed: ${manifestRow.error}`)); } return false; } } function markUnstartedRowsSkipped(manifest: BatchManifest): void { for (const row of manifest.rows) { if (row.status !== "pending") continue; row.status = "skipped"; row.error = "Skipped after --batch-fail-fast."; row.completedAt = new Date().toISOString(); } } export async function runBatchRender(options: RunBatchRenderOptions): Promise { if (options.concurrency < 1) { throw new BatchRenderInputError( "Invalid batch-concurrency", `Got "${options.concurrency}". Must be a positive integer.`, ); } const manifest = makeInitialManifest(options.prepared); writeManifest(manifest); if (!options.quiet && !options.json) { console.log(""); console.log( c.accent("◆") + ` Batch rendering ${options.prepared.rows.length} rows` + c.dim(` → ${options.prepared.manifestPath}`), ); console.log(c.dim(` batch concurrency: ${options.concurrency}`)); console.log(""); } let cursor = 0; let stopLaunching = false; const workerCount = Math.min(options.concurrency, options.prepared.rows.length); await Promise.all( Array.from({ length: workerCount }, async () => { while (!stopLaunching) { const row = options.prepared.rows[cursor]; if (!row) return; cursor++; const ok = await renderBatchRow(row, manifest, options); if (!ok && options.failFast) { stopLaunching = true; } } }), ); if (stopLaunching) markUnstartedRowsSkipped(manifest); writeManifest(manifest); emitJsonEvent( { type: "batch-complete", manifestPath: manifest.manifestPath, total: manifest.total, completed: manifest.completed, failed: manifest.failed, skipped: manifest.skipped, }, options.json, ); if (!options.quiet && !options.json) { console.log(""); console.log( manifest.failed > 0 ? c.warn( `Batch complete: ${manifest.completed} completed, ${manifest.failed} failed, ${manifest.skipped} skipped.`, ) : c.success(`Batch complete: ${manifest.completed} completed.`), ); console.log(c.dim(`Manifest: ${manifest.manifestPath}`)); } return manifest; } export function exitBatchRenderInputError(error: unknown): never { if (error instanceof BatchRenderInputError) { errorBox(error.title, error.message, error.hint); process.exit(1); } throw error; }