@stdout-design/core 0.3.1 → 0.4.0

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.d.mts CHANGED
@@ -1,9 +1,36 @@
1
1
  import { a as validateProps, i as isZodObject, n as PropValidationError, o as zodToJsonSchemaShape, t as PropSchema } from "./validation-MigzRuji.mjs";
2
2
  import { z } from "zod";
3
3
  import { fromJsx } from "takumi-js/helpers/jsx";
4
+ import { prepareImages } from "@takumi-rs/helpers";
4
5
  import { ComponentType } from "react";
6
+ import { EmojiType, extractEmojis } from "@takumi-rs/helpers/emoji";
5
7
  import { Node } from "takumi-js";
8
+ //#region src/assets/image-resolver.d.ts
9
+ interface ImagePolicy {
10
+ /** Return true to permit fetching a URL. Unset denies every URL -- see the module doc comment. */
11
+ allowUrl?: (url: string) => boolean;
12
+ /** Reject a fetched body past this size, by content-length or streamed. @default 32 MiB (the library default) */
13
+ maxBytes?: number;
14
+ /** Abort a fetch after this many milliseconds. @default 5000 (the library default) */
15
+ timeout?: number;
16
+ /** Custom fetch implementation. @default globalThis.fetch */
17
+ fetch?: typeof globalThis.fetch;
18
+ /**
19
+ * Emoji provider. `"from-font"` skips network extraction entirely and
20
+ * relies on a registered COLR font to supply emoji glyphs directly.
21
+ * Unset also skips extraction (emoji render as whatever glyph the
22
+ * fallback font stack provides, typically monochrome or missing).
23
+ */
24
+ emoji?: EmojiType | "from-font";
25
+ }
26
+ //#endregion
6
27
  //#region src/shared/types.d.ts
