@stdout-design/core 0.4.0 → 0.5.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,11 +1,10 @@
1
- import { a as validateProps, i as isZodObject, n as PropValidationError, o as zodToJsonSchemaShape, t as PropSchema } from "./validation-MigzRuji.mjs";
1
+ import { a as validateProps, i as isZodObject, n as PropValidationError, o as zodToJsonSchemaShape, t as PropSchema } from "./validation-2z23pVmR.mjs";
2
2
  import { z } from "zod";
3
3
  import { fromJsx } from "takumi-js/helpers/jsx";
4
4
  import { prepareImages } from "@takumi-rs/helpers";
5
5
  import { ComponentType } from "react";
6
6
  import { EmojiType, extractEmojis } from "@takumi-rs/helpers/emoji";
7
7
  import { Node } from "takumi-js";
8
- //#region src/assets/image-resolver.d.ts
9
8
  interface ImagePolicy {
10
9
  /** Return true to permit fetching a URL. Unset denies every URL -- see the module doc comment. */
11
10
  allowUrl?: (url: string) => boolean;
@@ -23,8 +22,6 @@ interface ImagePolicy {
23
22
  */
24
23
  emoji?: EmojiType | "from-font";
25
24
  }
26
- //#endregion
27
- //#region src/shared/types.d.ts
28
25
  interface FontConfig {
29
26
  family: string;
30
27
  weights?: number[];
@@ -62,9 +59,7 @@ interface TemplateModule {
62
59
  default: (props: Record<string, unknown>) => unknown;
63
60
  propsSchema: z.ZodObject<Record<string, z.ZodTypeAny>>;
64
61
  }
65
- //#endregion
66
- //#region src/shared/config-schema.d.ts
67
- declare const studioConfigSchema: z.ZodObject<{
62
+ export declare const studioConfigSchema: z.ZodObject<{
68
63
  defaultPreset: z.ZodOptional<z.ZodString>;
69
64
  fonts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
70
65
  family: z.ZodString;
@@ -96,21 +91,17 @@ declare const studioConfigSchema: z.ZodObject<{
96
91
  description: z.ZodOptional<z.ZodString>;
97
92
  }, z.core.$strip>>;
98
93
  }, z.core.$strip>;
99
- declare const parseStudioConfig: (raw: unknown) => {
94
+ export declare const parseStudioConfig: (raw: unknown) => {
100
95
  config: StudioConfig;
101
96
  } | {
102
97
  issues: string;
103
98
  };
104
- //#endregion
105
- //#region src/engine/render.d.ts
106
99
  interface CompiledTemplate {
107
100
  node: Node;
108
101
  stylesheets: string[];
109
102
  }
110
- declare const compileTemplate: (element: Parameters<typeof fromJsx>[0], options?: Parameters<typeof fromJsx>[1]) => Promise<CompiledTemplate>;
111
- //#endregion
112
- //#region src/shared/error-codes.d.ts
113
- declare const ErrorCode: {
103
+ export declare const compileTemplate: (element: Parameters<typeof fromJsx>[0], options?: Parameters<typeof fromJsx>[1]) => Promise<CompiledTemplate>;
104
+ export declare const ErrorCode: {
114
105
  readonly API_ERROR: "API_ERROR";
115
106
  readonly CACHE_CLEANUP_FAILED: "CACHE_CLEANUP_FAILED";
116
107
  readonly CACHE_CORRUPT_ENTRY: "CACHE_CORRUPT_ENTRY";
@@ -130,21 +121,17 @@ declare const ErrorCode: {
130
121
  readonly TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND";
131
122
  readonly UI_RENDER_ERROR: "UI_RENDER_ERROR";
132
123
  };
133
- type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
134
- declare class AppError extends Error {
124
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
125
+ export declare class AppError extends Error {
135
126
  readonly code: ErrorCode;
136
127
  readonly context: Record<string, unknown>;
137
128
  constructor(code: ErrorCode, message: string, context?: Record<string, unknown>);
138
129
  }
139
- //#endregion
140
- //#region src/shared/logger.d.ts
141
130
  type LogLevel = "debug" | "info" | "warn" | "error";
142
- declare const logDebug: (message: string, context?: Record<string, unknown>) => void;
143
- declare const logInfo: (message: string, context?: Record<string, unknown>) => void;
144
- declare const logWarn: (message: string, context?: Record<string, unknown>) => void;
145
- declare const logError: (code: ErrorCode, message: string, context?: Record<string, unknown>) => void;
146
- //#endregion
147
- //#region src/cache/cache-keys.d.ts
131
+ export declare const logDebug: (message: string, context?: Record<string, unknown>) => void;
132
+ export declare const logInfo: (message: string, context?: Record<string, unknown>) => void;
133
+ export declare const logWarn: (message: string, context?: Record<string, unknown>) => void;
134
+ export declare const logError: (code: ErrorCode, message: string, context?: Record<string, unknown>) => void;
148
135
  /**
149
136
  * Cache key derivation, shared by every component that needs to name a
150
137
  * cache entry consistently. Pulled out of the old monolithic RenderCache
@@ -166,8 +153,6 @@ interface PixelCacheKeyInput {
166
153
  }
167
154
  declare const createCompileCacheKey: (input: CompileCacheKeyInput) => string;
168
155
  declare const createPixelCacheKey: (input: PixelCacheKeyInput) => string;
169
- //#endregion
170
- //#region src/cache/render-cache.d.ts
171
156
  interface CacheStats {
172
157
  compiled: {
173
158
  entries: number;
@@ -219,7 +204,7 @@ interface SweepOutcome {
219
204
  * file. The full sweep now also scans the directory for files with no
220
205
  * metadata row and removes them.
221
206
  */
222
- declare class RenderCache {
207
+ export declare class RenderCache {
223
208
  private readonly cacheDir;
224
209
  private readonly maxSizeBytes;
225
210
  private readonly compiled;
@@ -273,8 +258,6 @@ declare class RenderCache {
273
258
  private requireFsStore;
274
259
  private requireEviction;
275
260
  }
276
- //#endregion
277
- //#region src/engine/takumi-types-shim.d.ts
278
261
  /**
279
262
  * Type-only shim for `@takumi-rs/core` (as re-exported by `takumi-js/node`).
280
263
  *
@@ -284,14 +267,13 @@ declare class RenderCache {
284
267
  * `moduleResolution: "nodenext"`. This file re-declares the v2 surface we
285
268
  * actually use, backed by the real runtime export.
286
269
  *
287
- * TODO(verify-on-upgrade): the v1 packaging bug this shim worked around
288
- * has not been independently re-verified against the v2 release actually
289
- * installed. Once upgraded, run the same isolated-probe check described
290
- * in the v1 version of this file (import the types directly from
291
- * "takumi-js/node" in a throwaway file and see if `render`/`measure`/
292
- * `registerFont` resolve). If they do, delete this file and switch all
293
- * importers back to `takumi-js/node` directly. Tracked against the
294
- * upstream repo: https://github.com/kane50613/takumi
270
+ * TODO: the v1 packaging bug this shim worked around is fixed as of
271
+ * takumi-js 2.17.1 -- a throwaway file importing `Renderer` from
272
+ * "takumi-js/node" resolves `render`/`measure`/`registerFont` cleanly.
273
+ * Because of the `as unknown` cast below, these hand-written types can
274
+ * silently drift from the real ones (2.17 inserted a `key` parameter into
275
+ * `registerFont`), so prefer deleting this file and importing from
276
+ * `takumi-js/node` directly. Upstream: https://github.com/kane50613/takumi
295
277
  *
296
278
  * v2 breaking changes reflected below (see /docs/upgrade/v2):
297
279
  * - `new Renderer()` -> `new Renderer(options?)`. The constructor takes an
@@ -364,6 +346,7 @@ interface RenderOptions {
364
346
  fontFamilies?: string[];
365
347
  /** BCP-47 language tag for locale-aware shaping. Inherited by children. */
366
348
  lang?: string;
349
+ /** CSS stylesheet text. Sent to Takumi as `css` (its `stylesheets` field is deprecated since 2.17). */
367
350
  stylesheets?: string[];
368
351
  devicePixelRatio?: number;
369
352
  timeMs?: number;
@@ -383,14 +366,12 @@ interface MeasuredNode {
383
366
  children: MeasuredNode[];
384
367
  runs: MeasuredTextRun[];
385
368
  }
386
- //#endregion
387
- //#region src/engine/renderer.d.ts
388
369
  /**
389
370
  * Preload a font once and reuse it across many renders without re-passing
390
371
  * its bytes on every call. Optional -- per Takumi's v2 guidance, passing
391
372
  * `fonts` directly in a render/measure call's options covers most cases.
392
373
  */
393
- declare const registerFont: (font: Font, signal?: AbortSignal) => Promise<string[]>;
374
+ export declare const registerFont: (font: Font, signal?: AbortSignal) => Promise<string[]>;
394
375
  interface RenderOutput {
395
376
  bytes: Buffer;
396
377
  width: number;
@@ -411,11 +392,11 @@ interface RenderOutput {
411
392
  * via `options` -- there is no construction-time renderer config to fall
412
393
  * back on in v2.
413
394
  */
414
- declare const renderToPixels: (template: CompiledTemplate, dimensions: {
395
+ export declare const renderToPixels: (template: CompiledTemplate, dimensions: {
415
396
  width: number;
416
397
  height: number;
417
398
  }, options?: Omit<RenderOptions, "width" | "height">, signal?: AbortSignal) => Promise<RenderOutput>;
418
- declare const measureTemplate: (template: CompiledTemplate, options?: RenderOptions, signal?: AbortSignal) => Promise<MeasuredNode>;
399
+ export declare const measureTemplate: (template: CompiledTemplate, options?: RenderOptions, signal?: AbortSignal) => Promise<MeasuredNode>;
419
400
  /**
420
401
  * Convenience: measure to discover natural dimensions, then render at
421
402
  * those dimensions in one call. This is the "auto-size" path -- it costs
@@ -423,25 +404,19 @@ declare const measureTemplate: (template: CompiledTemplate, options?: RenderOpti
423
404
  * so prefer explicit width/height (e.g. from a chosen output preset) when
424
405
  * you have them.
425
406
  */
426
- declare const renderAutoSized: (template: CompiledTemplate, options?: RenderOptions, signal?: AbortSignal) => Promise<RenderOutput>;
427
- //#endregion
428
- //#region src/project/project-paths.d.ts
407
+ export declare const renderAutoSized: (template: CompiledTemplate, options?: RenderOptions, signal?: AbortSignal) => Promise<RenderOutput>;
429
408
  interface ProjectPaths {
430
409
  configPath: string;
431
410
  defaultCacheDir: string;
432
411
  localesDir: string;
433
412
  templatesDir: string;
434
413
  }
435
- declare const resolveProjectPaths: (rootDir: string) => ProjectPaths;
436
- //#endregion
437
- //#region src/cache/open-cache.d.ts
414
+ export declare const resolveProjectPaths: (rootDir: string) => ProjectPaths;
438
415
  interface OpenCacheOptions {
439
416
  cacheDir?: string;
440
417
  maxSizeMB?: number;
441
418
  }
442
- declare const openCache: (rootDir: string, options?: OpenCacheOptions) => Promise<RenderCache>;
443
- //#endregion
444
- //#region src/assets/assets-resolver.d.ts
419
+ export declare const openCache: (rootDir: string, options?: OpenCacheOptions) => Promise<RenderCache>;
445
420
  interface AssetsForLocale {
446
421
  fonts: Font[];
447
422
  fontFamilies?: string[];
@@ -459,21 +434,15 @@ interface AssetsResolverOptions {
459
434
  preResolvedFonts?: Font[];
460
435
  preResolvedFontFamilies?: string[];
461
436
  }
462
- declare const clearFontCache: () => void;
463
- declare const resolveAssetsForLocale: (localeId: string, props: Record<string, unknown>, options?: AssetsResolverOptions) => Promise<AssetsForLocale>;
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
468
- //#region src/batch/loaders.d.ts
437
+ export declare const clearFontCache: () => void;
438
+ export declare const resolveAssetsForLocale: (localeId: string, props: Record<string, unknown>, options?: AssetsResolverOptions) => Promise<AssetsForLocale>;
439
+ export declare const createFetchFontsFromConfig: (fontsConfig?: Record<string, FontConfig[]> | undefined, configDir?: string) => ((localeId: string) => Promise<FontResolutionResult | null>) | undefined;
469
440
  interface LoadedTemplate {
470
441
  module: TemplateModule;
471
442
  contentHash: string;
472
443
  }
473
- declare const importTemplateForBatch: (rootDir: string, componentPath: string, templateId: string) => Promise<LoadedTemplate>;
474
- declare const loadConfig: (rootDir: string) => Promise<StudioConfig>;
475
- //#endregion
476
- //#region src/batch/types.d.ts
444
+ export declare const importTemplateForBatch: (rootDir: string, componentPath: string, templateId: string) => Promise<LoadedTemplate>;
445
+ export declare const loadConfig: (rootDir: string) => Promise<StudioConfig>;
477
446
  interface ManifestEntry {
478
447
  rowIndex: number;
479
448
  locale: string;
@@ -493,8 +462,6 @@ interface Manifest {
493
462
  error: string;
494
463
  }[];
495
464
  }
496
- //#endregion
497
- //#region src/orchestrate/single.d.ts
498
465
  interface SingleInput {
499
466
  component: ComponentType<Record<string, unknown>>;
500
467
  propsSchema: PropSchema;
@@ -518,9 +485,7 @@ interface SingleInput {
518
485
  interface SingleOutput {
519
486
  outputPath: string;
520
487
  }
521
- declare const renderComponent: (input: SingleInput) => Promise<SingleOutput>;
522
- //#endregion
523
- //#region src/orchestrate/types.d.ts
488
+ export declare const renderComponent: (input: SingleInput) => Promise<SingleOutput>;
524
489
  type OutputFormat = "webp" | "png" | "jpeg" | "ico" | "raw";
525
490
  interface OrchestrateRenderInput {
526
491
  component?: ComponentType<Record<string, unknown>>;
@@ -575,12 +540,8 @@ interface OrchestrateMeasureResult {
575
540
  height: number;
576
541
  durationMs: number;
577
542
  }
578
- //#endregion
579
- //#region src/orchestrate/orchestrate.d.ts
580
- declare const orchestrateRender: (input: OrchestrateRenderInput) => Promise<OrchestrateRenderResult>;
581
- declare const orchestrateMeasure: (input: OrchestrateMeasureInput) => Promise<OrchestrateMeasureResult>;
582
- //#endregion
583
- //#region src/batch/batch.d.ts
543
+ export declare const orchestrateRender: (input: OrchestrateRenderInput) => Promise<OrchestrateRenderResult>;
544
+ export declare const orchestrateMeasure: (input: OrchestrateMeasureInput) => Promise<OrchestrateMeasureResult>;
584
545
  interface BatchInput {
585
546
  rootDir: string;
586
547
  templateId: string;
@@ -598,6 +559,5 @@ interface BatchOutput {
598
559
  manifest: Manifest;
599
560
  elapsedMs: number;
600
561
  }
601
- declare const runBatch: (input: BatchInput) => Promise<BatchOutput>;
602
- //#endregion
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 };
562
+ export declare const runBatch: (input: BatchInput) => Promise<BatchOutput>;
563
+ export { type AssetsForLocale, type AssetsResolverOptions, type BatchInput, type BatchOutput, type CacheStats, type CompiledTemplate, 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, type RenderCacheOptions, type RenderOutput, type SingleInput, type SingleOutput, type StudioConfig, type TemplateModule, isZodObject, validateProps, zodToJsonSchemaShape };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
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`
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-DLY7ffyj.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=/* @__PURE__ */ new Map;head;tail;contentHashIndex=/* @__PURE__ */ new Map;keyToContentHash=/* @__PURE__ */ 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=/* @__PURE__ */ 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 logDebug,c as logWarn,i as renderToPixels,l as compileTemplate,n as
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}};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};
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});else{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)=>{let resolved=configDir?path.resolve(configDir,filePath):path.resolve(filePath);return{data:await readFile(resolved),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 function(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=[];if(googleConfigs.length>0){let families=googleConfigs.map(c=>({name:c.family,weight:c.weights??[400,700]}));googleFontDescriptors=await googleFonts(families)}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=/* @__PURE__ */ 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=/* @__PURE__ */ 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),raw=mod.default??mod,result=parseStudioConfig(raw);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=/* @__PURE__ */ 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,validated=propsSchema?validateProps(propsSchema,props):{...props},{fonts:resolvedFonts,fontFamilies:resolvedFontFamilies,lang:resolvedLang,props:merged}=await resolveAssetsForLocale(locale,validated,{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);if(cachedCompile)compiled=cachedCompile;else{let element=createElement(component,merged);compiled=await compileTemplate(element),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}}),localeCodes=filterLocales??config.locales??[],resolvedLocales=await resolveLocales(rootDir,localeCodes,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};
@@ -1,13 +1,10 @@
1
1
  import { ReactElement } from "react";
2
- //#region src/og/template.d.ts
3
2
  interface OgTemplateProps {
4
3
  title: string;
5
4
  description?: string;
6
5
  siteName?: string;
7
6
  }
8
- declare const StandardOgTemplate: ({ title, description, siteName }: OgTemplateProps) => import("react").JSX.Element;
9
- //#endregion
10
- //#region src/og/response.d.ts
7
+ export declare const StandardOgTemplate: ({ title, description, siteName }: OgTemplateProps) => import("react").JSX.Element;
11
8
  interface OgResponseOptions {
12
9
  locale?: string;
13
10
  width?: number;
@@ -21,15 +18,12 @@ interface OgResponseOptions {
21
18
  stylesheets?: string[];
22
19
  devicePixelRatio?: number;
23
20
  }
24
- declare const createOgResponse: (element: ReactElement, options?: OgResponseOptions) => Promise<Response>;
25
- //#endregion
26
- //#region src/og/pipeline-response.d.ts
21
+ export declare const createOgResponse: (element: ReactElement, options?: OgResponseOptions) => Promise<Response>;
27
22
  interface OgPipelineOptions {
28
23
  width?: number;
29
24
  height?: number;
30
25
  format?: "webp" | "png" | "jpeg";
31
26
  devicePixelRatio?: number;
32
27
  }
33
- declare const renderOgResponse: (element: ReactElement, options?: OgPipelineOptions) => Promise<Response>;
34
- //#endregion
35
- export { type OgPipelineOptions, type OgResponseOptions, type OgTemplateProps, StandardOgTemplate, createOgResponse, renderOgResponse };
28
+ export declare const renderOgResponse: (element: ReactElement, options?: OgPipelineOptions) => Promise<Response>;
29
+ export type { OgPipelineOptions, OgResponseOptions, OgTemplateProps };
package/dist/og/index.mjs CHANGED
@@ -1 +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};
1
+ import{i as renderToPixels,l as compileTemplate}from"../renderer-DLY7ffyj.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/* @__PURE__ */ 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:[/* @__PURE__ */ jsx(`div`,{style:{background:`linear-gradient(90deg, #6366f1 0%, #818cf8 100%)`,height:`4px`,left:`0`,position:`absolute`,right:`0`,top:`0`}}),/* @__PURE__ */ jsxs(`div`,{style:{alignItems:`center`,display:`flex`,gap:`10px`},children:[/* @__PURE__ */ jsx(`div`,{style:{backgroundColor:`#6366f1`,borderRadius:`50%`,flexShrink:`0`,height:`10px`,width:`10px`}}),siteName?/* @__PURE__ */ jsx(`span`,{style:{color:`#71717a`,fontSize:`18px`,fontWeight:`500`,letterSpacing:`0.04em`,textTransform:`uppercase`},children:siteName}):null]}),/* @__PURE__ */ jsxs(`div`,{style:{display:`flex`,flex:`1`,flexDirection:`column`,justifyContent:`center`,maxWidth:`860px`,paddingTop:`24px`},children:[/* @__PURE__ */ jsx(`h1`,{style:{color:`#fafafa`,fontSize:titleSize,fontWeight:`700`,letterSpacing:`-0.02em`,lineHeight:`1.1`,margin:`0`},children:title}),truncatedDescription?/* @__PURE__ */ jsx(`p`,{style:{color:`#a1a1aa`,display:`flex`,fontSize:`26px`,fontWeight:`400`,lineHeight:`1.5`,margin:`0`,marginTop:`24px`},children:truncatedDescription}):null]}),/* @__PURE__ */ 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,stylesheets,...rest}=options??{},response=new ImageResponse(element,{devicePixelRatio:explicitDpr??2,format:explicitFormat??`webp`,headers:{"Cache-Control":OG_CACHE_CONTROL,Vary:`Accept`},height:explicitHeight??630,...rest,css:stylesheets,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 compiled=await compileTemplate(element),{bytes}=await renderToPixels(compiled,{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=()=>(/* @__PURE__ */ 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=async(font,signal)=>(await __getRendererForTesting().registerFont(font,null,signal)).map(family=>family.name),renderToPixels=async(template,dimensions,options,signal)=>{let renderer=__getRendererForTesting();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});let{stylesheets,...nativeOptions}=options??{};return{bytes:await renderer.render(template.node,{...nativeOptions,css:[...stylesheets??[],...template.stylesheets],height:dimensions.height,width:dimensions.width},signal),format:options?.format??`png`,height:dimensions.height,width:dimensions.width}},measureTemplate=(template,options,signal)=>{let renderer=__getRendererForTesting(),{stylesheets,...nativeOptions}=options??{};return renderer.measure(template.node,{...nativeOptions,css:[...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};
package/dist/schema.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { r as defineSchema, t as PropSchema } from "./validation-MigzRuji.mjs";
1
+ import { r as defineSchema, t as PropSchema } from "./validation-2z23pVmR.mjs";
2
2
  export { type PropSchema, defineSchema };
package/dist/schema.mjs CHANGED
@@ -1 +1 @@
1
- import{n as defineSchema}from"./validation-BzP0M2-C.mjs";export{defineSchema};
1
+ export{n as defineSchema}from"./validation-BzP0M2-C.mjs";
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- //#region src/shared/validation.d.ts
3
2
  /**
4
3
  * Single source of truth for the prop-schema type. Previously declared
5
4
  * identically in both `props.ts` and `validation.ts` -- consolidated here
@@ -35,5 +34,4 @@ declare class PropValidationError extends Error {
35
34
  declare const validateProps: <T extends PropSchema>(schema: T, props: unknown) => z.infer<T>;
36
35
  declare const isZodObject: (schema: unknown) => schema is z.ZodObject<Record<string, z.ZodTypeAny>>;
37
36
  declare const zodToJsonSchemaShape: (schema: z.ZodTypeAny) => Record<string, unknown>;
38
- //#endregion
39
37
  export { validateProps as a, isZodObject as i, PropValidationError as n, zodToJsonSchemaShape as o, defineSchema as r, PropSchema as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stdout-design/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -44,40 +44,43 @@
44
44
  "scripts": {
45
45
  "build": "tsdown",
46
46
  "dev": "tsdown --watch",
47
- "test": "bun test",
47
+ "test": "bun test --isolate",
48
48
  "test:integration": "bun test ./src/__tests__/full-pipeline.integration.ts",
49
49
  "check-types": "tsc --noEmit",
50
50
  "check-types:tsgo": "tsgo --noEmit",
51
51
  "lint": "ultracite check"
52
52
  },
53
53
  "dependencies": {
54
- "@takumi-rs/core": "^2.7.2",
55
- "@takumi-rs/helpers": "^2.7.2",
56
- "csv-parse": "^7.0.1",
57
- "kysely": "^0.29.3",
58
- "p-limit": "^7.3.0",
54
+ "@takumi-rs/core": "^2.17.1",
55
+ "@takumi-rs/helpers": "^2.17.1",
56
+ "csv-parse": "^7.0.3",
57
+ "kysely": "^0.29.7",
58
+ "p-limit": "^7.3.3",
59
59
  "proper-lockfile": "^4.1.2",
60
- "takumi-js": "^2.7.2",
60
+ "takumi-js": "^2.17.1",
61
61
  "write-file-atomic": "^8.0.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@sinonjs/fake-timers": "^15.4.0",
65
65
  "@stdout-design/config": "workspace:*",
66
- "@takumi-rs/wasm": "^2.7.2",
67
- "@types/better-sqlite3": "^7.6.0",
66
+ "@takumi-rs/wasm": "^2.17.1",
67
+ "@types/better-sqlite3": "^9.6.0",
68
68
  "@types/proper-lockfile": "^4.1.4",
69
- "@types/react": "^19.2.17",
69
+ "@types/react": "^19.3.0",
70
70
  "@types/sinonjs__fake-timers": "^15.0.1",
71
71
  "@types/write-file-atomic": "^4.0.3",
72
- "tsdown": "^0.22.5",
73
- "typescript": "^7.0.2"
72
+ "tsdown": "^0.23.2",
73
+ "typescript": "7.1.0-beta"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "react": "^19.2.7",
77
77
  "zod": "^4.4.3"
78
78
  },
79
79
  "optionalDependencies": {
80
- "better-sqlite3": "^11.0.0",
80
+ "better-sqlite3": "^13.0.3",
81
81
  "kysely-bun-sqlite": "^0.4.0"
82
+ },
83
+ "engines": {
84
+ "node": ">=22.22"
82
85
  }
83
86
  }
@@ -1 +0,0 @@
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};
@@ -1,101 +0,0 @@
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 };