@stdout-design/core 0.1.1

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