28
+ interface FontConfig {
29
+ family: string;
30
+ weights?: number[];
31
+ source?: "google" | "local";
32
+ path?: string;
33
+ }
7
34
  interface TemplateEntry {
8
35
  componentPath: string;
9
36
  description?: string;
@@ -22,6 +49,14 @@ interface StudioConfig {
22
49
  locales?: string[];
23
50
  outDir?: string;
24
51
  scaffoldVersion?: string;
52
+ fonts?: Record<string, FontConfig[]>;
53
+ /**
54
+ * Fetch policy for external images/emoji any template's node tree may
55
+ * reference. This is the ONLY place `allowUrl` should be configured --
56
+ * never accept it from request props or template code. Unset denies
57
+ * every URL. See ADR-0018 and `assets/image-resolver.ts`.
58
+ */
59
+ images?: ImagePolicy;
25
60
  }
26
61
  interface TemplateModule {
27
62
  default: (props: Record<string, unknown>) => unknown;
@@ -31,6 +66,22 @@ interface TemplateModule {
31
66
  //#region src/shared/config-schema.d.ts
32
67
  declare const studioConfigSchema: z.ZodObject<{
33
68
  defaultPreset: z.ZodOptional<z.ZodString>;
69
+ fonts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
70
+ family: z.ZodString;
71
+ path: z.ZodOptional<z.ZodString>;
72
+ source: z.ZodOptional<z.ZodEnum<{
73
+ google: "google";
74
+ local: "local";
75
+ }>>;
76
+ weights: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
77
+ }, z.core.$strip>>>>;
78
+ images: z.ZodOptional<z.ZodObject<{
79
+ allowUrl: z.ZodOptional<z.ZodCustom<((url: string) => boolean) | undefined, ((url: string) => boolean) | undefined>>;
80
+ emoji: z.ZodOptional<z.ZodCustom<"from-font" | import("@takumi-rs/helpers/emoji").EmojiType | undefined, "from-font" | import("@takumi-rs/helpers/emoji").EmojiType | undefined>>;
81
+ fetch: z.ZodOptional<z.ZodCustom<typeof fetch | undefined, typeof fetch | undefined>>;
82
+ maxBytes: z.ZodOptional<z.ZodNumber>;
83
+ timeout: z.ZodOptional<z.ZodNumber>;
84
+ }, z.core.$strip>>;
34
85
  locales: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
86
  outDir: z.ZodOptional<z.ZodString>;
36
87
  presets: z.ZodArray<z.ZodObject<{
@@ -142,15 +193,31 @@ interface SweepOutcome {
142
193
  freedBytes: number;
143
194
  }
144
195
  /**
145
- * Content-addressed two-stage render cache -- coordinator.
196
+ * Content-addressed two-stage render cache — coordinator.
146
197
  *
147
- * This class used to own SQLite calls, filesystem calls, atomicity
148
- * handling, and eviction policy directly (~400 lines, five
149
- * responsibilities). It now owns none of that: it constructs and wires
150
- * together CompileCache, MetadataStore, FsStore, and EvictionCoordinator,
151
- * and exposes the exact same public method signatures CLI, MCP, and
152
- * dev-server already depend on. No downstream consumer needs to change
153
- * anything as a result of this refactor.
198
+ * FIX (recoverMetadata mutex): concurrent calls that both hit a SQLite
199
+ * error used to each close and replace this.metadata independently,
200
+ * with the second close() tearing down the store the first recovery just
201
+ * opened. Recovery now serializes behind a single promise so only one
202
+ * recovery runs at a time.
203
+ *
204
+ * FIX (recovery cooldown): recovery was triggered on every touchAccess
205
+ * failure with no rate-limiting. Under sustained SQLite busy conditions
206
+ * this caused a full MetadataStore re-open on every cache hit. Recovery
207
+ * is now skipped if one completed within the last RECOVERY_COOLDOWN_MS.
208
+ *
209
+ * FIX (ENOENT during writeAtomic on a live dev-server): write-file-atomic
210
+ * writes to a temp path in the same directory as the target, then renames.
211
+ * If the cache directory is deleted mid-session the temp-file open fails
212
+ * with ENOENT on a path like "<key>.png.1106080564" — not the final path.
213
+ * The old code recreated the directory and retried, but only caught ENOENT
214
+ * on the final path. Now the directory is always re-ensured before the
215
+ * retry regardless of which path triggered the ENOENT.
216
+ *
217
+ * FIX (orphaned files): the partial sweep was file-delete then row-delete,
218
+ * not atomic. A crash between those two steps left rows with no backing
219
+ * file. The full sweep now also scans the directory for files with no
220
+ * metadata row and removes them.
154
221
  */
155
222
  declare class RenderCache {
156
223
  private readonly cacheDir;
@@ -162,6 +229,8 @@ declare class RenderCache {
162
229
  private fsStore;
163
230
  private eviction;
164
231
  private initialized;
232
+ private recoveryPromise;
233
+ private lastRecoveryAt;
165
234
  static readonly createCompileCacheKey: typeof createCompileCacheKey;
166
235
  static readonly createPixelCacheKey: typeof createPixelCacheKey;
167
236
  constructor(options?: RenderCacheOptions);
@@ -175,11 +244,32 @@ declare class RenderCache {
175
244
  getPixels(key: string): Promise<Buffer | null>;
176
245
  setPixels(key: string, bytes: Buffer, width: number, height: number, format?: string): Promise<string>;
177
246
  stats(): Promise<CacheStats>;
178
- /** Full wipe -- unlike a budget-triggered sweep, always removes everything, still lock-coordinated. */
247
+ /** Full wipe — always removes everything, still lock-coordinated. */
179
248
  clean(): Promise<SweepOutcome>;
180
249
  private performSweep;
181
- private requireMetadata;
250
+ /**
251
+ * scan the cache directory for files not tracked in metadata and
252
+ * delete them. These are orphans from a crash mid-sweep (file written,
253
+ * row not yet inserted) or a partial eviction (file deleted before row).
254
+ *
255
+ * Only removes files that look like cache entries (matching the
256
+ * "<key>.<format>" naming pattern), not metadata files like cache.sqlite.
257
+ */
258
+ private deleteOrphanFiles;
259
+ /**
260
+ * previous implementation had no concurrency guard. Two concurrent
261
+ * callers both hitting a SQLite error would each call close() + init()
262
+ * independently; the second close() would tear down the MetadataStore
263
+ * the first recovery just opened, leaving the cache in a broken state.
264
+ *
265
+ * Now: all concurrent recovery attempts share a single promise. The
266
+ * first caller does the actual work; subsequent ones just await the same
267
+ * promise. A cooldown prevents re-opening on every busy-error hit under
268
+ * sustained load.
269
+ */
182
270
  private recoverMetadata;
271
+ private doRecoverMetadata;
272
+ private requireMetadata;
183
273
  private requireFsStore;
184
274
  private requireEviction;
185
275
  }
@@ -204,18 +294,28 @@ declare class RenderCache {
204
294
  * upstream repo: https://github.com/kane50613/takumi
205
295
  *
206
296
  * v2 breaking changes reflected below (see /docs/upgrade/v2):
207
- * - `new Renderer(options)` -> `new Renderer()`. The constructor takes no
208
- * arguments; fonts and images are now per-render options instead of
209
- * construction-time state.
297
+ * - `new Renderer()` -> `new Renderer(options?)`. The constructor takes an
298
+ * optional `RendererOptions` (currently just `cacheMaxBytes`, the byte
299
+ * budget shared by every cached resource -- decoded images, SVG rasters,
300
+ * parsed stylesheets -- default 16 MiB). Fonts and per-render images are
301
+ * still per-render options, not construction-time state.
210
302
  * - `loadFont` / `loadFonts` / `loadFontSync` -> `registerFont`. Use this
211
303
  * only to preload a font once and reuse it across many renders; most
212
304
  * callers should just pass `fonts` on the render/measure call instead.
213
305
  * - The persistent image store and `GlobalContext` are gone.
214
306
  * `putPersistentImage` / `clearImageStore` no longer exist. Every image
215
307
  * a render needs must be passed through `images`, keyed by `src`.
216
- * - `fetchedResources` -> `images`. `images` also accepts a group form
217
- * (`{ sources, fetch, fetchCache }`) that combines pre-fetched entries,
218
- * a custom fetch implementation, and a shared byte cache.
308
+ * - `fetchedResources` -> `images`. IMPORTANT: at *this* layer (the native
309
+ * `@takumi-rs/core` binding) `images` is `Array<ImageSource>` --
310
+ * pre-fetched `{ src, data, cache? }` entries only. There is no fetch
311
+ * mechanism here. The fetch-capable "group form" (`{ sources, fetch,
312
+ * timeout, fetchCache, allowUrl, maxBytes }`) documented for Takumi is a
313
+ * `@takumi-rs/helpers` concept (`prepareImages()`, and the managed
314
+ * `takumi-js` `render()`/`ImageResponse`), not something this renderer's
315
+ * `RenderOptions.images` accepts directly -- passing that shape here
316
+ * type-checks against nothing (see `ImagesOption` below) and would fail
317
+ * at the native boundary. `packages/core/src/assets/image-resolver.ts`
318
+ * is where the fetch step actually happens, upstream of this shim.
219
319
  * - `format` stays a string, but `quality` (jpeg / lossy webp) and
220
320
  * `lossless` (webp) are now separate optional fields instead of one
221
321
  * combined quality argument.
@@ -230,22 +330,22 @@ interface FontDescriptor {
230
330
  }
231
331
  /** A font supplied to a render/measure call: a loaded descriptor, raw bytes, or a bare URL fetched on demand. */
232
332
  type Font = FontDescriptor | Uint8Array | ArrayBuffer | Buffer | string;
333
+ /** Cache policy for a decoded image, applied against the renderer's shared `cacheMaxBytes` budget. Defaults to `"auto"`. */
334
+ type ImageCacheMode = "auto" | "none";
233
335
  interface ImageSourceEntry {
234
336
  src: string;
235
337
  data: Uint8Array | ArrayBuffer;
338
+ cache?: ImageCacheMode;
236
339
  }
237
- /** Shared byte cache across renders -- dedupes concurrent fetches of the same URL. */
238
- type ImageFetchCache = Map<string, Promise<ArrayBuffer>>;
239
- type ImageFetchFn = (url: string, signal?: AbortSignal) => Promise<ArrayBuffer>;
240
- interface ImageResourcesGroup {
241
- /** Pre-fetched entries, keyed by src. Not re-fetched. */
242
- sources?: ImageSourceEntry[];
243
- fetch?: ImageFetchFn;
244
- timeout?: number;
245
- fetchCache?: ImageFetchCache;
246
- }
247
- /** Either a flat list of pre-fetched images, or the group form with fetch behavior + a shared cache. */
248
- type ImagesOption = ImageSourceEntry[] | ImageResourcesGroup;
340
+ /**
341
+ * Pre-fetched images only -- this is the real native `RenderOptions.images`
342
+ * shape. There is no fetch-capable group form at this layer; resolve
343
+ * `src`/`backgroundImage`/`maskImage` URLs to bytes upstream (see
344
+ * `packages/core/src/assets/image-resolver.ts`, which wraps
345
+ * `@takumi-rs/helpers`'s `prepareImages`/`extractEmojis`) before they reach
346
+ * `RenderOptions.images`.
347
+ */
348
+ type ImagesOption = ImageSourceEntry[];
249
349
  type OutputFormat$1 = "webp" | "png" | "jpeg" | "ico" | "raw";
250
350
  type DitheringAlgorithm = "none" | "ordered-bayer" | "floyd-steinberg";
251
351
  interface RenderOptions {
@@ -359,8 +459,12 @@ interface AssetsResolverOptions {
359
459
  preResolvedFonts?: Font[];
360
460
  preResolvedFontFamilies?: string[];
361
461
  }
462
+ declare const clearFontCache: () => void;
362
463
  declare const resolveAssetsForLocale: (localeId: string, props: Record<string, unknown>, options?: AssetsResolverOptions) => Promise<AssetsForLocale>;
363
464
  //#endregion
465
+ //#region src/assets/font-config.d.ts
466
+ declare const createFetchFontsFromConfig: (fontsConfig?: Record<string, FontConfig[]> | undefined, configDir?: string) => ((localeId: string) => Promise<FontResolutionResult | null>) | undefined;
467
+ //#endregion
364
468
  //#region src/batch/loaders.d.ts
365
469
  interface LoadedTemplate {
366
470
  module: TemplateModule;
@@ -402,6 +506,14 @@ interface SingleInput {
402
506
  };
403
507
  outDir?: string;
404
508
  format?: "webp" | "png" | "jpeg" | "ico" | "raw";
509
+ /**
510
+ * `renderComponent` is a direct programmatic API, not a project loaded
511
+ * from `studio.config.ts` -- there's no config file to source this from.
512
+ * The caller (trusted code, not request props) is the policy source
513
+ * here, same tier as `OgResponseOptions.images`. Unset denies every
514
+ * external image URL. See ADR-0018.
515
+ */
516
+ images?: ImagePolicy;
405
517
  }
406
518
  interface SingleOutput {
407
519
  outputPath: string;
@@ -419,8 +531,16 @@ interface OrchestrateRenderInput {
419
531
  propsSchema?: PropSchema;
420
532
  locale?: string;
421
533
  loadLocaleData?: (locale: string) => Promise<Record<string, unknown>>;
534
+ fetchFonts?: (localeId: string) => Promise<FontResolutionResult | null>;
422
535
  fonts?: Font[];
423
536
  fontFamilies?: string[];
537
+ /**
538
+ * Fetch policy for external images/emoji this template's node tree
539
+ * references. Source this from a trusted, non-request-influenced place
540
+ * (studio.config.ts / OgResponseOptions) -- never from `props`. Unset
541
+ * denies every URL (see ADR-0018 and `assets/image-resolver.ts`).
542
+ */
543
+ images?: ImagePolicy;
424
544
  width: number;
425
545
  height: number;
426
546
  cache: RenderCache;
@@ -444,6 +564,9 @@ interface OrchestrateMeasureInput {
444
564
  propsSchema?: PropSchema;
445
565
  locale?: string;
446
566
  loadLocaleData?: (locale: string) => Promise<Record<string, unknown>>;
567
+ fetchFonts?: (localeId: string) => Promise<FontResolutionResult | null>;
568
+ /** See `OrchestrateRenderInput.images`. */
569
+ images?: ImagePolicy;
447
570
  cache: RenderCache;
448
571
  signal?: AbortSignal;
449
572
  }
@@ -477,4 +600,4 @@ interface BatchOutput {
477
600
  }
478
601
  declare const runBatch: (input: BatchInput) => Promise<BatchOutput>;
479
602
  //#endregion
480
- export { AppError, type AssetsForLocale, type AssetsResolverOptions, type BatchInput, type BatchOutput, type CacheStats, type CompiledTemplate, ErrorCode, type ErrorCode as ErrorCodeType, type FontResolutionResult, type LogLevel, type Manifest, type ManifestEntry, type OpenCacheOptions, type OrchestrateMeasureInput, type OrchestrateMeasureResult, type OrchestrateRenderInput, type OrchestrateRenderResult, PropValidationError, RenderCache, type RenderCacheOptions, type RenderOutput, type SingleInput, type SingleOutput, type StudioConfig, type TemplateModule, compileTemplate, importTemplateForBatch, isZodObject, loadConfig, logDebug, logError, logInfo, logWarn, measureTemplate, openCache, orchestrateMeasure, orchestrateRender, parseStudioConfig, registerFont, renderAutoSized, renderComponent, renderToPixels, resolveAssetsForLocale, resolveProjectPaths, runBatch, studioConfigSchema, validateProps, zodToJsonSchemaShape };
603
+ export { AppError, type AssetsForLocale, type AssetsResolverOptions, type BatchInput, type BatchOutput, type CacheStats, type CompiledTemplate, ErrorCode, type ErrorCode as ErrorCodeType, type FontConfig, type FontResolutionResult, type LogLevel, type Manifest, type ManifestEntry, type OpenCacheOptions, type OrchestrateMeasureInput, type OrchestrateMeasureResult, type OrchestrateRenderInput, type OrchestrateRenderResult, PropValidationError, RenderCache, type RenderCacheOptions, type RenderOutput, type SingleInput, type SingleOutput, type StudioConfig, type TemplateModule, clearFontCache, compileTemplate, createFetchFontsFromConfig, importTemplateForBatch, isZodObject, loadConfig, logDebug, logError, logInfo, logWarn, measureTemplate, openCache, orchestrateMeasure, orchestrateRender, parseStudioConfig, registerFont, renderAutoSized, renderComponent, renderToPixels, resolveAssetsForLocale, resolveProjectPaths, runBatch, studioConfigSchema, validateProps, zodToJsonSchemaShape };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
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{Kysely,SqliteDialect,sql}from"kysely";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){let bunGlobal=globalThis.Bun;return bunGlobal?bunGlobal.hash(bytes).toString(16):createHash(`sha256`).update(bytes).digest(`hex`)}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;closeDb;db;initialized=!1;initPromise;constructor(cacheDir){this.cacheDir=cacheDir,this.dbPath=cacheDir===`:memory:`?`:memory:`:path.join(cacheDir,`cache.sqlite`)}async init(){if(!this.initialized){if(this.initPromise)return this.initPromise;this.initPromise=this.#doInit();try{await this.initPromise}finally{this.initPromise=void 0,this.closeDb&&(this.initialized=!0)}}}async#doInit(){this.cacheDir!==`:memory:`&&!existsSync(this.cacheDir)&&await mkdir(this.cacheDir,{recursive:!0});let isBun=!1;try{await import(`bun:sqlite`),isBun=!0}catch{isBun=!1}await(isBun?this.initBun():this.initNode())}async initBun(){let Database,BunSqliteDialect;try{({Database}=await import(`bun:sqlite`)),{BunSqliteDialect}=await import(`kysely-bun-sqlite`)}catch(error){throw Error(`Failed to load Bun SQLite modules. Ensure 'bun:sqlite' and 'kysely-bun-sqlite' are available.`,{cause:error})}let rawDb=this.createAndVerifyDb(()=>new Database(this.dbPath,{create:!0,readwrite:!0}),(db,statement)=>db.run(statement),db=>db.query(`PRAGMA integrity_check`).get()?.integrity_check??null);this.closeDb=()=>{rawDb.run(`PRAGMA wal_checkpoint(TRUNCATE)`),rawDb.close()},this.db=new Kysely({dialect:new BunSqliteDialect({database:rawDb})}),await this.createSchema()}async initNode(){let BetterSqlite3;try{BetterSqlite3=(await import(`better-sqlite3`)).default}catch(error){throw Error("Failed to load better-sqlite3. This is required when running under Node.js. Install it as a dependency: `npm install better-sqlite3`.",{cause:error})}let rawDb=this.createAndVerifyDb(()=>new BetterSqlite3(this.dbPath),(db,statement)=>db.exec(statement),db=>db.prepare(`PRAGMA integrity_check`).get()?.integrity_check??null);this.closeDb=()=>{rawDb.exec(`PRAGMA wal_checkpoint(TRUNCATE)`),rawDb.close()},this.db=new Kysely({dialect:new SqliteDialect({database:rawDb})}),await this.createSchema()}createAndVerifyDb(createFn,runFn,checkFn){let rawDb=createFn(),dbHealthy=!0;try{runFn(rawDb,`PRAGMA journal_mode = WAL`),runFn(rawDb,`PRAGMA synchronous = NORMAL`),runFn(rawDb,`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`),runFn(rawDb,`PRAGMA wal_checkpoint(TRUNCATE)`);let result=checkFn(rawDb);result!==`ok`&&(dbHealthy=!1,logWarn(`SQLite integrity check failed, recreating cache database`,{result:result??`null`}))}catch{dbHealthy=!1,logWarn(`SQLite setup or integrity check threw, recreating cache database`)}if(dbHealthy)return rawDb;rawDb.close(),this.deleteDatabaseFiles();let freshDb=createFn();return runFn(freshDb,`PRAGMA journal_mode = WAL`),runFn(freshDb,`PRAGMA synchronous = NORMAL`),runFn(freshDb,`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`),freshDb}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`
1
+ import{a as logDebug,c as logWarn,i as renderToPixels,l as compileTemplate,n as registerFont,o as logError,r as renderAutoSized,s as logInfo,t as measureTemplate}from"./renderer-B2yMpMcX.mjs";import{a as zodToJsonSchemaShape,i as validateProps,r as isZodObject,t as PropValidationError}from"./validation-BzP0M2-C.mjs";import{z}from"zod";import{existsSync,unlinkSync}from"node:fs";import{mkdir,readFile,readdir,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{Kysely,SqliteDialect,sql}from"kysely";import{googleFonts,prepareImages}from"@takumi-rs/helpers";import{pathToFileURL}from"node:url";import{createElement}from"react";import{extractEmojis}from"@takumi-rs/helpers/emoji";const fontConfigSchema=z.object({family:z.string().min(1),path:z.string().optional(),source:z.enum([`google`,`local`]).optional(),weights:z.array(z.number().int().positive()).optional()}),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()}),imagePolicySchema=z.object({allowUrl:z.custom(val=>typeof val==`function`).optional(),emoji:z.custom(val=>typeof val==`string`).optional(),fetch:z.custom(val=>typeof val==`function`).optional(),maxBytes:z.number().int().positive().optional(),timeout:z.number().int().optional()}),studioConfigSchema=z.object({defaultPreset:z.string().optional(),fonts:z.record(z.string(),z.array(fontConfigSchema)).optional(),images:imagePolicySchema.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(`; `)}},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 createCompileCacheKey=input=>createHash(`sha256`).update(`${input.templateId}\u0000${input.templateContentHash}\u0000${input.propsJSON}`).digest(`hex`).slice(0,32),createPixelCacheKey=input=>createHash(`sha256`).update(`${input.templateContentHash}\u0000${input.propsJSON}\u0000${input.width}\u0000${input.height}\u0000${input.format??`png`}`).digest(`hex`).slice(0,32);var CompileCache=class CompileCache{maxEntries;nodeMap=new Map;head;tail;contentHashIndex=new Map;keyToContentHash=new Map;constructor(maxEntries=100){this.maxEntries=maxEntries,this.head={key:``,next:null,prev:null,value:void 0},this.tail={key:``,next:null,prev:null,value:void 0},this.head.next=this.tail,this.tail.prev=this.head}get size(){return this.nodeMap.size}get(key){let node=this.nodeMap.get(key);return node?(this.moveToTail(node),node.value):null}set(key,value){let existing=this.nodeMap.get(key);if(existing){existing.value=value,this.moveToTail(existing);return}let node={key,next:null,prev:null,value};this.nodeMap.set(key,node),this.insertBeforeTail(node),this.nodeMap.size>this.maxEntries&&this.evictLRU()}setWithContentHash(key,contentHash,value){let previousHash=this.keyToContentHash.get(key);if(previousHash!==void 0&&previousHash!==contentHash){let oldSet=this.contentHashIndex.get(previousHash);oldSet&&(oldSet.delete(key),oldSet.size===0&&this.contentHashIndex.delete(previousHash))}this.set(key,value);let set=this.contentHashIndex.get(contentHash);set||(set=new Set,this.contentHashIndex.set(contentHash,set)),set.add(key),this.keyToContentHash.set(key,contentHash)}invalidateByContentHash(contentHash){let keys=this.contentHashIndex.get(contentHash);if(keys){for(let key of keys)this.removeNode(key);this.contentHashIndex.delete(contentHash)}}clear(){this.nodeMap.clear(),this.contentHashIndex.clear(),this.keyToContentHash.clear(),this.head.next=this.tail,this.tail.prev=this.head}insertBeforeTail(node){let prev=this.tail.prev;node.prev=prev,node.next=this.tail,prev.next=node,this.tail.prev=node}static removeFromList(node){node.prev.next=node.next,node.next.prev=node.prev,node.prev=null,node.next=null}moveToTail(node){CompileCache.removeFromList(node),this.insertBeforeTail(node)}evictLRU(){let lru=this.head.next;!lru||lru===this.tail||this.removeNode(lru.key)}removeNode(key){let node=this.nodeMap.get(key);if(!node)return;CompileCache.removeFromList(node),this.nodeMap.delete(key);let contentHash=this.keyToContentHash.get(key);if(contentHash!==void 0){this.keyToContentHash.delete(key);let set=this.contentHashIndex.get(contentHash);set&&(set.delete(key),set.size===0&&this.contentHashIndex.delete(contentHash))}}},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 isFsNotFoundError=error=>error?.code===`ENOENT`,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){let bunGlobal=globalThis.Bun;return bunGlobal?`bun1:${bunGlobal.hash(bytes).toString(16)}`:`sha256:${createHash(`sha256`).update(bytes).digest(`hex`)}`}hashesMatch(stored,computed){let[storedPrefix]=stored.split(`:`),[computedPrefix]=computed.split(`:`);return storedPrefix===computedPrefix?stored===computed:(logDebug(`FsStore: cross-runtime hash algorithm mismatch, treating as cache miss`,{computed:computedPrefix,stored:storedPrefix}),!1)}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;try{fileStat=await this.fs.stat(filePath)}catch(error){if(isFsNotFoundError(error))return logDebug(`FsStore: file disappeared before stat (TOCTOU)`,{filePath}),null;throw error}if(fileStat.size!==expectedSizeBytes)return logWarn(`FsStore: size mismatch on read, treating as corrupt`,{expected:expectedSizeBytes,filePath,onDisk:fileStat.size}),null;let bytes;try{bytes=await this.fs.readFile(filePath)}catch(error){if(isFsNotFoundError(error))return logDebug(`FsStore: file disappeared before readFile (TOCTOU)`,{filePath}),null;throw error}let actualHash=this.computeHash(bytes);return this.hashesMatch(expectedContentHash,actualHash)?bytes:(expectedContentHash.split(`:`)[0]===actualHash.split(`:`)[0]&&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;closeDb;db;initialized=!1;initPromise;constructor(cacheDir){this.cacheDir=cacheDir,this.dbPath=cacheDir===`:memory:`?`:memory:`:path.join(cacheDir,`cache.sqlite`)}async init(){if(!this.initialized){if(this.initPromise)return this.initPromise;this.initPromise=this.#doInit();try{await this.initPromise}finally{this.initPromise=void 0,this.closeDb&&(this.initialized=!0)}}}async#doInit(){this.cacheDir!==`:memory:`&&!existsSync(this.cacheDir)&&await mkdir(this.cacheDir,{recursive:!0});let isBun=!1;try{await import(`bun:sqlite`),isBun=!0}catch{isBun=!1}await(isBun?this.initBun():this.initNode())}async initBun(){let Database,BunSqliteDialect;try{({Database}=await import(`bun:sqlite`)),{BunSqliteDialect}=await import(`kysely-bun-sqlite`)}catch(error){throw Error(`Failed to load Bun SQLite modules. Ensure 'bun:sqlite' and 'kysely-bun-sqlite' are available.`,{cause:error})}let rawDb=this.createAndVerifyDb(()=>new Database(this.dbPath,{create:!0,readwrite:!0}),(db,statement)=>db.run(statement),db=>db.query(`PRAGMA integrity_check`).get()?.integrity_check??null);this.closeDb=()=>{try{rawDb.run(`PRAGMA wal_checkpoint(TRUNCATE)`)}catch{}rawDb.close()},this.db=new Kysely({dialect:new BunSqliteDialect({database:rawDb})}),await this.createSchema()}async initNode(){let BetterSqlite3;try{BetterSqlite3=(await import(`better-sqlite3`)).default}catch(error){throw Error("Failed to load better-sqlite3. This is required when running under Node.js. Install it as a dependency: `npm install better-sqlite3`.",{cause:error})}let rawDb=this.createAndVerifyDb(()=>new BetterSqlite3(this.dbPath),(db,statement)=>db.exec(statement),db=>db.prepare(`PRAGMA integrity_check`).get()?.integrity_check??null);this.closeDb=()=>{try{rawDb.exec(`PRAGMA wal_checkpoint(TRUNCATE)`)}catch{}rawDb.close()},this.db=new Kysely({dialect:new SqliteDialect({database:rawDb})}),await this.createSchema()}createAndVerifyDb(createFn,runFn,checkFn){let rawDb=createFn(),dbHealthy=!0;try{runFn(rawDb,`PRAGMA journal_mode = WAL`),runFn(rawDb,`PRAGMA synchronous = NORMAL`),runFn(rawDb,`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`),runFn(rawDb,`PRAGMA wal_checkpoint(TRUNCATE)`);let result=checkFn(rawDb);result!==`ok`&&(dbHealthy=!1,logWarn(`SQLite integrity check failed, recreating cache database`,{result:result??`null`}))}catch{dbHealthy=!1,logWarn(`SQLite setup or integrity check threw, recreating cache database`)}if(dbHealthy)return rawDb;rawDb.close(),this.deleteDatabaseFiles();let freshDb=createFn();return runFn(freshDb,`PRAGMA journal_mode = WAL`),runFn(freshDb,`PRAGMA synchronous = NORMAL`),runFn(freshDb,`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`),freshDb}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`
2
2
  CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_insert
3
3
  AFTER INSERT ON pixel_cache_entries
4
4
  BEGIN
@@ -25,4 +25,4 @@ import{a as zodToJsonSchemaShape,i as validateProps,r as isZodObject,t as PropVa
25
25
  SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes
26
26
  WHERE id = 1;
27
27
  END;
28
- `.execute(db)}close(){this.closeDb&&=(this.closeDb(),void 0),this.db=void 0,this.initialized=!1,this.initPromise=void 0}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 writeFile(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};
28
+ `.execute(db)}close(){this.closeDb&&=(this.closeDb(),void 0),this.db=void 0,this.initialized=!1,this.initPromise=void 0}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}};const RECOVERY_COOLDOWN_MS=2e3,isCacheEntryFileName=name=>/^[0-9a-f]{32,}\.[a-z]+$/u.test(name);var RenderCache=class{cacheDir;maxSizeBytes;compiled;maxConcurrentWrites;evictionDebounceMs;metadata;fsStore;eviction;initialized=!1;recoveryPromise=null;lastRecoveryAt=0;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);try{await fsStore.writeAtomic(filePath,bytes)}catch(error){if(error?.code===`ENOENT`)logDebug(`FsStore: ENOENT during writeAtomic, recreating cache directory and retrying`,{filePath}),await mkdir(this.cacheDir,{recursive:!0}),await fsStore.writeAtomic(filePath,bytes);else throw error}try{await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width})}catch(error){let err=error;if(err?.code===`SQLITE_IOERR_VNODE`||(err?.message??``).includes(`disk I/O error`))logWarn(`Metadata write failed (I/O error), recovering and retrying`,{error:String(error),key}),await this.recoverMetadata(),metadata=this.requireMetadata(),await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width});else throw error}return 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)));await metadata.deleteAll();let orphanFreed=await this.deleteOrphanFiles(new Set(rows.map(r=>r.fileName)),fsStore);return{freedBytes:freedAmounts.reduce((sum,n)=>sum+n,0)+orphanFreed}}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)}}async deleteOrphanFiles(knownFileNames,fsStore){let freed=0;try{let entries=await readdir(this.cacheDir);await Promise.all(entries.map(async entry=>{if(knownFileNames.has(entry)||!isCacheEntryFileName(entry))return;let filePath=path.join(this.cacheDir,entry);logDebug(`FsStore: removing orphaned cache file`,{filePath}),freed+=await fsStore.delete(filePath)}))}catch(error){logWarn(`RenderCache: failed to scan for orphaned files`,{error:String(error)})}return freed}async recoverMetadata(){if(Date.now()-this.lastRecoveryAt<RECOVERY_COOLDOWN_MS){logDebug(`RenderCache: skipping recovery — within cooldown window`,{cooldownMs:RECOVERY_COOLDOWN_MS});return}if(this.recoveryPromise)return this.recoveryPromise;this.recoveryPromise=this.doRecoverMetadata();try{await this.recoveryPromise}finally{this.recoveryPromise=null}}async doRecoverMetadata(){this.lastRecoveryAt=Date.now(),this.metadata?.close(),this.metadata=new MetadataStore(this.cacheDir),await this.metadata.init()}requireMetadata(){if(!this.metadata)throw Error(`RenderCache.init() must be called before use.`);return this.metadata}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 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},isFontObject=f=>typeof f==`object`&&!(f instanceof Uint8Array),loadLocalFontDescriptor=async(family,filePath,weight,configDir)=>({data:await readFile(configDir?path.resolve(configDir,filePath):path.resolve(filePath)),name:family,weight:weight??400}),isLocalConfig=c=>c.source===`local`&&typeof c.path==`string`&&c.path.length>0,createFetchFontsFromConfig=(fontsConfig,configDir)=>{if(fontsConfig)return async localeId=>{let configs=fontsConfig[localeId];if(!configs||configs.length===0)return null;try{let localConfigs=configs.filter(isLocalConfig),googleConfigs=configs.filter(c=>!isLocalConfig(c)),localFonts=await Promise.all(localConfigs.map(c=>loadLocalFontDescriptor(c.family,c.path,c.weights?.[0],configDir))),googleFontDescriptors=[];googleConfigs.length>0&&(googleFontDescriptors=await googleFonts(googleConfigs.map(c=>({name:c.family,weight:c.weights??[400,700]}))));let fonts=[...localFonts,...googleFontDescriptors],fontFamilies=fonts.filter(isFontObject).map(f=>String(f.name??``)).filter(Boolean);return logDebug(`fonts resolved from config`,{count:fonts.length,localeId,names:fontFamilies}),{fontFamilies,fonts}}catch(error){return logDebug(`fonts from config failed`,{error:String(error),localeId}),null}}},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,clearFontCache=()=>{fontCache.clear()},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||fontCache.delete(localeId)}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}`};var BoundedImageFetchCache=class{maxEntries;store=new Map;constructor(maxEntries=500){this.maxEntries=maxEntries}get size(){return this.store.size}get(url){let value=this.store.get(url);if(value!==void 0)return this.store.delete(url),this.store.set(url,value),value}set(url,data){if(this.store.delete(url),this.store.set(url,data),this.store.size>this.maxEntries){let oldest=this.store.keys().next().value;oldest!==void 0&&this.store.delete(oldest)}}delete(url){this.store.delete(url)}clear(){this.store.clear()}},ImagePolicyBlockedError=class extends Error{url;constructor(url){super(`Blocked by image fetch policy: ${url}`),this.name=`ImagePolicyBlockedError`,this.url=url}};const denyAll=()=>!1;let sharedFetchCache=null;const getSharedFetchCache=()=>(sharedFetchCache||=new BoundedImageFetchCache,sharedFetchCache),isAllowUrlBlockedError=error=>error instanceof Error&&error.message.startsWith(`URL blocked by allowUrl policy: `),resolveImages=async(node,policy,deps)=>{let prepareImagesFn=deps?.prepareImages??prepareImages,extractEmojisFn=deps?.extractEmojis??extractEmojis,fetchCache=deps?.fetchCache??getSharedFetchCache(),resolvedNode=policy?.emoji&&policy.emoji!==`from-font`?extractEmojisFn(node,policy.emoji):node;try{let images=await prepareImagesFn({allowUrl:policy?.allowUrl??denyAll,fetch:policy?.fetch,fetchCache,maxBytes:policy?.maxBytes,node:resolvedNode,timeout:policy?.timeout});return logDebug(`image-resolver: resolved images`,{count:images.length}),{images,node:resolvedNode}}catch(error){throw isAllowUrlBlockedError(error)?new ImagePolicyBlockedError(error.message.slice(32)):(logWarn(`image-resolver: image fetch failed`,{error:error instanceof Error?error.message:String(error)}),error)}},prepPipeline=async input=>{let{compiledTemplate:preCompiled,component,templateContentHash,templateId,props,propsSchema,locale,loadLocaleData,preResolvedFonts,preResolvedFontFamilies,fetchFonts,images,cache}=input,{fonts:resolvedFonts,fontFamilies:resolvedFontFamilies,lang:resolvedLang,props:merged}=await resolveAssetsForLocale(locale,propsSchema?validateProps(propsSchema,props):{...props},{fetchFonts,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))}let{node:resolvedNode,images:resolvedImages}=await resolveImages(compiled.node,images),renderReady=resolvedNode===compiled.node?compiled:{...compiled,node:resolvedNode};return{compileKey,compiled,mergedProps:merged,propsJSON,renderReady,resolvedFontFamilies,resolvedFonts,resolvedImages,resolvedLang}},orchestrateRender=async input=>{let start=performance.now(),{compiledTemplate,component,templateContentHash,templateId,props,propsSchema,locale:localeId,loadLocaleData,fetchFonts,fonts:preResolvedFonts,fontFamilies:preResolvedFontFamilies,images,width,height,cache,format=`png`,signal}=input,prep=await prepPipeline({cache,compiledTemplate,component,fetchFonts,images,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.renderReady,{height,width},{fontFamilies:(prep.resolvedFontFamilies??[]).length>0?prep.resolvedFontFamilies:void 0,fonts:prep.resolvedFonts.length>0?prep.resolvedFonts:void 0,format,images:prep.resolvedImages.length>0?prep.resolvedImages:void 0,lang:prep.resolvedLang},signal);try{await cache.setPixels(pixelKey,output.bytes,width,height,format)}catch(error){logWarn(`Failed to write to pixel cache`,{error:String(error),pixelKey})}return{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,fetchFonts,images,cache,signal}=input,prep=await prepPipeline({cache,compiledTemplate,component,fetchFonts,images,loadLocaleData,locale:localeId??`default`,preResolvedFontFamilies:void 0,preResolvedFonts:void 0,props,propsSchema,templateContentHash,templateId}),measured=await measureTemplate(prep.renderReady,{fontFamilies:(prep.resolvedFontFamilies??[]).length>0?prep.resolvedFontFamilies:void 0,fonts:prep.resolvedFonts.length>0?prep.resolvedFonts:void 0,images:prep.resolvedImages.length>0?prep.resolvedImages: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`,images,outDir=`./out`,preset,props,propsSchema}=input,result=await orchestrateRender({cache:noopCache,component,format,height:preset.height,images,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 writeFile(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),fetchFonts=createFetchFontsFromConfig(config.fonts,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,fetchFonts,format:`png`,height:cell.preset.height,images:config.images,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,clearFontCache,compileTemplate,createFetchFontsFromConfig,importTemplateForBatch,isZodObject,loadConfig,logDebug,logError,logInfo,logWarn,measureTemplate,openCache,orchestrateMeasure,orchestrateRender,parseStudioConfig,registerFont,renderAutoSized,renderComponent,renderToPixels,resolveAssetsForLocale,resolveProjectPaths,runBatch,studioConfigSchema,validateProps,zodToJsonSchemaShape};
@@ -0,0 +1,35 @@
1
+ import { ReactElement } from "react";
2
+ //#region src/og/template.d.ts
3
+ interface OgTemplateProps {
4
+ title: string;
5
+ description?: string;
6
+ siteName?: string;
7
+ }
8
+ declare const StandardOgTemplate: ({ title, description, siteName }: OgTemplateProps) => import("react").JSX.Element;
9
+ //#endregion
10
+ //#region src/og/response.d.ts
11
+ interface OgResponseOptions {
12
+ locale?: string;
13
+ width?: number;
14
+ height?: number;
15
+ format?: "webp" | "png" | "jpeg";
16
+ quality?: number;
17
+ lossless?: boolean;
18
+ fonts?: unknown[];
19
+ fontFamilies?: string[];
20
+ lang?: string;
21
+ stylesheets?: string[];
22
+ devicePixelRatio?: number;
23
+ }
24
+ declare const createOgResponse: (element: ReactElement, options?: OgResponseOptions) => Promise<Response>;
25
+ //#endregion
26
+ //#region src/og/pipeline-response.d.ts
27
+ interface OgPipelineOptions {
28
+ width?: number;
29
+ height?: number;
30
+ format?: "webp" | "png" | "jpeg";
31
+ devicePixelRatio?: number;
32
+ }
33
+ declare const renderOgResponse: (element: ReactElement, options?: OgPipelineOptions) => Promise<Response>;
34
+ //#endregion
35
+ export { type OgPipelineOptions, type OgResponseOptions, type OgTemplateProps, StandardOgTemplate, createOgResponse, renderOgResponse };
@@ -0,0 +1 @@
1
+ import{i as renderToPixels,l as compileTemplate}from"../renderer-B2yMpMcX.mjs";import{jsx,jsxs}from"react/jsx-runtime";import{ImageResponse}from"takumi-js/response";const getTitleSize=length=>length>60?`48px`:length>35?`60px`:`76px`,truncateText=(text,maxLength=130)=>text.length<=maxLength?text:`${text.slice(0,maxLength).trimEnd()}…`,StandardOgTemplate=({title,description,siteName})=>{let titleSize=getTitleSize(title.length),truncatedDescription=description?truncateText(description,130):null;return jsxs(`div`,{style:{backgroundColor:`#0a0a0b`,color:`#e4e4e7`,display:`flex`,flexDirection:`column`,fontFamily:`system-ui, -apple-system, BlinkMacSystemFont, sans-serif`,height:`100%`,justifyContent:`space-between`,padding:`64px 72px 56px`,position:`relative`,width:`100%`},children:[jsx(`div`,{style:{background:`linear-gradient(90deg, #6366f1 0%, #818cf8 100%)`,height:`4px`,left:`0`,position:`absolute`,right:`0`,top:`0`}}),jsxs(`div`,{style:{alignItems:`center`,display:`flex`,gap:`10px`},children:[jsx(`div`,{style:{backgroundColor:`#6366f1`,borderRadius:`50%`,flexShrink:`0`,height:`10px`,width:`10px`}}),siteName?jsx(`span`,{style:{color:`#71717a`,fontSize:`18px`,fontWeight:`500`,letterSpacing:`0.04em`,textTransform:`uppercase`},children:siteName}):null]}),jsxs(`div`,{style:{display:`flex`,flex:`1`,flexDirection:`column`,justifyContent:`center`,maxWidth:`860px`,paddingTop:`24px`},children:[jsx(`h1`,{style:{color:`#fafafa`,fontSize:titleSize,fontWeight:`700`,letterSpacing:`-0.02em`,lineHeight:`1.1`,margin:`0`},children:title}),truncatedDescription?jsx(`p`,{style:{color:`#a1a1aa`,display:`flex`,fontSize:`26px`,fontWeight:`400`,lineHeight:`1.5`,margin:`0`,marginTop:`24px`},children:truncatedDescription}):null]}),jsx(`div`,{style:{backgroundColor:`#27272a`,height:`1px`,marginBottom:`0`,width:`100%`}})]})},OG_CACHE_CONTROL=`public, s-maxage=31536000, max-age=3600, immutable`,createOgErrorResponse=error=>(error&&console.error(`[og] render failed`,error),new Response(`Failed to generate OG image`,{headers:{"Cache-Control":`no-store`,"Content-Type":`text/plain; charset=utf-8`},status:500})),createOgResponse=async(element,options)=>{let{locale,format:explicitFormat,height:explicitHeight,width:explicitWidth,devicePixelRatio:explicitDpr,...rest}=options??{},response=new ImageResponse(element,{devicePixelRatio:explicitDpr??2,format:explicitFormat??`webp`,headers:{"Cache-Control":OG_CACHE_CONTROL,Vary:`Accept`},height:explicitHeight??630,...rest,lang:locale??rest.lang,onError:error=>{console.error(`[og] render failed`,error)},width:explicitWidth??1200});try{return await response.ready,response}catch(error){return console.error(`[og] returning 500 after render failure`,error),createOgErrorResponse()}},renderOgResponse=async(element,options)=>{let width=options?.width??1200,height=options?.height??630,format=options?.format??`webp`;try{let{bytes}=await renderToPixels(await compileTemplate(element),{height,width},{format});return new Response(new Uint8Array(bytes),{headers:{"Cache-Control":OG_CACHE_CONTROL,"Content-Type":`image/${format}`,Vary:`Accept`}})}catch{return createOgErrorResponse()}};export{StandardOgTemplate,createOgResponse,renderOgResponse};
@@ -0,0 +1 @@
1
+ import{fromJsx}from"takumi-js/helpers/jsx";import{Renderer}from"takumi-js/node";const compileTemplate=async(element,options)=>{let result=await fromJsx(element,options);return{node:result.node,stylesheets:result.stylesheets}},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)}`)},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)};export{logDebug as a,logWarn as c,renderToPixels as i,compileTemplate as l,registerFont as n,logError as o,renderAutoSized as r,logInfo as s,measureTemplate as t};
@@ -0,0 +1,101 @@
1
+ //#region src/engine/takumi-types-shim.d.ts
2
+ /**
3
+ * Type-only shim for `@takumi-rs/core` (as re-exported by `takumi-js/node`).
4
+ *
5
+ * Migrated for Takumi v2. The v1 shim existed to work around a broken
6
+ * `export.d.ts` re-export chain in @takumi-rs/core@1.8.6 that hid
7
+ * `render`/`measure` and several option types from consumers on
8
+ * `moduleResolution: "nodenext"`. This file re-declares the v2 surface we
9
+ * actually use, backed by the real runtime export.
10
+ *
11
+ * TODO(verify-on-upgrade): the v1 packaging bug this shim worked around
12
+ * has not been independently re-verified against the v2 release actually
13
+ * installed. Once upgraded, run the same isolated-probe check described
14
+ * in the v1 version of this file (import the types directly from
15
+ * "takumi-js/node" in a throwaway file and see if `render`/`measure`/
16
+ * `registerFont` resolve). If they do, delete this file and switch all
17
+ * importers back to `takumi-js/node` directly. Tracked against the
18
+ * upstream repo: https://github.com/kane50613/takumi
19
+ *
20
+ * v2 breaking changes reflected below (see /docs/upgrade/v2):
21
+ * - `new Renderer(options)` -> `new Renderer()`. The constructor takes no
22
+ * arguments; fonts and images are now per-render options instead of
23
+ * construction-time state.
24
+ * - `loadFont` / `loadFonts` / `loadFontSync` -> `registerFont`. Use this
25
+ * only to preload a font once and reuse it across many renders; most
26
+ * callers should just pass `fonts` on the render/measure call instead.
27
+ * - The persistent image store and `GlobalContext` are gone.
28
+ * `putPersistentImage` / `clearImageStore` no longer exist. Every image
29
+ * a render needs must be passed through `images`, keyed by `src`.
30
+ * - `fetchedResources` -> `images`. `images` also accepts a group form
31
+ * (`{ sources, fetch, fetchCache }`) that combines pre-fetched entries,
32
+ * a custom fetch implementation, and a shared byte cache.
33
+ * - `format` stays a string, but `quality` (jpeg / lossy webp) and
34
+ * `lossless` (webp) are now separate optional fields instead of one
35
+ * combined quality argument.
36
+ * - `fonts` entries may be bare URL strings, fetched and cached on demand,
37
+ * in addition to loaded font descriptors / raw bytes.
38
+ */
39
+ interface FontDescriptor {
40
+ name?: string;
41
+ data: Uint8Array | ArrayBuffer | Buffer | (() => Promise<ArrayBuffer | Uint8Array>);
42
+ weight?: number;
43
+ style?: "normal" | "italic" | "oblique" | `oblique ${number}deg` | (string & {});
44
+ }
45
+ /** A font supplied to a render/measure call: a loaded descriptor, raw bytes, or a bare URL fetched on demand. */
46
+ type Font = FontDescriptor | Uint8Array | ArrayBuffer | Buffer | string;
47
+ interface ImageSourceEntry {
48
+ src: string;
49
+ data: Uint8Array | ArrayBuffer;
50
+ }
51
+ /** Shared byte cache across renders -- dedupes concurrent fetches of the same URL. */
52
+ type ImageFetchCache = Map<string, Promise<ArrayBuffer>>;
53
+ type ImageFetchFn = (url: string, signal?: AbortSignal) => Promise<ArrayBuffer>;
54
+ interface ImageResourcesGroup {
55
+ /** Pre-fetched entries, keyed by src. Not re-fetched. */
56
+ sources?: ImageSourceEntry[];
57
+ fetch?: ImageFetchFn;
58
+ timeout?: number;
59
+ fetchCache?: ImageFetchCache;
60
+ }
61
+ /** Either a flat list of pre-fetched images, or the group form with fetch behavior + a shared cache. */
62
+ type ImagesOption = ImageSourceEntry[] | ImageResourcesGroup;
63
+ type OutputFormat = "webp" | "png" | "jpeg" | "ico" | "raw";
64
+ type DitheringAlgorithm = "none" | "ordered-bayer" | "floyd-steinberg";
65
+ interface RenderOptions {
66
+ width?: number;
67
+ height?: number;
68
+ format?: OutputFormat;
69
+ /** JPEG and lossy WebP only. */
70
+ quality?: number;
71
+ /** WebP only (napi binding). Ignored on wasm, where WebP is always lossless. */
72
+ lossless?: boolean;
73
+ drawDebugBorder?: boolean;
74
+ images?: ImagesOption;
75
+ /** Fonts needed for this render. Descriptors, raw bytes, or bare URLs. */
76
+ fonts?: Font[];
77
+ /** Ordered fallback chain of family names. Defaults to every registered family in registration order. */
78
+ fontFamilies?: string[];
79
+ /** BCP-47 language tag for locale-aware shaping. Inherited by children. */
80
+ lang?: string;
81
+ stylesheets?: string[];
82
+ devicePixelRatio?: number;
83
+ timeMs?: number;
84
+ dithering?: DitheringAlgorithm;
85
+ }
86
+ interface MeasuredTextRun {
87
+ text: string;
88
+ x: number;
89
+ y: number;
90
+ width: number;
91
+ height: number;
92
+ }
93
+ interface MeasuredNode {
94
+ width: number;
95
+ height: number;
96
+ transform: [number, number, number, number, number, number];
97
+ children: MeasuredNode[];
98
+ runs: MeasuredTextRun[];
99
+ }
100
+ //#endregion
101
+ export { MeasuredNode as n, RenderOptions as r, Font as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stdout-design/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Core rendering engine for stdout-design — compile TSX templates to pixel-perfect images via Takumi",
5
5
  "keywords": [
6
6
  "design",
@@ -32,6 +32,10 @@
32
32
  "./schema": {
33
33
  "import": "./dist/schema.mjs",
34
34
  "types": "./dist/schema.d.mts"
35
+ },
36
+ "./og": {
37
+ "import": "./dist/og/index.mjs",
38
+ "types": "./dist/og/index.d.mts"
35
39
  }
36
40
  },
37
41
  "publishConfig": {
@@ -47,19 +51,19 @@
47
51
  "lint": "ultracite check"
48
52
  },
49
53
  "dependencies": {
50
- "@takumi-rs/core": "^2.1.1",
51
- "@takumi-rs/helpers": "^2.1.1",
54
+ "@takumi-rs/core": "^2.7.2",
55
+ "@takumi-rs/helpers": "^2.7.2",
52
56
  "csv-parse": "^7.0.1",
53
57
  "kysely": "^0.29.3",
54
58
  "p-limit": "^7.3.0",
55
59
  "proper-lockfile": "^4.1.2",
56
- "takumi-js": "^2.1.1",
60
+ "takumi-js": "^2.7.2",
57
61
  "write-file-atomic": "^8.0.0"
58
62
  },
59
63
  "devDependencies": {
60
64
  "@sinonjs/fake-timers": "^15.4.0",
61
65
  "@stdout-design/config": "workspace:*",
62
- "@takumi-rs/wasm": "^2.1.1",
66
+ "@takumi-rs/wasm": "^2.7.2",
63
67
  "@types/better-sqlite3": "^7.6.0",
64
68
  "@types/proper-lockfile": "^4.1.4",
65
69
  "@types/react": "^19.2.17",