@stdout-design/core 0.2.5 → 0.2.7

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