@stdout-design/core 0.4.0 → 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.mjs +1 -1
- package/dist/og/index.mjs +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -25,4 +25,4 @@ import{a as logDebug,c as logWarn,i as renderToPixels,l as compileTemplate,n as
|
|
|
25
25
|
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes
|
|
26
26
|
WHERE id = 1;
|
|
27
27
|
END;
|
|
28
|
-
`.execute(db)}close(){this.closeDb&&=(this.closeDb(),void 0),this.db=void 0,this.initialized=!1,this.initPromise=void 0}deleteDatabaseFiles(){if(this.dbPath!==`:memory:`)for(let suffix of[``,`-wal`,`-shm`])try{unlinkSync(`${this.dbPath}${suffix}`)}catch{}}async upsertEntry(input){let db=this.requireDb(),now=Date.now();await db.insertInto(`pixel_cache_entries`).values({content_hash:input.contentHash,created_at:now,file_name:input.fileName,hash:input.hash,height:input.height,last_accessed_at:now,size_bytes:input.sizeBytes,width:input.width}).onConflict(oc=>oc.column(`hash`).doUpdateSet({content_hash:input.contentHash,file_name:input.fileName,height:input.height,last_accessed_at:now,size_bytes:input.sizeBytes,width:input.width})).execute()}async getEntry(hash){let row=await this.requireDb().selectFrom(`pixel_cache_entries`).selectAll().where(`hash`,`=`,hash).executeTakeFirst();return row?toRow(row):null}async touchAccess(hash,timestamp=Date.now()){await this.requireDb().updateTable(`pixel_cache_entries`).set({last_accessed_at:timestamp}).where(`hash`,`=`,hash).execute()}async deleteEntry(hash){await this.requireDb().deleteFrom(`pixel_cache_entries`).where(`hash`,`=`,hash).execute()}async deleteAll(){await this.requireDb().deleteFrom(`pixel_cache_entries`).execute()}async listAllOrderedByLastAccessed(){return(await this.requireDb().selectFrom(`pixel_cache_entries`).selectAll().orderBy(`last_accessed_at`,`asc`).execute()).map(toRow)}async getTotals(){let row=await this.requireDb().selectFrom(`cache_totals`).select([`total_size_bytes`,`entry_count`]).where(`id`,`=`,1).executeTakeFirstOrThrow();return{entryCount:row.entry_count,totalSizeBytes:row.total_size_bytes}}requireDb(){if(!this.db)throw Error(`MetadataStore.init() must be called before use.`);return this.db}};const RECOVERY_COOLDOWN_MS=2e3,isCacheEntryFileName=name=>/^[0-9a-f]{32,}\.[a-z]+$/u.test(name);var RenderCache=class{cacheDir;maxSizeBytes;compiled;maxConcurrentWrites;evictionDebounceMs;metadata;fsStore;eviction;initialized=!1;recoveryPromise=null;lastRecoveryAt=0;static createCompileCacheKey=createCompileCacheKey;static createPixelCacheKey=createPixelCacheKey;constructor(options){this.cacheDir=options?.cacheDir??`.studio-cache`,this.maxSizeBytes=(options?.maxSizeMB??500)*1024*1024,this.compiled=new CompileCache(options?.compiledMaxEntries),this.maxConcurrentWrites=options?.maxConcurrentWrites,this.evictionDebounceMs=options?.evictionDebounceMs}async init(){if(this.initialized)return;existsSync(this.cacheDir)||await mkdir(this.cacheDir,{recursive:!0}),this.metadata=new MetadataStore(this.cacheDir),await this.metadata.init();let limiter=new ConcurrencyLimiter(this.maxConcurrentWrites);this.fsStore=new FsStore(limiter),this.eviction=new EvictionCoordinator({debounceMs:this.evictionDebounceMs,getCurrentTotalBytes:async()=>(await this.requireMetadata().getTotals()).totalSizeBytes,lockTargetPath:this.cacheDir,maxSizeBytes:this.maxSizeBytes,sweep:options=>this.performSweep(options)}),this.initialized=!0}close(){this.eviction?.cancelPending(),this.metadata?.close(),this.initialized=!1}getCompiled(key){return this.compiled.get(key)}setCompiled(key,value){this.compiled.set(key,value)}setCompiledWithContentHash(key,contentHash,value){this.compiled.setWithContentHash(key,contentHash,value)}invalidateCompiledByContentHash(contentHash){this.compiled.invalidateByContentHash(contentHash)}clearCompiled(){this.compiled.clear()}async getPixels(key){let metadata=this.requireMetadata(),fsStore=this.requireFsStore(),row;try{row=await metadata.getEntry(key)}catch(error){return logWarn(`Pixel metadata read failed, attempting recovery`,{error:error instanceof Error?error.message:String(error),key}),await this.recoverMetadata(),null}if(!row)return null;let filePath=path.join(this.cacheDir,row.fileName),bytes=await fsStore.readVerified(filePath,row.sizeBytes,row.contentHash);if(bytes===null){try{await metadata.deleteEntry(key)}catch{}return null}try{await metadata.touchAccess(key)}catch(error){logWarn(`Pixel touchAccess failed, attempting recovery`,{error:error instanceof Error?error.message:String(error),key}),await this.recoverMetadata()}return bytes}async setPixels(key,bytes,width,height,format=`png`){let metadata=this.requireMetadata(),fsStore=this.requireFsStore(),fileName=`${key}.${format}`,filePath=path.join(this.cacheDir,fileName),contentHash=fsStore.computeHash(bytes);try{await fsStore.writeAtomic(filePath,bytes)}catch(error){if(error?.code===`ENOENT`)logDebug(`FsStore: ENOENT during writeAtomic, recreating cache directory and retrying`,{filePath}),await mkdir(this.cacheDir,{recursive:!0}),await fsStore.writeAtomic(filePath,bytes);else throw error}try{await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width})}catch(error){let err=error;if(err?.code===`SQLITE_IOERR_VNODE`||(err?.message??``).includes(`disk I/O error`))logWarn(`Metadata write failed (I/O error), recovering and retrying`,{error:String(error),key}),await this.recoverMetadata(),metadata=this.requireMetadata(),await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width});else throw error}return this.requireEviction().requestEviction(),filePath}async stats(){let totals=await this.requireMetadata().getTotals();return{cacheDir:this.cacheDir,compiled:{entries:this.compiled.size},pixels:{entries:totals.entryCount,maxSizeBytes:this.maxSizeBytes,sizeBytes:totals.totalSizeBytes}}}clean(){return this.compiled.clear(),this.requireEviction().runFullSweep()}async performSweep(options){let metadata=this.requireMetadata(),fsStore=this.requireFsStore();if(options.full){let rows=await metadata.listAllOrderedByLastAccessed(),freedAmounts=await Promise.all(rows.map(row=>fsStore.delete(path.join(this.cacheDir,row.fileName),row.sizeBytes)));await metadata.deleteAll();let orphanFreed=await this.deleteOrphanFiles(new Set(rows.map(r=>r.fileName)),fsStore);return{freedBytes:freedAmounts.reduce((sum,n)=>sum+n,0)+orphanFreed}}let totals=await metadata.getTotals();if(totals.totalSizeBytes<=this.maxSizeBytes)return{freedBytes:0};let rows=await metadata.listAllOrderedByLastAccessed(),remaining=totals.totalSizeBytes,rowsToEvict=[];for(let row of rows){if(remaining<=this.maxSizeBytes)break;rowsToEvict.push(row),remaining-=row.sizeBytes}return{freedBytes:(await Promise.all(rowsToEvict.map(async row=>{let freedForRow=await fsStore.delete(path.join(this.cacheDir,row.fileName),row.sizeBytes);return await metadata.deleteEntry(row.hash),freedForRow}))).reduce((sum,n)=>sum+n,0)}}async deleteOrphanFiles(knownFileNames,fsStore){let freed=0;try{let entries=await readdir(this.cacheDir);await Promise.all(entries.map(async entry=>{if(knownFileNames.has(entry)||!isCacheEntryFileName(entry))return;let filePath=path.join(this.cacheDir,entry);logDebug(`FsStore: removing orphaned cache file`,{filePath}),freed+=await fsStore.delete(filePath)}))}catch(error){logWarn(`RenderCache: failed to scan for orphaned files`,{error:String(error)})}return freed}async recoverMetadata(){if(Date.now()-this.lastRecoveryAt<RECOVERY_COOLDOWN_MS){logDebug(`RenderCache: skipping recovery — within cooldown window`,{cooldownMs:RECOVERY_COOLDOWN_MS});return}if(this.recoveryPromise)return this.recoveryPromise;this.recoveryPromise=this.doRecoverMetadata();try{await this.recoveryPromise}finally{this.recoveryPromise=null}}async doRecoverMetadata(){this.lastRecoveryAt=Date.now(),this.metadata?.close(),this.metadata=new MetadataStore(this.cacheDir),await this.metadata.init()}requireMetadata(){if(!this.metadata)throw Error(`RenderCache.init() must be called before use.`);return this.metadata}requireFsStore(){if(!this.fsStore)throw Error(`RenderCache.init() must be called before use.`);return this.fsStore}requireEviction(){if(!this.eviction)throw Error(`RenderCache.init() must be called before use.`);return this.eviction}};const resolveProjectPaths=rootDir=>({configPath:path.resolve(rootDir,`studio.config.ts`),defaultCacheDir:path.resolve(rootDir,`.studio-cache`),localesDir:path.resolve(rootDir,`locales`),templatesDir:path.resolve(rootDir,`templates`)}),openCache=async(rootDir,options)=>{let cache=new RenderCache({cacheDir:options?.cacheDir??resolveProjectPaths(rootDir).defaultCacheDir,maxSizeMB:options?.maxSizeMB});return await cache.init(),cache},isFontObject=f=>typeof f==`object`&&!(f instanceof Uint8Array),loadLocalFontDescriptor=async(family,filePath,weight,configDir)=>({data:await readFile(configDir?path.resolve(configDir,filePath):path.resolve(filePath)),name:family,weight:weight??400}),isLocalConfig=c=>c.source===`local`&&typeof c.path==`string`&&c.path.length>0,createFetchFontsFromConfig=(fontsConfig,configDir)=>{if(fontsConfig)return async localeId=>{let configs=fontsConfig[localeId];if(!configs||configs.length===0)return null;try{let localConfigs=configs.filter(isLocalConfig),googleConfigs=configs.filter(c=>!isLocalConfig(c)),localFonts=await Promise.all(localConfigs.map(c=>loadLocalFontDescriptor(c.family,c.path,c.weights?.[0],configDir))),googleFontDescriptors=[];googleConfigs.length>0&&(googleFontDescriptors=await googleFonts(googleConfigs.map(c=>({name:c.family,weight:c.weights??[400,700]}))));let fonts=[...localFonts,...googleFontDescriptors],fontFamilies=fonts.filter(isFontObject).map(f=>String(f.name??``)).filter(Boolean);return logDebug(`fonts resolved from config`,{count:fonts.length,localeId,names:fontFamilies}),{fontFamilies,fonts}}catch(error){return logDebug(`fonts from config failed`,{error:String(error),localeId}),null}}},isFontDescriptor=f=>typeof f==`object`&&!(f instanceof Uint8Array),defaultFetchFonts=async localeId=>{try{let fonts=await googleFonts([{name:`Noto Sans Arabic`,weight:[400,700]}]),fontFamilies=fonts.filter(isFontDescriptor).map(f=>String(f.name??``)).filter(Boolean);return logDebug(`googleFonts resolved per locale`,{count:fonts.length,localeId,names:fonts.filter(isFontDescriptor).map(f=>f.name)}),{fontFamilies,fonts}}catch(error){return logDebug(`googleFonts failed for locale`,{error:String(error),localeId}),null}},mergeLocaleProps=(props,localeData)=>{if(!localeData)return{...props};let merged={...props};for(let[key,value]of Object.entries(localeData))(Array.isArray(value)&&Array.isArray(merged[key])||typeof value==`string`&&typeof merged[key]==`string`)&&(merged[key]=value);return merged},classifyLocale=localeId=>{let isArabic=localeId.startsWith(`ar`);return{lang:isArabic?`ar`:void 0,needsArabic:isArabic}},fontCache=new Map,clearFontCache=()=>{fontCache.clear()},resolveAssetsForLocale=async(localeId,props,options)=>{let localeData=options?.localeData;if(options?.loadLocaleData)try{localeData=await options.loadLocaleData(localeId)}catch{localeData={}}let mergedProps=mergeLocaleProps(props,localeData),{lang,needsArabic}=classifyLocale(localeId),fonts=[],fontFamilies;if(options?.preResolvedFonts)fonts=options.preResolvedFonts,fontFamilies=options.preResolvedFontFamilies;else if(needsArabic){let fetch=options?.fetchFonts??defaultFetchFonts,cached=fontCache.get(localeId),resolution;if(cached)resolution=await cached;else{let promise=fetch(localeId);fontCache.set(localeId,promise),resolution=await promise,resolution||fontCache.delete(localeId)}resolution&&({fontFamilies,fonts}=resolution)}return{fontFamilies,fonts,lang,props:mergedProps}},templateCache=new Map,loadTemplateForBatch=async(fullPath,templateId)=>{let content;try{content=await readFile(fullPath,`utf-8`)}catch{throw new AppError(ErrorCode.TEMPLATE_LOAD_FAILED,`Template file not found: ${fullPath}`,{componentPath:fullPath,templateId})}let contentHash=createHash(`sha256`).update(content).digest(`hex`).slice(0,16);logDebug(`Loading template`,{path:fullPath,templateId});let mod=await import(pathToFileURL(fullPath).href);if(typeof mod.default!=`function`)throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT,`Template "${templateId}" must have a default export that is a component function.`,{templateId});let{propsSchema}=mod;if(!isZodObject(propsSchema))throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT,`Template "${templateId}" must export a "propsSchema" that is a z.object({...}).`,{templateId});return{contentHash,module:mod}},importTemplateForBatch=async(rootDir,componentPath,templateId)=>{let fullPath=path.resolve(rootDir,`${componentPath}.tsx`),cached=templateCache.get(fullPath);if(cached)return cached;let promise=loadTemplateForBatch(fullPath,templateId);templateCache.set(fullPath,promise);try{return await promise}catch{throw templateCache.delete(fullPath),await promise.catch(error=>error)}},loadConfig=async rootDir=>{let{configPath}=resolveProjectPaths(rootDir);try{await readFile(configPath,`utf-8`)}catch{throw new AppError(ErrorCode.CONFIG_NOT_FOUND,`No studio.config.ts found in ${rootDir}.`)}logDebug(`Loading studio.config.ts`,{path:configPath});let mod=await import(pathToFileURL(configPath).href),result=parseStudioConfig(mod.default??mod);if(`issues`in result)throw new AppError(ErrorCode.CONFIG_INVALID,`studio.config.ts is invalid: ${result.issues}`);return result.config},parseDataFile=async filePath=>{let content=await readFile(filePath,`utf-8`);if(filePath.endsWith(`.json`)){let data=JSON.parse(content);if(Array.isArray(data))return data;throw new AppError(ErrorCode.API_ERROR,`JSON data file must contain an array of objects.`)}if(filePath.endsWith(`.csv`)){let{parse}=await import(`csv-parse/sync`);return parse(content,{columns:!0,skip_empty_lines:!0,trim:!0})}throw new AppError(ErrorCode.API_ERROR,`Data file must be .csv or .json.`)},resolveLocales=async(rootDir,localeCodes,templateId)=>{if(localeCodes.length===0)return[{id:`default`}];let{localesDir}=resolveProjectPaths(rootDir);return await Promise.all(localeCodes.map(async code=>{let localePath=path.resolve(localesDir,`${code}.json`);try{let content=await readFile(localePath,`utf-8`);return{data:JSON.parse(content)[templateId],id:code}}catch{return{id:code}}}))},generateOutputFilename=options=>{let{templateId,locale,presetId,rowKey,format=`png`}=options;return`${templateId}.${locale}.${presetId}.${rowKey}.${format}`};var BoundedImageFetchCache=class{maxEntries;store=new Map;constructor(maxEntries=500){this.maxEntries=maxEntries}get size(){return this.store.size}get(url){let value=this.store.get(url);if(value!==void 0)return this.store.delete(url),this.store.set(url,value),value}set(url,data){if(this.store.delete(url),this.store.set(url,data),this.store.size>this.maxEntries){let oldest=this.store.keys().next().value;oldest!==void 0&&this.store.delete(oldest)}}delete(url){this.store.delete(url)}clear(){this.store.clear()}},ImagePolicyBlockedError=class extends Error{url;constructor(url){super(`Blocked by image fetch policy: ${url}`),this.name=`ImagePolicyBlockedError`,this.url=url}};const denyAll=()=>!1;let sharedFetchCache=null;const getSharedFetchCache=()=>(sharedFetchCache||=new BoundedImageFetchCache,sharedFetchCache),isAllowUrlBlockedError=error=>error instanceof Error&&error.message.startsWith(`URL blocked by allowUrl policy: `),resolveImages=async(node,policy,deps)=>{let prepareImagesFn=deps?.prepareImages??prepareImages,extractEmojisFn=deps?.extractEmojis??extractEmojis,fetchCache=deps?.fetchCache??getSharedFetchCache(),resolvedNode=policy?.emoji&&policy.emoji!==`from-font`?extractEmojisFn(node,policy.emoji):node;try{let images=await prepareImagesFn({allowUrl:policy?.allowUrl??denyAll,fetch:policy?.fetch,fetchCache,maxBytes:policy?.maxBytes,node:resolvedNode,timeout:policy?.timeout});return logDebug(`image-resolver: resolved images`,{count:images.length}),{images,node:resolvedNode}}catch(error){throw isAllowUrlBlockedError(error)?new ImagePolicyBlockedError(error.message.slice(32)):(logWarn(`image-resolver: image fetch failed`,{error:error instanceof Error?error.message:String(error)}),error)}},prepPipeline=async input=>{let{compiledTemplate:preCompiled,component,templateContentHash,templateId,props,propsSchema,locale,loadLocaleData,preResolvedFonts,preResolvedFontFamilies,fetchFonts,images,cache}=input,{fonts:resolvedFonts,fontFamilies:resolvedFontFamilies,lang:resolvedLang,props:merged}=await resolveAssetsForLocale(locale,propsSchema?validateProps(propsSchema,props):{...props},{fetchFonts,loadLocaleData,preResolvedFontFamilies,preResolvedFonts}),propsJSON=JSON.stringify(merged),compileKey=RenderCache.createCompileCacheKey({propsJSON,templateContentHash,templateId}),compiled=preCompiled;if(!compiled){if(!component)throw Error(`Must provide either compiledTemplate or component`);let cachedCompile=cache.getCompiled(compileKey);cachedCompile?compiled=cachedCompile:(compiled=await compileTemplate(createElement(component,merged)),cache.setCompiledWithContentHash(compileKey,templateContentHash,compiled))}let{node:resolvedNode,images:resolvedImages}=await resolveImages(compiled.node,images),renderReady=resolvedNode===compiled.node?compiled:{...compiled,node:resolvedNode};return{compileKey,compiled,mergedProps:merged,propsJSON,renderReady,resolvedFontFamilies,resolvedFonts,resolvedImages,resolvedLang}},orchestrateRender=async input=>{let start=performance.now(),{compiledTemplate,component,templateContentHash,templateId,props,propsSchema,locale:localeId,loadLocaleData,fetchFonts,fonts:preResolvedFonts,fontFamilies:preResolvedFontFamilies,images,width,height,cache,format=`png`,signal}=input,prep=await prepPipeline({cache,compiledTemplate,component,fetchFonts,images,loadLocaleData,locale:localeId??`default`,preResolvedFontFamilies,preResolvedFonts,props,propsSchema,templateContentHash,templateId}),pixelKey=RenderCache.createPixelCacheKey({format,height,propsJSON:prep.propsJSON,templateContentHash,width}),cached=await cache.getPixels(pixelKey);if(cached)return{bytes:cached,cacheHit:!0,durationMs:performance.now()-start,format,height,width};let output=await renderToPixels(prep.renderReady,{height,width},{fontFamilies:(prep.resolvedFontFamilies??[]).length>0?prep.resolvedFontFamilies:void 0,fonts:prep.resolvedFonts.length>0?prep.resolvedFonts:void 0,format,images:prep.resolvedImages.length>0?prep.resolvedImages:void 0,lang:prep.resolvedLang},signal);try{await cache.setPixels(pixelKey,output.bytes,width,height,format)}catch(error){logWarn(`Failed to write to pixel cache`,{error:String(error),pixelKey})}return{bytes:output.bytes,cacheHit:!1,durationMs:performance.now()-start,format,height:output.height,width:output.width}},orchestrateMeasure=async input=>{let start=performance.now(),{compiledTemplate,component,templateContentHash,templateId,props,propsSchema,locale:localeId,loadLocaleData,fetchFonts,images,cache,signal}=input,prep=await prepPipeline({cache,compiledTemplate,component,fetchFonts,images,loadLocaleData,locale:localeId??`default`,preResolvedFontFamilies:void 0,preResolvedFonts:void 0,props,propsSchema,templateContentHash,templateId}),measured=await measureTemplate(prep.renderReady,{fontFamilies:(prep.resolvedFontFamilies??[]).length>0?prep.resolvedFontFamilies:void 0,fonts:prep.resolvedFonts.length>0?prep.resolvedFonts:void 0,images:prep.resolvedImages.length>0?prep.resolvedImages:void 0,lang:prep.resolvedLang},signal);return{durationMs:performance.now()-start,height:measured.height,width:measured.width}},ensureDir=async dir=>{existsSync(dir)||await mkdir(dir,{recursive:!0})},tryWriteFile=async(filePath,data)=>{try{await writeFile(filePath,data)}catch(error){logWarn(`Failed to write output file`,{error:String(error),filePath})}},noop=()=>{},noopCache={getCompiled:()=>null,getPixels:()=>null,setCompiledWithContentHash:noop,setPixels:noop},renderComponent=async input=>{let{component,format=`png`,images,outDir=`./out`,preset,props,propsSchema}=input,result=await orchestrateRender({cache:noopCache,component,format,height:preset.height,images,props,propsSchema,templateContentHash:`single`,templateId:`single`,width:preset.width}),outputPath=`${outDir}/${generateOutputFilename({locale:`default`,presetId:preset.id,rowKey:`single`,templateId:`single`})}`;return await ensureDir(outDir),await writeFile(outputPath,result.bytes),{outputPath}},writeManifest=async(filePath,manifest)=>{await mkdir(path.dirname(filePath),{recursive:!0}),await writeFile(filePath,JSON.stringify(manifest,null,2))},expandMatrix=input=>{let{rows,locales,presets,baseProps={}}=input;if(rows.length===0)return[];let cells=[];for(let[rowIndex,row]of rows.entries()){let rowKey=String(row.key??row.id??row.slug??String(rowIndex)),rowProps={...baseProps,...row};delete rowProps.key,delete rowProps.id,delete rowProps.slug;for(let localeId of locales)for(let preset of presets)cells.push({locale:localeId,preset:{height:preset.height,id:preset.id,width:preset.width},props:{...rowProps,locale:localeId},rowIndex,rowKey})}return cells},runBatch=async input=>{let start=performance.now(),{baseProps={},dataFile,failFast=!1,cacheDir:cacheDirOverride,locales:filterLocales,outDir:outDirOverride,presets:filterPresets,rootDir,rows:inlineRows,templateId}=input,config=await loadConfig(rootDir),fetchFonts=createFetchFontsFromConfig(config.fonts,rootDir),entry=config.templates[templateId];if(!entry)throw Error(`Template not found: ${templateId}`);let{contentHash,module}=await importTemplateForBatch(rootDir,entry.componentPath,templateId),Component=module.default,presets=(filterPresets??config.presets.map(p=>p.id)).map(id=>{let p=config.presets.find(preset=>preset.id===id);if(!p)throw Error(`Preset not found: ${id}`);return{height:p.height,id:p.id,width:p.width}}),resolvedLocales=await resolveLocales(rootDir,filterLocales??config.locales??[],templateId),localeDataMap=new Map(resolvedLocales.map(l=>[l.id,l.data??{}])),loadLocaleData=code=>Promise.resolve(localeDataMap.get(code)??{}),rows=dataFile?await parseDataFile(path.resolve(rootDir,dataFile)):inlineRows??[],cells=expandMatrix({baseProps,locales:resolvedLocales.map(l=>l.id),presets,rows}),outDir=path.resolve(rootDir,outDirOverride??config.outDir??`./out`),cache=await openCache(rootDir,{cacheDir:cacheDirOverride}),succeeded=[],failed=[];try{for(let cell of cells)try{let filename=generateOutputFilename({locale:cell.locale,presetId:cell.preset.id,rowKey:cell.rowKey,templateId}),result=await orchestrateRender({cache,component:Component,fetchFonts,format:`png`,height:cell.preset.height,images:config.images,loadLocaleData,locale:cell.locale,props:cell.props,propsSchema:module.propsSchema,templateContentHash:contentHash,templateId,width:cell.preset.width}),outputPath=`${outDir}/${filename}`;await ensureDir(outDir),await tryWriteFile(outputPath,result.bytes),succeeded.push({cacheHit:result.cacheHit,locale:cell.locale,outputPath,preset:cell.preset.id,rowIndex:cell.rowIndex})}catch(error){if(failed.push({error:error instanceof Error?error.message:String(error),locale:cell.locale,preset:cell.preset.id,rowIndex:cell.rowIndex}),failFast)throw error}}finally{cache.close()}let manifest={completedCount:cells.length,failed,status:failFast&&failed.length>0?`aborted`:`completed`,succeeded,totalCount:cells.length};return await writeManifest(`${outDir}/manifest.json`,manifest),{elapsedMs:performance.now()-start,manifest}};export{AppError,ErrorCode,PropValidationError,RenderCache,clearFontCache,compileTemplate,createFetchFontsFromConfig,importTemplateForBatch,isZodObject,loadConfig,logDebug,logError,logInfo,logWarn,measureTemplate,openCache,orchestrateMeasure,orchestrateRender,parseStudioConfig,registerFont,renderAutoSized,renderComponent,renderToPixels,resolveAssetsForLocale,resolveProjectPaths,runBatch,studioConfigSchema,validateProps,zodToJsonSchemaShape};
|
|
28
|
+
`.execute(db)}close(){this.closeDb&&=(this.closeDb(),void 0),this.db=void 0,this.initialized=!1,this.initPromise=void 0}deleteDatabaseFiles(){if(this.dbPath!==`:memory:`)for(let suffix of[``,`-wal`,`-shm`])try{unlinkSync(`${this.dbPath}${suffix}`)}catch{}}async upsertEntry(input){let db=this.requireDb(),now=Date.now();await db.insertInto(`pixel_cache_entries`).values({content_hash:input.contentHash,created_at:now,file_name:input.fileName,hash:input.hash,height:input.height,last_accessed_at:now,size_bytes:input.sizeBytes,width:input.width}).onConflict(oc=>oc.column(`hash`).doUpdateSet({content_hash:input.contentHash,file_name:input.fileName,height:input.height,last_accessed_at:now,size_bytes:input.sizeBytes,width:input.width})).execute()}async getEntry(hash){let row=await this.requireDb().selectFrom(`pixel_cache_entries`).selectAll().where(`hash`,`=`,hash).executeTakeFirst();return row?toRow(row):null}async touchAccess(hash,timestamp=Date.now()){await this.requireDb().updateTable(`pixel_cache_entries`).set({last_accessed_at:timestamp}).where(`hash`,`=`,hash).execute()}async deleteEntry(hash){await this.requireDb().deleteFrom(`pixel_cache_entries`).where(`hash`,`=`,hash).execute()}async deleteAll(){await this.requireDb().deleteFrom(`pixel_cache_entries`).execute()}async listAllOrderedByLastAccessed(){return(await this.requireDb().selectFrom(`pixel_cache_entries`).selectAll().orderBy(`last_accessed_at`,`asc`).execute()).map(toRow)}async getTotals(){let row=await this.requireDb().selectFrom(`cache_totals`).select([`total_size_bytes`,`entry_count`]).where(`id`,`=`,1).executeTakeFirstOrThrow();return{entryCount:row.entry_count,totalSizeBytes:row.total_size_bytes}}requireDb(){if(!this.db)throw Error(`MetadataStore.init() must be called before use.`);return this.db}};const RECOVERY_COOLDOWN_MS=2e3,isCacheEntryFileName=name=>/^[0-9a-f]{32,}\.[a-z]+$/u.test(name);var RenderCache=class{cacheDir;maxSizeBytes;compiled;maxConcurrentWrites;evictionDebounceMs;metadata;fsStore;eviction;initialized=!1;recoveryPromise=null;lastRecoveryAt=0;static createCompileCacheKey=createCompileCacheKey;static createPixelCacheKey=createPixelCacheKey;constructor(options){this.cacheDir=options?.cacheDir??`.studio-cache`,this.maxSizeBytes=(options?.maxSizeMB??500)*1024*1024,this.compiled=new CompileCache(options?.compiledMaxEntries),this.maxConcurrentWrites=options?.maxConcurrentWrites,this.evictionDebounceMs=options?.evictionDebounceMs}async init(){if(this.initialized)return;existsSync(this.cacheDir)||await mkdir(this.cacheDir,{recursive:!0}),this.metadata=new MetadataStore(this.cacheDir),await this.metadata.init();let limiter=new ConcurrencyLimiter(this.maxConcurrentWrites);this.fsStore=new FsStore(limiter),this.eviction=new EvictionCoordinator({debounceMs:this.evictionDebounceMs,getCurrentTotalBytes:async()=>(await this.requireMetadata().getTotals()).totalSizeBytes,lockTargetPath:this.cacheDir,maxSizeBytes:this.maxSizeBytes,sweep:options=>this.performSweep(options)}),this.initialized=!0}close(){this.eviction?.cancelPending(),this.metadata?.close(),this.initialized=!1}getCompiled(key){return this.compiled.get(key)}setCompiled(key,value){this.compiled.set(key,value)}setCompiledWithContentHash(key,contentHash,value){this.compiled.setWithContentHash(key,contentHash,value)}invalidateCompiledByContentHash(contentHash){this.compiled.invalidateByContentHash(contentHash)}clearCompiled(){this.compiled.clear()}async getPixels(key){let metadata=this.requireMetadata(),fsStore=this.requireFsStore(),row;try{row=await metadata.getEntry(key)}catch(error){return logWarn(`Pixel metadata read failed, attempting recovery`,{error:error instanceof Error?error.message:String(error),key}),await this.recoverMetadata(),null}if(!row)return null;let filePath=path.join(this.cacheDir,row.fileName),bytes=await fsStore.readVerified(filePath,row.sizeBytes,row.contentHash);if(bytes===null){try{await metadata.deleteEntry(key)}catch{}return null}try{await metadata.touchAccess(key)}catch(error){logWarn(`Pixel touchAccess failed, attempting recovery`,{error:error instanceof Error?error.message:String(error),key}),await this.recoverMetadata()}return bytes}async setPixels(key,bytes,width,height,format=`png`){let metadata=this.requireMetadata(),fsStore=this.requireFsStore(),fileName=`${key}.${format}`,filePath=path.join(this.cacheDir,fileName),contentHash=fsStore.computeHash(bytes);try{await fsStore.writeAtomic(filePath,bytes)}catch(error){if(error?.code===`ENOENT`)logDebug(`FsStore: ENOENT during writeAtomic, recreating cache directory and retrying`,{filePath}),await mkdir(this.cacheDir,{recursive:!0}),await fsStore.writeAtomic(filePath,bytes);else throw error}try{await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width})}catch(error){let err=error;if(err?.code===`SQLITE_IOERR_VNODE`||(err?.message??``).includes(`disk I/O error`))logWarn(`Metadata write failed (I/O error), recovering and retrying`,{error:String(error),key}),await this.recoverMetadata(),metadata=this.requireMetadata(),await metadata.upsertEntry({contentHash,fileName,hash:key,height,sizeBytes:bytes.length,width});else throw error}return this.requireEviction().requestEviction(),filePath}async stats(){let totals=await this.requireMetadata().getTotals();return{cacheDir:this.cacheDir,compiled:{entries:this.compiled.size},pixels:{entries:totals.entryCount,maxSizeBytes:this.maxSizeBytes,sizeBytes:totals.totalSizeBytes}}}clean(){return this.compiled.clear(),this.requireEviction().runFullSweep()}async performSweep(options){let metadata=this.requireMetadata(),fsStore=this.requireFsStore();if(options.full){let rows=await metadata.listAllOrderedByLastAccessed(),freedAmounts=await Promise.all(rows.map(row=>fsStore.delete(path.join(this.cacheDir,row.fileName),row.sizeBytes)));await metadata.deleteAll();let orphanFreed=await this.deleteOrphanFiles(new Set(rows.map(r=>r.fileName)),fsStore);return{freedBytes:freedAmounts.reduce((sum,n)=>sum+n,0)+orphanFreed}}let totals=await metadata.getTotals();if(totals.totalSizeBytes<=this.maxSizeBytes)return{freedBytes:0};let rows=await metadata.listAllOrderedByLastAccessed(),remaining=totals.totalSizeBytes,rowsToEvict=[];for(let row of rows){if(remaining<=this.maxSizeBytes)break;rowsToEvict.push(row),remaining-=row.sizeBytes}return{freedBytes:(await Promise.all(rowsToEvict.map(async row=>{let freedForRow=await fsStore.delete(path.join(this.cacheDir,row.fileName),row.sizeBytes);return await metadata.deleteEntry(row.hash),freedForRow}))).reduce((sum,n)=>sum+n,0)}}async deleteOrphanFiles(knownFileNames,fsStore){let freed=0;try{let entries=await readdir(this.cacheDir);await Promise.all(entries.map(async entry=>{if(knownFileNames.has(entry)||!isCacheEntryFileName(entry))return;let filePath=path.join(this.cacheDir,entry);logDebug(`FsStore: removing orphaned cache file`,{filePath}),freed+=await fsStore.delete(filePath)}))}catch(error){logWarn(`RenderCache: failed to scan for orphaned files`,{error:String(error)})}return freed}async recoverMetadata(){if(Date.now()-this.lastRecoveryAt<RECOVERY_COOLDOWN_MS){logDebug(`RenderCache: skipping recovery — within cooldown window`,{cooldownMs:RECOVERY_COOLDOWN_MS});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};
|
package/dist/og/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{i as renderToPixels,l as compileTemplate}from"../renderer-B2yMpMcX.mjs";import{jsx,jsxs}from"react/jsx-runtime";import{ImageResponse}from"takumi-js/response";const getTitleSize=length=>length>60?`48px`:length>35?`60px`:`76px`,truncateText=(text,maxLength=130)=>text.length<=maxLength?text:`${text.slice(0,maxLength).trimEnd()}…`,StandardOgTemplate=({title,description,siteName})=>{let titleSize=getTitleSize(title.length),truncatedDescription=description?truncateText(description,130):null;return jsxs(`div`,{style:{backgroundColor:`#0a0a0b`,color:`#e4e4e7`,display:`flex`,flexDirection:`column`,fontFamily:`system-ui, -apple-system, BlinkMacSystemFont, sans-serif`,height:`100%`,justifyContent:`space-between`,padding:`64px 72px 56px`,position:`relative`,width:`100%`},children:[jsx(`div`,{style:{background:`linear-gradient(90deg, #6366f1 0%, #818cf8 100%)`,height:`4px`,left:`0`,position:`absolute`,right:`0`,top:`0`}}),jsxs(`div`,{style:{alignItems:`center`,display:`flex`,gap:`10px`},children:[jsx(`div`,{style:{backgroundColor:`#6366f1`,borderRadius:`50%`,flexShrink:`0`,height:`10px`,width:`10px`}}),siteName?jsx(`span`,{style:{color:`#71717a`,fontSize:`18px`,fontWeight:`500`,letterSpacing:`0.04em`,textTransform:`uppercase`},children:siteName}):null]}),jsxs(`div`,{style:{display:`flex`,flex:`1`,flexDirection:`column`,justifyContent:`center`,maxWidth:`860px`,paddingTop:`24px`},children:[jsx(`h1`,{style:{color:`#fafafa`,fontSize:titleSize,fontWeight:`700`,letterSpacing:`-0.02em`,lineHeight:`1.1`,margin:`0`},children:title}),truncatedDescription?jsx(`p`,{style:{color:`#a1a1aa`,display:`flex`,fontSize:`26px`,fontWeight:`400`,lineHeight:`1.5`,margin:`0`,marginTop:`24px`},children:truncatedDescription}):null]}),jsx(`div`,{style:{backgroundColor:`#27272a`,height:`1px`,marginBottom:`0`,width:`100%`}})]})},OG_CACHE_CONTROL=`public, s-maxage=31536000, max-age=3600, immutable`,createOgErrorResponse=error=>(error&&console.error(`[og] render failed`,error),new Response(`Failed to generate OG image`,{headers:{"Cache-Control":`no-store`,"Content-Type":`text/plain; charset=utf-8`},status:500})),createOgResponse=async(element,options)=>{let{locale,format:explicitFormat,height:explicitHeight,width:explicitWidth,devicePixelRatio:explicitDpr,...rest}=options??{},response=new ImageResponse(element,{devicePixelRatio:explicitDpr??2,format:explicitFormat??`webp`,headers:{"Cache-Control":OG_CACHE_CONTROL,Vary:`Accept`},height:explicitHeight??630,...rest,lang:locale??rest.lang,onError:error=>{console.error(`[og] render failed`,error)},width:explicitWidth??1200});try{return await response.ready,response}catch(error){return console.error(`[og] returning 500 after render failure`,error),createOgErrorResponse()}},renderOgResponse=async(element,options)=>{let width=options?.width??1200,height=options?.height??630,format=options?.format??`webp`;try{let{bytes}=await renderToPixels(
|
|
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};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stdout-design/core",
|
|
3
|
-
"version": "0.4.
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsdown",
|
|
46
46
|
"dev": "tsdown --watch",
|
|
47
|
-
"test": "bun test",
|
|
47
|
+
"test": "bun test --isolate",
|
|
48
48
|
"test:integration": "bun test ./src/__tests__/full-pipeline.integration.ts",
|
|
49
49
|
"check-types": "tsc --noEmit",
|
|
50
50
|
"check-types:tsgo": "tsgo --noEmit",
|