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