@stdout-design/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1187 +0,0 @@
1
- import { n as isZodObject } from "./validation-CMTs74Vm-C3_d2dBv.mjs";
2
- import { z } from "zod";
3
- import path from "node:path";
4
- import { fromJsx } from "takumi-js/helpers/jsx";
5
- import { existsSync, unlinkSync } from "node:fs";
6
- import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
7
- import { createHash } from "node:crypto";
8
- import pLimit from "p-limit";
9
- import lockfile from "proper-lockfile";
10
- import realWriteFileAtomic from "write-file-atomic";
11
- import { Database } from "bun:sqlite";
12
- import { Kysely, sql } from "kysely";
13
- import { BunSqliteDialect } from "kysely-bun-sqlite";
14
- import { Renderer } from "takumi-js/node";
15
- import { createElement } from "react";
16
- import { googleFonts } from "@takumi-rs/helpers";
17
- import { pathToFileURL } from "node:url";
18
- //#region ../core/dist/index.mjs
19
- const templateEntrySchema = z.object({
20
- componentPath: z.string().min(1, "componentPath is required"),
21
- description: z.string().optional()
22
- });
23
- const presetSchema = z.object({
24
- height: z.number().int().positive(),
25
- id: z.string().min(1),
26
- platform: z.string().min(1),
27
- width: z.number().int().positive()
28
- });
29
- const studioConfigSchema = z.object({
30
- defaultPreset: z.string().optional(),
31
- locales: z.array(z.string()).optional(),
32
- outDir: z.string().optional(),
33
- presets: z.array(presetSchema),
34
- templates: z.record(z.string(), templateEntrySchema)
35
- });
36
- const compileTemplate = async (element, options) => {
37
- const result = await fromJsx(element, options);
38
- return {
39
- node: result.node,
40
- stylesheets: result.stylesheets
41
- };
42
- };
43
- const ErrorCode = {
44
- API_ERROR: "API_ERROR",
45
- CACHE_CLEANUP_FAILED: "CACHE_CLEANUP_FAILED",
46
- CACHE_CORRUPT_ENTRY: "CACHE_CORRUPT_ENTRY",
47
- CACHE_INIT_FAILED: "CACHE_INIT_FAILED",
48
- CACHE_WRITE_FAILED: "CACHE_WRITE_FAILED",
49
- CONFIG_INVALID: "CONFIG_INVALID",
50
- CONFIG_NOT_FOUND: "CONFIG_NOT_FOUND",
51
- FONT_REGISTRATION_FAILED: "FONT_REGISTRATION_FAILED",
52
- INTERNAL_ERROR: "INTERNAL_ERROR",
53
- LOCALE_LOAD_FAILED: "LOCALE_LOAD_FAILED",
54
- PROP_VALIDATION_FAILED: "PROP_VALIDATION_FAILED",
55
- RENDERER_NOT_INITIALIZED: "RENDERER_NOT_INITIALIZED",
56
- RENDER_FAILED: "RENDER_FAILED",
57
- SSE_ERROR: "SSE_ERROR",
58
- TEMPLATE_INVALID_EXPORT: "TEMPLATE_INVALID_EXPORT",
59
- TEMPLATE_LOAD_FAILED: "TEMPLATE_LOAD_FAILED",
60
- TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
61
- UI_RENDER_ERROR: "UI_RENDER_ERROR"
62
- };
63
- var AppError = class extends Error {
64
- code;
65
- context;
66
- constructor(code, message, context) {
67
- super(message);
68
- this.name = "AppError";
69
- this.code = code;
70
- this.context = context ?? {};
71
- }
72
- };
73
- const timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
74
- const stringifyContext = (context) => {
75
- if (!context || Object.keys(context).length === 0) return "";
76
- return ` ${JSON.stringify(context)}`;
77
- };
78
- const logDebug = (message, context) => {
79
- console.log(`[${timestamp()}] DEBUG ${message}${stringifyContext(context)}`);
80
- };
81
- const logWarn = (message, context) => {
82
- console.warn(`[${timestamp()}] WARN ${message}${stringifyContext(context)}`);
83
- };
84
- const logError = (code, message, context) => {
85
- console.error(`[${timestamp()}] ERROR [${code}] ${message}${stringifyContext(context)}`);
86
- };
87
- const createCompileCacheKey = (input) => createHash("sha256").update(`${input.templateId}\u0000${input.templateContentHash}\u0000${input.propsJSON}`).digest("hex").slice(0, 16);
88
- const createPixelCacheKey = (input) => createHash("sha256").update(`${input.templateContentHash}\u0000${input.propsJSON}\u0000${input.width}x${input.height}\u0000${input.format ?? "png"}`).digest("hex").slice(0, 32);
89
- /**
90
- * CompileCache: in-memory compile-result cache.
91
- *
92
- * Process-local only, never persisted, never shared across processes.
93
- * Cheap to recompute, so this uses a simple LRU eviction policy: the
94
- * `get()` method promotes a hit to most-recently-used position before
95
- * returning, and eviction removes the least-recently-used entry when
96
- * capacity is exceeded.
97
- */
98
- const DEFAULT_COMPILED_MAX = 100;
99
- var CompileCache = class {
100
- maxEntries;
101
- cache = /* @__PURE__ */ new Map();
102
- order = [];
103
- contentHashIndex = /* @__PURE__ */ new Map();
104
- constructor(maxEntries = DEFAULT_COMPILED_MAX) {
105
- this.maxEntries = maxEntries;
106
- }
107
- get size() {
108
- return this.cache.size;
109
- }
110
- get(key) {
111
- if (this.cache.has(key)) {
112
- const idx = this.order.indexOf(key);
113
- if (idx !== -1) {
114
- this.order.splice(idx, 1);
115
- this.order.push(key);
116
- }
117
- return this.cache.get(key);
118
- }
119
- return null;
120
- }
121
- set(key, value) {
122
- if (!this.cache.has(key)) this.order.push(key);
123
- this.cache.set(key, value);
124
- while (this.order.length > this.maxEntries) {
125
- const oldest = this.order.shift();
126
- if (oldest !== void 0) this.cache.delete(oldest);
127
- }
128
- }
129
- /**
130
- * Use this instead of `set` when the entry should be invalidatable by
131
- * template content hash later (the normal case for template compile
132
- * results).
133
- */
134
- setWithContentHash(key, contentHash, value) {
135
- this.set(key, value);
136
- let set = this.contentHashIndex.get(contentHash);
137
- if (!set) {
138
- set = /* @__PURE__ */ new Set();
139
- this.contentHashIndex.set(contentHash, set);
140
- }
141
- set.add(key);
142
- }
143
- /**
144
- * Invalidate entries previously written via `setWithContentHash` for a
145
- * given content hash. Uses the explicit index built at write time, not
146
- * a hash-prefix guess against the cache key itself (the key is a
147
- * one-way hash, it cannot be pattern-matched against its own input).
148
- */
149
- invalidateByContentHash(contentHash) {
150
- const keys = this.contentHashIndex.get(contentHash);
151
- if (!keys) return;
152
- for (const key of keys) {
153
- this.cache.delete(key);
154
- const idx = this.order.indexOf(key);
155
- if (idx !== -1) this.order.splice(idx, 1);
156
- }
157
- this.contentHashIndex.delete(contentHash);
158
- }
159
- clear() {
160
- this.cache.clear();
161
- this.order.length = 0;
162
- this.contentHashIndex.clear();
163
- }
164
- };
165
- /**
166
- * Bounds how many concurrent operations (writes/reads through FsStore)
167
- * run at once. Exists to cap the "N concurrent large-buffer renders spike
168
- * RSS" risk at the layer that actually does the I/O, rather than trusting
169
- * every future caller (CLI batch executor, dev-server, MCP) to remember
170
- * to throttle itself.
171
- *
172
- * Deliberately a thin wrapper, not a reimplementation -- p-limit is the
173
- * de facto standard promise concurrency limiter in the Node/Bun
174
- * ecosystem, small and heavily exercised. No maxConcurrent (or a
175
- * non-positive value) means unbounded, matching the cache's behavior
176
- * before this existed.
177
- */
178
- var ConcurrencyLimiter = class {
179
- limit;
180
- constructor(maxConcurrent) {
181
- this.limit = maxConcurrent && maxConcurrent > 0 ? pLimit(maxConcurrent) : null;
182
- }
183
- run(fn) {
184
- if (!this.limit) return fn();
185
- return this.limit(fn);
186
- }
187
- get activeCount() {
188
- return this.limit?.activeCount ?? 0;
189
- }
190
- get pendingCount() {
191
- return this.limit?.pendingCount ?? 0;
192
- }
193
- get isBounded() {
194
- return this.limit !== null;
195
- }
196
- };
197
- const DEFAULT_DEBOUNCE_MS = 250;
198
- /**
199
- * Coordinates eviction across both time (debouncing) and processes
200
- * (advisory locking), replacing two things from the old monolithic
201
- * RenderCache:
202
- *
203
- * 1. Eviction ran synchronously inside every `setPixels` call, doing a
204
- * full-table SUM and (once over budget) a full-table ORDER BY on
205
- * every single write. Here, a write just calls `requestEviction()`,
206
- * which schedules a debounced check -- the expensive scan runs at
207
- * most once per debounce window, not once per write.
208
- *
209
- * 2. Multi-process safety was implicit ("WAL mode makes concurrent DB
210
- * access safe"), which said nothing about the filesystem-level TOCTOU
211
- * race in delete. `proper-lockfile` wraps the actual sweep in an
212
- * advisory lock so only one process ever runs a sweep at a time --
213
- * the delete race is eliminated by construction (only one actor ever
214
- * deletes at once) rather than caught after the fact.
215
- */
216
- var EvictionCoordinator = class {
217
- lockTargetPath;
218
- maxSizeBytes;
219
- debounceMs;
220
- getCurrentTotalBytes;
221
- sweepFn;
222
- debounceTimer = null;
223
- constructor(options) {
224
- this.lockTargetPath = options.lockTargetPath;
225
- this.maxSizeBytes = options.maxSizeBytes;
226
- this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
227
- this.getCurrentTotalBytes = options.getCurrentTotalBytes;
228
- this.sweepFn = options.sweep;
229
- }
230
- /**
231
- * Call after every write. Schedules (or no-ops if one is already
232
- * scheduled) a debounced budget check -- callers never pay the full
233
- * scan cost inline.
234
- */
235
- requestEviction() {
236
- if (this.debounceTimer) return;
237
- this.debounceTimer = setTimeout(async () => {
238
- this.debounceTimer = null;
239
- try {
240
- await this.runIfOverBudget();
241
- } catch (error) {
242
- logWarn("EvictionCoordinator: debounced sweep failed", { error: String(error) });
243
- }
244
- }, this.debounceMs);
245
- this.debounceTimer.unref?.();
246
- }
247
- /** Checks the current total against budget and runs a partial sweep only if over. */
248
- async runIfOverBudget() {
249
- if (await this.getCurrentTotalBytes() <= this.maxSizeBytes) return { freedBytes: 0 };
250
- return this.runSweep({ full: false });
251
- }
252
- /** Forces a full wipe (backs the public `clean()` API), still funneled through the same lock so it can't race another process's sweep. */
253
- runFullSweep() {
254
- return this.runSweep({ full: true });
255
- }
256
- async runSweep(options) {
257
- let release = null;
258
- try {
259
- release = await lockfile.lock(this.lockTargetPath, {
260
- retries: 0,
261
- stale: 1e4
262
- });
263
- } catch (error) {
264
- logDebug("EvictionCoordinator: lock held elsewhere, skipping this pass", { error: String(error) });
265
- return { freedBytes: 0 };
266
- }
267
- try {
268
- return await this.sweepFn(options);
269
- } finally {
270
- await release();
271
- }
272
- }
273
- /** Cancels any pending debounced sweep. Call on shutdown so a timer doesn't fire after the owning cache has closed. */
274
- cancelPending() {
275
- if (this.debounceTimer) {
276
- clearTimeout(this.debounceTimer);
277
- this.debounceTimer = null;
278
- }
279
- }
280
- };
281
- const defaultFsPrimitives = {
282
- existsSync,
283
- readFile,
284
- stat: (filePath) => stat(filePath),
285
- unlink,
286
- writeFileAtomic: (filePath, data) => realWriteFileAtomic(filePath, data)
287
- };
288
- /**
289
- * Filesystem I/O for pixel cache entries.
290
- *
291
- * Replaces two things from the old monolithic RenderCache:
292
- *
293
- * 1. The hand-rolled tmp-file-then-rename with a copy+unlink fallback on
294
- * cross-device rename (EXDEV). That fallback was NOT atomic -- a
295
- * concurrent reader could observe a partially-copied file at the final
296
- * path. `write-file-atomic` (maintained by the npm CLI team, who hit
297
- * this exact problem with npm's own package cache) handles the
298
- * cross-device case correctly.
299
- *
300
- * 2. The size-only integrity check on read (comparing `stat().size`
301
- * against a DB column). A same-size bit-flip was silently served as
302
- * valid. `Bun.hash()` gives us a cheap digest to verify content, not
303
- * just length, on every read.
304
- *
305
- * This class knows nothing about SQLite or eviction policy -- it only
306
- * knows how to put bytes on disk safely and get verified bytes back.
307
- */
308
- var FsStore = class {
309
- limiter;
310
- fs;
311
- constructor(limiter, fs = defaultFsPrimitives) {
312
- this.limiter = limiter;
313
- this.fs = fs;
314
- }
315
- /**
316
- * Non-cryptographic content hash, used purely for corruption detection
317
- * within a cache whose entries are always regenerable -- not for
318
- * anything requiring cross-version stability or resistance to
319
- * intentional tampering. Bun.hash()'s algorithm is not a documented,
320
- * version-stable guarantee; if a future Bun upgrade changes it, the
321
- * worst case is a one-time false "corrupted" verdict that costs a
322
- * re-render, not data loss.
323
- */
324
- computeHash(bytes) {
325
- return Bun.hash(bytes).toString(16);
326
- }
327
- /**
328
- * Writes `bytes` to `filePath` atomically: either the write is fully
329
- * visible at `filePath` or `filePath` doesn't change at all. No caller
330
- * ever observes a partial file at the final path, including across a
331
- * cross-device fallback.
332
- */
333
- async writeAtomic(filePath, bytes) {
334
- const doWrite = () => this.fs.writeFileAtomic(filePath, bytes);
335
- await (this.limiter ? this.limiter.run(doWrite) : doWrite());
336
- }
337
- /**
338
- * Reads `filePath` and verifies both size and content hash against the
339
- * expected values before returning it. Returns `null` (and does NOT
340
- * throw) on any mismatch or read failure -- callers are expected to
341
- * treat `null` as "entry is gone/invalid, self-heal by forgetting it,"
342
- * matching the old cache's missing-file behavior, now extended to
343
- * cover content corruption too instead of only missing/truncated files.
344
- */
345
- readVerified(filePath, expectedSizeBytes, expectedContentHash) {
346
- const doRead = () => this.readVerifiedUnbounded(filePath, expectedSizeBytes, expectedContentHash);
347
- return this.limiter ? this.limiter.run(doRead) : doRead();
348
- }
349
- async readVerifiedUnbounded(filePath, expectedSizeBytes, expectedContentHash) {
350
- if (!this.fs.existsSync(filePath)) {
351
- logDebug("FsStore: cached file missing on read", { filePath });
352
- return null;
353
- }
354
- const fileStat = await this.fs.stat(filePath);
355
- if (fileStat.size !== expectedSizeBytes) {
356
- logWarn("FsStore: size mismatch on read, treating as corrupt", {
357
- expected: expectedSizeBytes,
358
- filePath,
359
- onDisk: fileStat.size
360
- });
361
- return null;
362
- }
363
- const bytes = await this.fs.readFile(filePath);
364
- if (this.computeHash(bytes) !== expectedContentHash) {
365
- logWarn("FsStore: content-hash mismatch on read, treating as corrupt", {
366
- expected: expectedContentHash,
367
- filePath
368
- });
369
- return null;
370
- }
371
- return bytes;
372
- }
373
- /**
374
- * Removes a cache entry's file, if present. Returns the number of
375
- * bytes actually freed. Tolerant of the file already being gone (a
376
- * concurrent process may have removed it first) -- this is the TOCTOU
377
- * gap from the old implementation, still possible here since checking
378
- * existence and then deleting is inherently two steps, but now
379
- * contained to one small, well-tested method instead of being
380
- * duplicated across the eviction and clean code paths.
381
- */
382
- async delete(filePath, knownSizeBytes) {
383
- if (!this.fs.existsSync(filePath)) return 0;
384
- let freed = knownSizeBytes ?? 0;
385
- try {
386
- if (knownSizeBytes === void 0) freed = (await this.fs.stat(filePath)).size;
387
- await this.fs.unlink(filePath);
388
- } catch (error) {
389
- logWarn("FsStore: failed to delete cache entry file", {
390
- error: String(error),
391
- filePath
392
- });
393
- return freed;
394
- }
395
- return freed;
396
- }
397
- };
398
- const BUSY_TIMEOUT_MS = 5e3;
399
- const toRow = (r) => ({
400
- contentHash: r.content_hash,
401
- createdAt: r.created_at,
402
- fileName: r.file_name,
403
- hash: r.hash,
404
- height: r.height,
405
- lastAccessedAt: r.last_accessed_at,
406
- sizeBytes: r.size_bytes,
407
- width: r.width
408
- });
409
- /**
410
- * SQLite-backed metadata store for pixel cache entries.
411
- *
412
- * Two things changed from the pre-split RenderCache:
413
- *
414
- * 1. Queries go through Kysely instead of hand-written SQL strings with
415
- * positional parameter arrays (`db.run("...", [key, fileName, ...])`)
416
- * -- a schema change that adds a column no longer risks a call site's
417
- * array silently drifting out of sync; it's a compile error instead.
418
- *
419
- * 2. Total size/entry count are maintained by SQLite triggers into a
420
- * single-row `cache_totals` table, updated in the same transaction as
421
- * every insert/update/delete. Reading the current totals is an O(1)
422
- * primary-key lookup instead of `SELECT SUM(size_bytes)` /
423
- * `SELECT COUNT(*)` scanning the whole table on every write.
424
- */
425
- var MetadataStore = class {
426
- cacheDir;
427
- dbPath;
428
- rawDb;
429
- db;
430
- initialized = false;
431
- constructor(cacheDir) {
432
- this.cacheDir = cacheDir;
433
- this.dbPath = cacheDir === ":memory:" ? ":memory:" : path.join(cacheDir, "cache.sqlite");
434
- }
435
- async init() {
436
- if (this.initialized) return;
437
- if (this.cacheDir !== ":memory:" && !existsSync(this.cacheDir)) await mkdir(this.cacheDir, { recursive: true });
438
- const rawDb = new Database(this.dbPath, {
439
- create: true,
440
- readwrite: true
441
- });
442
- rawDb.run("PRAGMA journal_mode = WAL");
443
- rawDb.run("PRAGMA synchronous = NORMAL");
444
- rawDb.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
445
- rawDb.run("PRAGMA wal_checkpoint(TRUNCATE)");
446
- let dbHealthy = true;
447
- try {
448
- const result = rawDb.query("PRAGMA integrity_check").get();
449
- if (result?.integrity_check !== "ok") {
450
- dbHealthy = false;
451
- logWarn("SQLite integrity check failed, recreating cache database", { result: result?.integrity_check ?? "null" });
452
- }
453
- } catch {
454
- dbHealthy = false;
455
- logWarn("SQLite integrity check threw, recreating cache database");
456
- }
457
- if (dbHealthy) this.rawDb = rawDb;
458
- else {
459
- rawDb.close();
460
- this.deleteDatabaseFiles();
461
- const freshDb = new Database(this.dbPath, {
462
- create: true,
463
- readwrite: true
464
- });
465
- freshDb.run("PRAGMA journal_mode = WAL");
466
- freshDb.run("PRAGMA synchronous = NORMAL");
467
- freshDb.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
468
- this.rawDb = freshDb;
469
- }
470
- this.db = new Kysely({ dialect: new BunSqliteDialect({ database: this.rawDb }) });
471
- await this.createSchema();
472
- this.initialized = true;
473
- }
474
- async createSchema() {
475
- const db = this.requireDb();
476
- await db.schema.createTable("pixel_cache_entries").ifNotExists().addColumn("hash", "text", (c) => c.primaryKey()).addColumn("file_name", "text", (c) => c.notNull()).addColumn("created_at", "integer", (c) => c.notNull()).addColumn("last_accessed_at", "integer", (c) => c.notNull()).addColumn("size_bytes", "integer", (c) => c.notNull()).addColumn("width", "integer", (c) => c.notNull()).addColumn("height", "integer", (c) => c.notNull()).addColumn("content_hash", "text", (c) => c.notNull()).execute();
477
- await db.schema.createIndex("idx_last_accessed").ifNotExists().on("pixel_cache_entries").column("last_accessed_at").execute();
478
- await db.schema.createTable("cache_totals").ifNotExists().addColumn("id", "integer", (c) => c.primaryKey()).addColumn("total_size_bytes", "integer", (c) => c.notNull().defaultTo(0)).addColumn("entry_count", "integer", (c) => c.notNull().defaultTo(0)).execute();
479
- await sql`INSERT OR IGNORE INTO cache_totals (id, total_size_bytes, entry_count) VALUES (1, 0, 0)`.execute(db);
480
- await sql`
481
- CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_insert
482
- AFTER INSERT ON pixel_cache_entries
483
- BEGIN
484
- UPDATE cache_totals
485
- SET total_size_bytes = total_size_bytes + NEW.size_bytes,
486
- entry_count = entry_count + 1
487
- WHERE id = 1;
488
- END;
489
- `.execute(db);
490
- await sql`
491
- CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_delete
492
- AFTER DELETE ON pixel_cache_entries
493
- BEGIN
494
- UPDATE cache_totals
495
- SET total_size_bytes = total_size_bytes - OLD.size_bytes,
496
- entry_count = entry_count - 1
497
- WHERE id = 1;
498
- END;
499
- `.execute(db);
500
- await sql`
501
- CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_update_size
502
- AFTER UPDATE OF size_bytes ON pixel_cache_entries
503
- WHEN OLD.size_bytes != NEW.size_bytes
504
- BEGIN
505
- UPDATE cache_totals
506
- SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes
507
- WHERE id = 1;
508
- END;
509
- `.execute(db);
510
- }
511
- close() {
512
- if (this.rawDb) {
513
- this.rawDb.run("PRAGMA wal_checkpoint(TRUNCATE)");
514
- this.rawDb.close();
515
- }
516
- this.initialized = false;
517
- }
518
- deleteDatabaseFiles() {
519
- if (this.dbPath === ":memory:") return;
520
- for (const suffix of [
521
- "",
522
- "-wal",
523
- "-shm"
524
- ]) try {
525
- unlinkSync(`${this.dbPath}${suffix}`);
526
- } catch {}
527
- }
528
- /** Insert a new entry, or update it in place (same behavior as the old ON CONFLICT DO UPDATE) if the hash already exists -- e.g. a re-render at a different size for the same key. */
529
- async upsertEntry(input) {
530
- const db = this.requireDb();
531
- const now = Date.now();
532
- await db.insertInto("pixel_cache_entries").values({
533
- content_hash: input.contentHash,
534
- created_at: now,
535
- file_name: input.fileName,
536
- hash: input.hash,
537
- height: input.height,
538
- last_accessed_at: now,
539
- size_bytes: input.sizeBytes,
540
- width: input.width
541
- }).onConflict((oc) => oc.column("hash").doUpdateSet({
542
- content_hash: input.contentHash,
543
- file_name: input.fileName,
544
- height: input.height,
545
- last_accessed_at: now,
546
- size_bytes: input.sizeBytes,
547
- width: input.width
548
- })).execute();
549
- }
550
- async getEntry(hash) {
551
- const row = await this.requireDb().selectFrom("pixel_cache_entries").selectAll().where("hash", "=", hash).executeTakeFirst();
552
- return row ? toRow(row) : null;
553
- }
554
- async touchAccess(hash, timestamp = Date.now()) {
555
- await this.requireDb().updateTable("pixel_cache_entries").set({ last_accessed_at: timestamp }).where("hash", "=", hash).execute();
556
- }
557
- async deleteEntry(hash) {
558
- await this.requireDb().deleteFrom("pixel_cache_entries").where("hash", "=", hash).execute();
559
- }
560
- async deleteAll() {
561
- await this.requireDb().deleteFrom("pixel_cache_entries").execute();
562
- }
563
- async listAllOrderedByLastAccessed() {
564
- return (await this.requireDb().selectFrom("pixel_cache_entries").selectAll().orderBy("last_accessed_at", "asc").execute()).map(toRow);
565
- }
566
- /** O(1): reads the trigger-maintained single-row counter instead of scanning the table. */
567
- async getTotals() {
568
- const row = await this.requireDb().selectFrom("cache_totals").select(["total_size_bytes", "entry_count"]).where("id", "=", 1).executeTakeFirstOrThrow();
569
- return {
570
- entryCount: row.entry_count,
571
- totalSizeBytes: row.total_size_bytes
572
- };
573
- }
574
- requireDb() {
575
- if (!this.db) throw new Error("MetadataStore.init() must be called before use.");
576
- return this.db;
577
- }
578
- };
579
- const DEFAULT_MAX_SIZE_MB = 500;
580
- /**
581
- * Content-addressed two-stage render cache -- coordinator.
582
- *
583
- * This class used to own SQLite calls, filesystem calls, atomicity
584
- * handling, and eviction policy directly (~400 lines, five
585
- * responsibilities). It now owns none of that: it constructs and wires
586
- * together CompileCache, MetadataStore, FsStore, and EvictionCoordinator,
587
- * and exposes the exact same public method signatures CLI, MCP, and
588
- * dev-server already depend on. No downstream consumer needs to change
589
- * anything as a result of this refactor.
590
- */
591
- var RenderCache = class {
592
- cacheDir;
593
- maxSizeBytes;
594
- compiled;
595
- maxConcurrentWrites;
596
- evictionDebounceMs;
597
- metadata;
598
- fsStore;
599
- eviction;
600
- initialized = false;
601
- static createCompileCacheKey = createCompileCacheKey;
602
- static createPixelCacheKey = createPixelCacheKey;
603
- constructor(options) {
604
- this.cacheDir = options?.cacheDir ?? ".studio-cache";
605
- this.maxSizeBytes = (options?.maxSizeMB ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
606
- this.compiled = new CompileCache(options?.compiledMaxEntries);
607
- this.maxConcurrentWrites = options?.maxConcurrentWrites;
608
- this.evictionDebounceMs = options?.evictionDebounceMs;
609
- }
610
- async init() {
611
- if (this.initialized) return;
612
- if (!existsSync(this.cacheDir)) await mkdir(this.cacheDir, { recursive: true });
613
- this.metadata = new MetadataStore(this.cacheDir);
614
- await this.metadata.init();
615
- const limiter = new ConcurrencyLimiter(this.maxConcurrentWrites);
616
- this.fsStore = new FsStore(limiter);
617
- this.eviction = new EvictionCoordinator({
618
- debounceMs: this.evictionDebounceMs,
619
- getCurrentTotalBytes: async () => {
620
- return (await this.requireMetadata().getTotals()).totalSizeBytes;
621
- },
622
- lockTargetPath: this.cacheDir,
623
- maxSizeBytes: this.maxSizeBytes,
624
- sweep: (options) => this.performSweep(options)
625
- });
626
- this.initialized = true;
627
- }
628
- close() {
629
- this.eviction?.cancelPending();
630
- this.metadata?.close();
631
- this.initialized = false;
632
- }
633
- getCompiled(key) {
634
- return this.compiled.get(key);
635
- }
636
- setCompiled(key, value) {
637
- this.compiled.set(key, value);
638
- }
639
- setCompiledWithContentHash(key, contentHash, value) {
640
- this.compiled.setWithContentHash(key, contentHash, value);
641
- }
642
- invalidateCompiledByContentHash(contentHash) {
643
- this.compiled.invalidateByContentHash(contentHash);
644
- }
645
- clearCompiled() {
646
- this.compiled.clear();
647
- }
648
- async getPixels(key) {
649
- const metadata = this.requireMetadata();
650
- const fsStore = this.requireFsStore();
651
- let row;
652
- try {
653
- row = await metadata.getEntry(key);
654
- } catch (error) {
655
- logWarn("Pixel metadata read failed, attempting recovery", {
656
- error: error instanceof Error ? error.message : String(error),
657
- key
658
- });
659
- await this.recoverMetadata();
660
- return null;
661
- }
662
- if (!row) return null;
663
- const filePath = path.join(this.cacheDir, row.fileName);
664
- const bytes = await fsStore.readVerified(filePath, row.sizeBytes, row.contentHash);
665
- if (bytes === null) {
666
- try {
667
- await metadata.deleteEntry(key);
668
- } catch {}
669
- return null;
670
- }
671
- try {
672
- await metadata.touchAccess(key);
673
- } catch (error) {
674
- logWarn("Pixel touchAccess failed, attempting recovery", {
675
- error: error instanceof Error ? error.message : String(error),
676
- key
677
- });
678
- await this.recoverMetadata();
679
- }
680
- return bytes;
681
- }
682
- async setPixels(key, bytes, width, height, format = "png") {
683
- const metadata = this.requireMetadata();
684
- const fsStore = this.requireFsStore();
685
- const fileName = `${key}.${format}`;
686
- const filePath = path.join(this.cacheDir, fileName);
687
- const contentHash = fsStore.computeHash(bytes);
688
- await fsStore.writeAtomic(filePath, bytes);
689
- await metadata.upsertEntry({
690
- contentHash,
691
- fileName,
692
- hash: key,
693
- height,
694
- sizeBytes: bytes.length,
695
- width
696
- });
697
- this.requireEviction().requestEviction();
698
- return filePath;
699
- }
700
- async stats() {
701
- const totals = await this.requireMetadata().getTotals();
702
- return {
703
- cacheDir: this.cacheDir,
704
- compiled: { entries: this.compiled.size },
705
- pixels: {
706
- entries: totals.entryCount,
707
- maxSizeBytes: this.maxSizeBytes,
708
- sizeBytes: totals.totalSizeBytes
709
- }
710
- };
711
- }
712
- /** Full wipe -- unlike a budget-triggered sweep, always removes everything, still lock-coordinated. */
713
- clean() {
714
- this.compiled.clear();
715
- return this.requireEviction().runFullSweep();
716
- }
717
- async performSweep(options) {
718
- const metadata = this.requireMetadata();
719
- const fsStore = this.requireFsStore();
720
- if (options.full) {
721
- const rows = await metadata.listAllOrderedByLastAccessed();
722
- const freedAmounts = await Promise.all(rows.map((row) => fsStore.delete(path.join(this.cacheDir, row.fileName), row.sizeBytes)));
723
- await metadata.deleteAll();
724
- return { freedBytes: freedAmounts.reduce((sum, n) => sum + n, 0) };
725
- }
726
- const totals = await metadata.getTotals();
727
- if (totals.totalSizeBytes <= this.maxSizeBytes) return { freedBytes: 0 };
728
- const rows = await metadata.listAllOrderedByLastAccessed();
729
- let remaining = totals.totalSizeBytes;
730
- const rowsToEvict = [];
731
- for (const row of rows) {
732
- if (remaining <= this.maxSizeBytes) break;
733
- rowsToEvict.push(row);
734
- remaining -= row.sizeBytes;
735
- }
736
- return { freedBytes: (await Promise.all(rowsToEvict.map(async (row) => {
737
- const freedForRow = await fsStore.delete(path.join(this.cacheDir, row.fileName), row.sizeBytes);
738
- await metadata.deleteEntry(row.hash);
739
- return freedForRow;
740
- }))).reduce((sum, n) => sum + n, 0) };
741
- }
742
- requireMetadata() {
743
- if (!this.metadata) throw new Error("RenderCache.init() must be called before use.");
744
- return this.metadata;
745
- }
746
- async recoverMetadata() {
747
- this.metadata?.close();
748
- this.metadata = new MetadataStore(this.cacheDir);
749
- await this.metadata.init();
750
- }
751
- requireFsStore() {
752
- if (!this.fsStore) throw new Error("RenderCache.init() must be called before use.");
753
- return this.fsStore;
754
- }
755
- requireEviction() {
756
- if (!this.eviction) throw new Error("RenderCache.init() must be called before use.");
757
- return this.eviction;
758
- }
759
- };
760
- /**
761
- * Type-only shim for `@takumi-rs/core` (as re-exported by `takumi-js/node`).
762
- *
763
- * Migrated for Takumi v2. The v1 shim existed to work around a broken
764
- * `export.d.ts` re-export chain in @takumi-rs/core@1.8.6 that hid
765
- * `render`/`measure` and several option types from consumers on
766
- * `moduleResolution: "nodenext"`. This file re-declares the v2 surface we
767
- * actually use, backed by the real runtime export.
768
- *
769
- * TODO(verify-on-upgrade): the v1 packaging bug this shim worked around
770
- * has not been independently re-verified against the v2 release actually
771
- * installed. Once upgraded, run the same isolated-probe check described
772
- * in the v1 version of this file (import the types directly from
773
- * "takumi-js/node" in a throwaway file and see if `render`/`measure`/
774
- * `registerFont` resolve). If they do, delete this file and switch all
775
- * importers back to `takumi-js/node` directly. Tracked against the
776
- * upstream repo: https://github.com/kane50613/takumi
777
- *
778
- * v2 breaking changes reflected below (see /docs/upgrade/v2):
779
- * - `new Renderer(options)` -> `new Renderer()`. The constructor takes no
780
- * arguments; fonts and images are now per-render options instead of
781
- * construction-time state.
782
- * - `loadFont` / `loadFonts` / `loadFontSync` -> `registerFont`. Use this
783
- * only to preload a font once and reuse it across many renders; most
784
- * callers should just pass `fonts` on the render/measure call instead.
785
- * - The persistent image store and `GlobalContext` are gone.
786
- * `putPersistentImage` / `clearImageStore` no longer exist. Every image
787
- * a render needs must be passed through `images`, keyed by `src`.
788
- * - `fetchedResources` -> `images`. `images` also accepts a group form
789
- * (`{ sources, fetch, fetchCache }`) that combines pre-fetched entries,
790
- * a custom fetch implementation, and a shared byte cache.
791
- * - `format` stays a string, but `quality` (jpeg / lossy webp) and
792
- * `lossless` (webp) are now separate optional fields instead of one
793
- * combined quality argument.
794
- * - `fonts` entries may be bare URL strings, fetched and cached on demand,
795
- * in addition to loaded font descriptors / raw bytes.
796
- */
797
- const Renderer$1 = Renderer;
798
- /**
799
- * Renderer lifecycle -- v2.
800
- *
801
- * v1 kept a single Renderer instance and rebuilt it whenever
802
- * font/persistent-image config changed, because fonts and images were
803
- * construction-time state (`new Renderer({ fonts, persistentImages })`).
804
- * That reconfigure-on-drift mechanism required every caller to pass the
805
- * current config on every call so drift could be detected -- and in the
806
- * shipped v1 code, `renderToPixels`/`measureTemplate` never actually did
807
- * that (both called `getRenderer()` with no arguments), so config changes
808
- * silently never took effect.
809
- *
810
- * v2 removes the problem at the source: `new Renderer()` takes no
811
- * arguments at all. Fonts are now a per-render option
812
- * (`render(node, { fonts })`), so there is nothing renderer-construction-
813
- * time to drift out of sync with `studio.config.ts` in the first place.
814
- * The singleton below exists purely to reuse the renderer's internal font
815
- * cache/thread pool across calls, per Takumi's own performance guidance --
816
- * it carries no configuration state to go stale.
817
- */
818
- let activeRenderer = null;
819
- const __getRendererForTesting = () => {
820
- if (!activeRenderer) activeRenderer = new Renderer$1();
821
- return activeRenderer;
822
- };
823
- /**
824
- * Renders a compiled template to pixels.
825
- *
826
- * `width`/`height` are REQUIRED here, not optional pass-through fields.
827
- * Takumi's `render()` returns only a Buffer -- it does not report back the
828
- * dimensions it used, so auto-sized output isn't supported by this
829
- * wrapper. Callers that need auto-sizing must call `measureTemplate` first
830
- * and pass the resulting width/height through explicitly (see
831
- * `renderAutoSized` below).
832
- *
833
- * Any `fonts`/`fontFamilies`/`images` the template needs must be supplied
834
- * via `options` -- there is no construction-time renderer config to fall
835
- * back on in v2.
836
- */
837
- const renderToPixels = async (template, dimensions, options, signal) => {
838
- const renderer = __getRendererForTesting();
839
- logDebug("renderer.render start", {
840
- fontFamilies: options?.fontFamilies,
841
- fonts: options?.fonts?.filter((f) => typeof f === "object" && "name" in f).map((f) => ({
842
- dataType: typeof f.data,
843
- name: f.name
844
- })),
845
- fontsCount: options?.fonts?.length ?? 0,
846
- lang: options?.lang
847
- });
848
- return {
849
- bytes: await renderer.render(template.node, {
850
- ...options,
851
- height: dimensions.height,
852
- stylesheets: [...options?.stylesheets ?? [], ...template.stylesheets],
853
- width: dimensions.width
854
- }, signal),
855
- format: options?.format ?? "png",
856
- height: dimensions.height,
857
- width: dimensions.width
858
- };
859
- };
860
- const measureTemplate = (template, options, signal) => {
861
- return __getRendererForTesting().measure(template.node, {
862
- ...options,
863
- stylesheets: [...options?.stylesheets ?? [], ...template.stylesheets]
864
- }, signal);
865
- };
866
- const mergeLocale = (props, localeData) => {
867
- if (!localeData) return props;
868
- const merged = { ...props };
869
- for (const [key, value] of Object.entries(localeData)) if (Array.isArray(value) && Array.isArray(merged[key])) merged[key] = value;
870
- else if (typeof value === "string" && typeof merged[key] === "string") merged[key] = value;
871
- return merged;
872
- };
873
- /**
874
- * Expands rows × locales × presets into a flat array of matrix cells.
875
- * Locale data is merged into props during expansion so each cell carries
876
- * fully resolved props.
877
- */
878
- const expandMatrix = (input) => {
879
- const { rows, locales, presets, baseProps = {} } = input;
880
- if (rows.length === 0) return [];
881
- const cells = [];
882
- for (const [rowIndex, row] of rows.entries()) {
883
- const rowKey = String(row.key ?? row.id ?? row.slug ?? String(rowIndex));
884
- const rowProps = {
885
- ...baseProps,
886
- ...row
887
- };
888
- delete rowProps.key;
889
- delete rowProps.id;
890
- delete rowProps.slug;
891
- for (const locale of locales) {
892
- const localeProps = mergeLocale(rowProps, locale.data);
893
- for (const preset of presets) cells.push({
894
- locale: locale.id,
895
- preset: {
896
- height: preset.height,
897
- id: preset.id,
898
- width: preset.width
899
- },
900
- props: {
901
- ...localeProps,
902
- locale: locale.id
903
- },
904
- rowIndex,
905
- rowKey
906
- });
907
- }
908
- }
909
- return cells;
910
- };
911
- const ensureDir = async (dir) => {
912
- if (!existsSync(dir)) await mkdir(dir, { recursive: true });
913
- };
914
- const tryWriteFile = async (filePath, data) => {
915
- try {
916
- await writeFile(filePath, data);
917
- } catch (error) {
918
- logWarn("Failed to write output file", {
919
- error: String(error),
920
- filePath
921
- });
922
- }
923
- };
924
- const renderOne = async (input) => {
925
- const start = performance.now();
926
- const { compiledTemplate, contentHash, props, width, height, format = "png", locale, cache, outDir, filename, signal, renderOptions } = input;
927
- const propsJSON = JSON.stringify(props);
928
- const pixelKey = RenderCache.createPixelCacheKey({
929
- format,
930
- height,
931
- propsJSON,
932
- templateContentHash: contentHash,
933
- width
934
- });
935
- const cached = await cache.getPixels(pixelKey);
936
- if (cached) {
937
- const outputPath = `${outDir}/${filename}`;
938
- await ensureDir(outDir);
939
- await tryWriteFile(outputPath, cached);
940
- return {
941
- cacheHit: true,
942
- durationMs: performance.now() - start,
943
- outputPath
944
- };
945
- }
946
- const lang = locale?.startsWith("ar") ? "ar" : void 0;
947
- logDebug("renderOne -> renderToPixels", {
948
- fontFamilies: renderOptions?.fontFamilies,
949
- fontNames: renderOptions?.fonts?.map((f) => typeof f === "object" && "name" in f ? f.name : "raw"),
950
- fontsCount: renderOptions?.fonts?.length ?? 0,
951
- lang,
952
- locale
953
- });
954
- const output = await renderToPixels(compiledTemplate, {
955
- height,
956
- width
957
- }, {
958
- fontFamilies: renderOptions?.fontFamilies,
959
- fonts: renderOptions?.fonts,
960
- format,
961
- lang
962
- }, signal);
963
- await cache.setPixels(pixelKey, output.bytes, width, height, format);
964
- const outputPath = `${outDir}/${filename}`;
965
- await ensureDir(outDir);
966
- await tryWriteFile(outputPath, output.bytes);
967
- return {
968
- cacheHit: false,
969
- durationMs: performance.now() - start,
970
- outputPath
971
- };
972
- };
973
- const generateOutputFilename = (options) => {
974
- const { templateId, locale, presetId, rowKey, format = "png" } = options;
975
- return `${templateId}.${locale}.${presetId}.${rowKey}.${format}`;
976
- };
977
- const writeManifest = async (filePath, manifest) => {
978
- await mkdir(path.dirname(filePath), { recursive: true });
979
- await writeFile(filePath, JSON.stringify(manifest, null, 2));
980
- };
981
- const loadConfig = async (rootDir) => {
982
- const configPath = path.resolve(rootDir, "studio.config.ts");
983
- try {
984
- await readFile(configPath, "utf-8");
985
- } catch {
986
- throw new AppError(ErrorCode.CONFIG_NOT_FOUND, `No studio.config.ts found in ${rootDir}.`);
987
- }
988
- logDebug("Loading studio.config.ts", { path: configPath });
989
- const mod = await import(pathToFileURL(configPath).href);
990
- const raw = mod.default ?? mod;
991
- const parsed = studioConfigSchema.safeParse(raw);
992
- if (!parsed.success) {
993
- const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
994
- throw new AppError(ErrorCode.CONFIG_INVALID, `studio.config.ts is invalid: ${issues}`);
995
- }
996
- return parsed.data;
997
- };
998
- const importTemplateForBatch = async (rootDir, componentPath, templateId) => {
999
- const fullPath = path.resolve(rootDir, `${componentPath}.tsx`);
1000
- let content;
1001
- try {
1002
- content = await readFile(fullPath, "utf-8");
1003
- } catch {
1004
- throw new AppError(ErrorCode.TEMPLATE_LOAD_FAILED, `Template file not found: ${fullPath}`, {
1005
- componentPath,
1006
- templateId
1007
- });
1008
- }
1009
- const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
1010
- logDebug("Loading template", {
1011
- path: fullPath,
1012
- templateId
1013
- });
1014
- const mod = await import(pathToFileURL(fullPath).href);
1015
- if (typeof mod.default !== "function") throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT, `Template "${templateId}" must have a default export that is a component function.`, { templateId });
1016
- const { propsSchema } = mod;
1017
- if (!isZodObject(propsSchema)) throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT, `Template "${templateId}" must export a "propsSchema" that is a z.object({...}).`, { templateId });
1018
- return {
1019
- contentHash,
1020
- module: mod
1021
- };
1022
- };
1023
- const parseDataFile = async (filePath) => {
1024
- const content = await readFile(filePath, "utf-8");
1025
- if (filePath.endsWith(".json")) {
1026
- const data = JSON.parse(content);
1027
- if (Array.isArray(data)) return data;
1028
- throw new AppError(ErrorCode.API_ERROR, "JSON data file must contain an array of objects.");
1029
- }
1030
- if (filePath.endsWith(".csv")) {
1031
- const { parse } = await import("csv-parse/sync");
1032
- return parse(content, {
1033
- columns: true,
1034
- skip_empty_lines: true,
1035
- trim: true
1036
- });
1037
- }
1038
- throw new AppError(ErrorCode.API_ERROR, "Data file must be .csv or .json.");
1039
- };
1040
- const resolveLocales = async (rootDir, localeCodes, templateId) => {
1041
- if (localeCodes.length === 0) return [{ id: "default" }];
1042
- return await Promise.all(localeCodes.map(async (code) => {
1043
- const localePath = path.resolve(rootDir, `locales/${code}.json`);
1044
- try {
1045
- const content = await readFile(localePath, "utf-8");
1046
- return {
1047
- data: JSON.parse(content)[templateId],
1048
- id: code
1049
- };
1050
- } catch {
1051
- return { id: code };
1052
- }
1053
- }));
1054
- };
1055
- const isFontDescriptor = (f) => typeof f === "object" && !(f instanceof Uint8Array);
1056
- const resolveArabicFonts = async (_locales) => {
1057
- let fonts = [];
1058
- let fontFamilies = [];
1059
- try {
1060
- fonts = await googleFonts([{
1061
- name: "Noto Sans Arabic",
1062
- weight: [400, 700]
1063
- }]);
1064
- fontFamilies = fonts.filter(isFontDescriptor).map((f) => String(f.name ?? "")).filter(Boolean);
1065
- logDebug("googleFonts resolved", {
1066
- count: fonts.length,
1067
- hasData: fonts.filter(isFontDescriptor).map((f) => typeof f.data),
1068
- names: fonts.filter(isFontDescriptor).map((f) => f.name)
1069
- });
1070
- } catch (error) {
1071
- logDebug("googleFonts failed, using direct URL fallback", { error: String(error) });
1072
- fonts = [{
1073
- data: /* @__PURE__ */ new Uint8Array(0),
1074
- name: "Noto Sans Arabic",
1075
- weight: 400
1076
- }];
1077
- fontFamilies = ["Noto Sans Arabic"];
1078
- }
1079
- return {
1080
- fontFamilies,
1081
- fonts
1082
- };
1083
- };
1084
- const runBatch = async (input) => {
1085
- const start = performance.now();
1086
- const { baseProps = {}, dataFile, failFast = false, cacheDir: cacheDirOverride, locales: filterLocales, outDir: outDirOverride, presets: filterPresets, rootDir, rows: inlineRows, templateId } = input;
1087
- const config = await loadConfig(rootDir);
1088
- const entry = config.templates[templateId];
1089
- if (!entry) throw new Error(`Template not found: ${templateId}`);
1090
- const { contentHash, module } = await importTemplateForBatch(rootDir, entry.componentPath, templateId);
1091
- const Component = module.default;
1092
- const presets = (filterPresets ?? config.presets.map((p) => p.id)).map((id) => {
1093
- const p = config.presets.find((preset) => preset.id === id);
1094
- if (!p) throw new Error(`Preset not found: ${id}`);
1095
- return {
1096
- height: p.height,
1097
- id: p.id,
1098
- width: p.width
1099
- };
1100
- });
1101
- const locales = await resolveLocales(rootDir, filterLocales ?? config.locales ?? [], templateId);
1102
- const cells = expandMatrix({
1103
- baseProps,
1104
- locales,
1105
- presets,
1106
- rows: dataFile ? await parseDataFile(path.resolve(rootDir, dataFile)) : inlineRows ?? []
1107
- });
1108
- const outDir = path.resolve(rootDir, outDirOverride ?? config.outDir ?? "./out");
1109
- const cache = new RenderCache({ cacheDir: cacheDirOverride ?? path.resolve(rootDir, ".studio-cache") });
1110
- await cache.init();
1111
- const arabicLocales = locales.filter((l) => l.id.startsWith("ar"));
1112
- logDebug("Arabic locale detection", {
1113
- arabicCount: arabicLocales.length,
1114
- localeIds: locales.map((l) => l.id)
1115
- });
1116
- let fonts = [];
1117
- let fontFamilies;
1118
- if (arabicLocales.length > 0) {
1119
- const { fonts: resolvedFonts, fontFamilies: resolvedFamilies } = await resolveArabicFonts(locales);
1120
- fonts = resolvedFonts;
1121
- fontFamilies = resolvedFamilies;
1122
- }
1123
- const succeeded = [];
1124
- const failed = [];
1125
- const renderOptions = fonts.length > 0 ? {
1126
- fontFamilies,
1127
- fonts
1128
- } : void 0;
1129
- logDebug("renderOptions before loop", {
1130
- fontFamilies,
1131
- fontsCount: fonts.length,
1132
- hasOptions: renderOptions !== void 0
1133
- });
1134
- try {
1135
- for (const cell of cells) try {
1136
- const result = await renderOne({
1137
- cache,
1138
- compiledTemplate: await compileTemplate(createElement(Component, cell.props)),
1139
- contentHash,
1140
- filename: generateOutputFilename({
1141
- locale: cell.locale,
1142
- presetId: cell.preset.id,
1143
- rowKey: cell.rowKey,
1144
- templateId
1145
- }),
1146
- height: cell.preset.height,
1147
- locale: cell.locale,
1148
- outDir,
1149
- props: cell.props,
1150
- renderOptions,
1151
- templateId,
1152
- width: cell.preset.width
1153
- });
1154
- succeeded.push({
1155
- cacheHit: result.cacheHit,
1156
- locale: cell.locale,
1157
- outputPath: result.outputPath,
1158
- preset: cell.preset.id,
1159
- rowIndex: cell.rowIndex
1160
- });
1161
- } catch (error) {
1162
- failed.push({
1163
- error: error instanceof Error ? error.message : String(error),
1164
- locale: cell.locale,
1165
- preset: cell.preset.id,
1166
- rowIndex: cell.rowIndex
1167
- });
1168
- if (failFast) throw error;
1169
- }
1170
- } finally {
1171
- cache.close();
1172
- }
1173
- const manifest = {
1174
- completedCount: cells.length,
1175
- failed,
1176
- status: failFast && failed.length > 0 ? "aborted" : "completed",
1177
- succeeded,
1178
- totalCount: cells.length
1179
- };
1180
- await writeManifest(`${outDir}/manifest.json`, manifest);
1181
- return {
1182
- elapsedMs: performance.now() - start,
1183
- manifest
1184
- };
1185
- };
1186
- //#endregion
1187
- export { importTemplateForBatch as a, logWarn as c, runBatch as d, studioConfigSchema as f, compileTemplate as i, measureTemplate as l, ErrorCode as n, loadConfig as o, RenderCache as r, logError as s, AppError as t, renderToPixels as u };