@ogify/core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  import satori from 'satori';
2
2
  import { html } from 'satori-html';
3
- import { Resvg } from '@resvg/resvg-js';
3
+ import { renderAsync } from '@resvg/resvg-js';
4
+ import { LRUCache } from 'lru-cache';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as crypto from 'crypto';
8
+ import rtlCSSJS from 'rtl-css-js';
4
9
 
5
10
  // src/template.ts
6
11
 
@@ -169,6 +174,216 @@ var GoogleFontDetector = class {
169
174
  }
170
175
  }
171
176
  };
177
+ var DEFAULT_TTL = 1e3 * 60 * 60 * 24 * 7;
178
+ var DEFAULT_MAX = 100;
179
+ var DEFAULT_CACHE_DIR = ".ogify-cache";
180
+ var CacheManager = class {
181
+ cache;
182
+ config;
183
+ cacheDir = DEFAULT_CACHE_DIR;
184
+ /**
185
+ * Creates a new CacheManager instance.
186
+ *
187
+ * @param config - Cache configuration (memory or filesystem)
188
+ */
189
+ constructor(config) {
190
+ this.config = config;
191
+ if (config.type === "memory") {
192
+ this.cache = new LRUCache({
193
+ max: config.max || DEFAULT_MAX,
194
+ ttl: config.ttl || DEFAULT_TTL
195
+ });
196
+ }
197
+ if (config.type === "filesystem") {
198
+ this.cacheDir = config.dir || DEFAULT_CACHE_DIR;
199
+ this.ensureCacheDirectory();
200
+ this.cleanExpiredFiles().catch((err) => {
201
+ console.warn("Failed to clean expired cache files:", err);
202
+ });
203
+ }
204
+ }
205
+ // eslint-disable-next-line
206
+ generateKey(object) {
207
+ const sorted = Object.keys(object).sort().map((key) => `${key}=${object[key]}`);
208
+ return crypto.createHash("md5").update(sorted.join("|")).digest("hex");
209
+ }
210
+ /**
211
+ * Retrieves a value from the cache.
212
+ *
213
+ * For filesystem cache, attempts to load from disk if not in memory.
214
+ *
215
+ * @param key - Cache key
216
+ * @returns Cached buffer or undefined if not found
217
+ */
218
+ async get(key) {
219
+ if (this.config.type === "memory") {
220
+ return this.cache?.get(key);
221
+ }
222
+ if (this.config.type === "filesystem") {
223
+ return this.loadFromDisk(key);
224
+ }
225
+ return void 0;
226
+ }
227
+ /**
228
+ * Stores a value in the cache.
229
+ *
230
+ * For filesystem cache, also persists to disk.
231
+ *
232
+ * @param key - Cache key
233
+ * @param value - Buffer to cache
234
+ */
235
+ async set(key, value) {
236
+ if (this.config.type === "memory") {
237
+ this.cache?.set(key, value);
238
+ }
239
+ if (this.config.type === "filesystem") {
240
+ await this.saveToDisk(key, value);
241
+ }
242
+ }
243
+ /**
244
+ * Checks if a key exists in the cache.
245
+ *
246
+ * @param key - Cache key
247
+ * @returns true if the key exists and is not expired
248
+ */
249
+ async has(key) {
250
+ if (this.config.type === "memory") {
251
+ return this.cache?.has(key) || false;
252
+ }
253
+ if (this.config.type === "filesystem") {
254
+ try {
255
+ await fs.promises.access(path.join(this.cacheDir, this.getFilename(key)));
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+ return false;
262
+ }
263
+ /**
264
+ * Clears all cached items.
265
+ *
266
+ * For filesystem cache, also removes all files from disk.
267
+ */
268
+ async clear() {
269
+ if (this.config.type === "memory") {
270
+ this.cache?.clear();
271
+ }
272
+ if (this.config.type === "filesystem") {
273
+ await this.clearFilesystem();
274
+ }
275
+ }
276
+ /**
277
+ * Ensures the cache directory exists.
278
+ * Creates it if it doesn't exist.
279
+ */
280
+ ensureCacheDirectory() {
281
+ if (!this.cacheDir) return;
282
+ if (!fs.existsSync(this.cacheDir)) {
283
+ fs.mkdirSync(this.cacheDir, { recursive: true });
284
+ }
285
+ }
286
+ /**
287
+ * Generates a safe filename from a cache key.
288
+ *
289
+ * Uses SHA-256 hash to avoid filesystem issues with special characters.
290
+ *
291
+ * @param key - Cache key
292
+ * @returns Safe filename
293
+ */
294
+ getFilename(key) {
295
+ const hash = crypto.createHash("sha256").update(key).digest("hex");
296
+ return `${hash}.cache`;
297
+ }
298
+ /**
299
+ * Loads a cached item from disk.
300
+ *
301
+ * @param key - Cache key
302
+ * @returns Cached buffer or undefined if not found or expired
303
+ */
304
+ async loadFromDisk(key) {
305
+ if (this.config.type !== "filesystem") return void 0;
306
+ const filename = this.getFilename(key);
307
+ const filepath = path.join(this.cacheDir, filename);
308
+ try {
309
+ try {
310
+ await fs.promises.access(filepath);
311
+ } catch {
312
+ return void 0;
313
+ }
314
+ const stats = await fs.promises.stat(filepath);
315
+ const age = Date.now() - stats.mtimeMs;
316
+ const ttl = this.config.ttl || DEFAULT_TTL;
317
+ if (age > ttl) {
318
+ await fs.promises.unlink(filepath).catch(() => {
319
+ });
320
+ return void 0;
321
+ }
322
+ const data = await fs.promises.readFile(filepath);
323
+ this.cache?.set(key, data);
324
+ return data;
325
+ } catch (error) {
326
+ return void 0;
327
+ }
328
+ }
329
+ /**
330
+ * Saves a cached item to disk.
331
+ *
332
+ * @param key - Cache key
333
+ * @param value - Buffer to save
334
+ */
335
+ async saveToDisk(key, value) {
336
+ if (this.config.type !== "filesystem") return;
337
+ const filename = this.getFilename(key);
338
+ const filepath = path.join(this.cacheDir, filename);
339
+ try {
340
+ await fs.promises.writeFile(filepath, value);
341
+ } catch (error) {
342
+ }
343
+ }
344
+ /**
345
+ * Loads all cached items from filesystem into memory on initialization.
346
+ */
347
+ async cleanExpiredFiles() {
348
+ if (this.config.type !== "filesystem") return;
349
+ try {
350
+ const files = await fs.promises.readdir(this.cacheDir);
351
+ for (const file of files) {
352
+ if (!file.endsWith(".cache")) continue;
353
+ const filepath = path.join(this.cacheDir, file);
354
+ try {
355
+ const stats = await fs.promises.stat(filepath);
356
+ const age = Date.now() - stats.mtimeMs;
357
+ const ttl = this.config.ttl || DEFAULT_TTL;
358
+ if (age > ttl) {
359
+ await fs.promises.unlink(filepath).catch(() => {
360
+ });
361
+ }
362
+ } catch {
363
+ }
364
+ }
365
+ } catch (error) {
366
+ }
367
+ }
368
+ /**
369
+ * Clears all files from the filesystem cache directory.
370
+ */
371
+ async clearFilesystem() {
372
+ if (this.config.type !== "filesystem") return;
373
+ const cacheDir = this.cacheDir;
374
+ try {
375
+ const files = await fs.promises.readdir(cacheDir);
376
+ const unlinkPromises = files.map(async (file) => {
377
+ if (!file.endsWith(".cache")) return;
378
+ const filepath = path.join(cacheDir, file);
379
+ await fs.promises.unlink(filepath).catch(() => {
380
+ });
381
+ });
382
+ await Promise.all(unlinkPromises);
383
+ } catch (error) {
384
+ }
385
+ }
386
+ };
172
387
 
173
388
  // src/utils/emoji-loader.ts
174
389
  var ZERO_WIDTH_JOINER = String.fromCharCode(8205);
@@ -204,12 +419,15 @@ var apis = {
204
419
  fluent: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`,
205
420
  fluentFlat: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`
206
421
  };
207
- var cache = {};
422
+ var cache = new CacheManager({
423
+ type: "memory"
424
+ });
208
425
  async function loadEmoji(type, text) {
209
426
  const code = getIconCode(text);
210
427
  const cacheKey = `${type}:${code}`;
211
- if (cacheKey in cache) {
212
- return cache[cacheKey];
428
+ const cached = await cache.get(cacheKey);
429
+ if (cached) {
430
+ return cached.toString();
213
431
  }
214
432
  if (!type || !apis[type]) {
215
433
  type = "noto";
@@ -218,19 +436,22 @@ async function loadEmoji(type, text) {
218
436
  const baseUrl = typeof api === "function" ? api(code) : api;
219
437
  const fullUrl = typeof api === "function" ? baseUrl : `${baseUrl}${code.toUpperCase()}.svg`;
220
438
  const emojiPromise = fetch(fullUrl).then((response) => response.text()).then((svgContent) => `data:image/svg+xml;base64,${btoa(svgContent)}`);
221
- cache[cacheKey] = emojiPromise;
439
+ await cache.set(cacheKey, Buffer.from(await emojiPromise));
222
440
  return emojiPromise;
223
441
  }
224
442
 
225
- // src/utils/fetcher.ts
226
- var cache2 = {};
443
+ // src/utils/font-fetcher.ts
444
+ var cache2 = new CacheManager({
445
+ type: "memory"
446
+ });
227
447
  var loadFontFromUrl = async (url) => {
228
- if (url in cache2) {
229
- return cache2[url];
448
+ const cachedFont = await cache2.get(url);
449
+ if (cachedFont) {
450
+ return cachedFont;
230
451
  }
231
- const fontData = fetch(url).then((response) => response.arrayBuffer());
232
- cache2[url] = fontData;
233
- return fontData;
452
+ const fontData = await fetch(url).then((response) => response.arrayBuffer());
453
+ await cache2.set(url, Buffer.from(fontData));
454
+ return Buffer.from(fontData);
234
455
  };
235
456
 
236
457
  // src/utils/additional-asset-loader.ts
@@ -308,11 +529,12 @@ var DEFAULT_HEIGHT = 630;
308
529
  async function renderTemplate(template, params, options) {
309
530
  const width = options?.width || DEFAULT_WIDTH;
310
531
  const height = options?.height || DEFAULT_HEIGHT;
311
- const satoriFonts = await loadFonts(template.fonts);
532
+ const fonts = options?.fonts?.length ? options.fonts : template.fonts;
533
+ const emojiProvider = options?.emojiProvider || template.emojiProvider || "noto";
534
+ const satoriFonts = await loadFonts(fonts);
312
535
  const htmlString = await template.renderer({
313
536
  params: typeof params === "function" ? await params() : params,
314
- width,
315
- height
537
+ ...options
316
538
  });
317
539
  const element = html(htmlString);
318
540
  const svg = await satori(element, {
@@ -331,32 +553,25 @@ async function renderTemplate(template, params, options) {
331
553
  // Asset type ('emoji' or other)
332
554
  segment,
333
555
  // The character(s) to load
334
- fonts: template.fonts,
556
+ fonts,
335
557
  // Available fonts for fallback detection
336
- emojiProvider: template.emojiProvider || "noto"
558
+ emojiProvider
337
559
  // Emoji provider (default: noto)
338
560
  });
339
561
  }
340
562
  });
341
- const resvg = new Resvg(svg, {
563
+ const pngData = await renderAsync(svg, {
342
564
  fitTo: {
343
565
  mode: "width",
344
566
  // Scale based on width, maintain aspect ratio
345
567
  value: width
346
568
  }
347
569
  });
348
- const pngData = resvg.render();
349
570
  return Buffer.from(pngData.asPng());
350
571
  }
351
572
 
352
573
  // src/renderer.ts
353
574
  function validateTemplate(config) {
354
- if (!config.id) {
355
- throw new Error("Template must have an id");
356
- }
357
- if (!config.name) {
358
- throw new Error("Template must have a name");
359
- }
360
575
  if (typeof config.renderer !== "function") {
361
576
  throw new Error("Template must have a renderer function");
362
577
  }
@@ -366,9 +581,14 @@ function defineTemplate(config) {
366
581
  validateTemplate(config);
367
582
  return config;
368
583
  }
584
+ var DEFAULT_CACHE = {
585
+ type: "memory"
586
+ };
369
587
  var TemplateRenderer = class {
370
588
  /** Configuration including global settings and lifecycle hooks */
371
589
  config;
590
+ /** Cache manager for fonts and icons */
591
+ cacheManager;
372
592
  /**
373
593
  * Internal registry mapping template IDs to template definitions.
374
594
  *
@@ -377,7 +597,7 @@ var TemplateRenderer = class {
377
597
  * - Guaranteed insertion order
378
598
  * - Better memory efficiency than objects
379
599
  */
380
- templates = /* @__PURE__ */ new Map();
600
+ templates = {};
381
601
  /**
382
602
  * Creates a new TemplateRenderer instance.
383
603
  *
@@ -385,6 +605,7 @@ var TemplateRenderer = class {
385
605
  */
386
606
  constructor(config) {
387
607
  this.config = config;
608
+ this.cacheManager = new CacheManager(config.cache || DEFAULT_CACHE);
388
609
  this.registerTemplates(config.templates);
389
610
  }
390
611
  /**
@@ -393,12 +614,13 @@ var TemplateRenderer = class {
393
614
  * Templates are indexed by their ID for fast lookup.
394
615
  * If a template with the same ID already exists, it will be overwritten.
395
616
  *
396
- * @param templates - Array of template definitions to register
617
+ * @param templates - Map of template definitions to register
397
618
  */
398
619
  registerTemplates(templates) {
399
- for (const template of templates) {
400
- this.templates.set(template.id, template);
401
- }
620
+ const keys = Object.keys(templates);
621
+ keys.forEach((key) => {
622
+ this.templates[key] = templates[key];
623
+ });
402
624
  }
403
625
  /**
404
626
  * Retrieves a template by its unique ID.
@@ -412,20 +634,7 @@ var TemplateRenderer = class {
412
634
  * @returns The template definition, or undefined if not found
413
635
  */
414
636
  getTemplate(id) {
415
- return this.templates.get(id);
416
- }
417
- /**
418
- * Gets all registered template IDs.
419
- *
420
- * Useful for:
421
- * - Building template selection dropdowns
422
- * - Listing available templates in documentation
423
- * - Debugging template registration
424
- *
425
- * @returns Array of template IDs
426
- */
427
- getTemplateIds() {
428
- return Array.from(this.templates.keys());
637
+ return this.templates[id];
429
638
  }
430
639
  /**
431
640
  * Renders a template to a PNG image buffer.
@@ -448,7 +657,7 @@ var TemplateRenderer = class {
448
657
  * - Throws if rendering fails (font loading, HTML generation, etc.)
449
658
  *
450
659
  * @param templateId - ID of the template to render
451
- * @param params - Parameters to pass to the template
660
+ * @param params - Parameters (or function returning params) to pass to the template
452
661
  * @param options - Optional rendering options
453
662
  * @param options.width - Custom image width in pixels (default: 1200)
454
663
  * @param options.height - Custom image height in pixels (default: 630)
@@ -456,28 +665,63 @@ var TemplateRenderer = class {
456
665
  * @throws Error if template is not found or rendering fails
457
666
  */
458
667
  async renderToImage(templateId, params, options) {
459
- const { defaultParams } = this.config;
668
+ const { sharedParams } = this.config;
460
669
  const template = this.getTemplate(templateId);
461
670
  if (!template) {
462
671
  throw new Error(`Template '${templateId}' not found`);
463
672
  }
464
673
  const mergedParams = {
465
- ...typeof defaultParams === "function" ? await defaultParams() : defaultParams,
466
- ...typeof params === "function" ? await params() : params
674
+ ...typeof sharedParams === "function" ? await sharedParams() : sharedParams || {},
675
+ ...typeof params === "function" ? await params() : params || {}
467
676
  };
677
+ const cacheKey = this.cacheManager.generateKey({
678
+ templateId,
679
+ ...mergedParams,
680
+ ...options
681
+ });
682
+ const cached = await this.cacheManager.get(cacheKey);
683
+ if (cached) {
684
+ return cached;
685
+ }
468
686
  if (this.config.beforeRender) {
469
687
  await this.config.beforeRender(templateId, mergedParams);
470
688
  }
471
689
  const imageBuffer = await renderTemplate(template, mergedParams, options);
690
+ await this.cacheManager.set(cacheKey, imageBuffer);
472
691
  if (this.config.afterRender) {
473
692
  await this.config.afterRender(templateId, mergedParams, imageBuffer);
474
693
  }
475
694
  return imageBuffer;
476
695
  }
477
696
  };
478
- function createTemplateRenderer(config) {
697
+ function createRenderer(config) {
479
698
  return new TemplateRenderer(config);
480
699
  }
481
- /*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
482
700
 
483
- export { TemplateRenderer, createTemplateRenderer, defineTemplate, loadFontFromUrl, renderTemplate, validateTemplate };
701
+ // src/utils/clsx.ts
702
+ function clsx(...inputs) {
703
+ var i = 0, tmp, str = "", len = inputs.length;
704
+ for (; i < len; i++) {
705
+ if (tmp = inputs[i]) {
706
+ if (typeof tmp === "string") {
707
+ str += (str && " ") + tmp;
708
+ }
709
+ }
710
+ }
711
+ return str;
712
+ }
713
+ var objectToStyle = (style, options) => {
714
+ if (!style) {
715
+ return "";
716
+ }
717
+ const { isRTL = false } = options || {};
718
+ return Object.entries(isRTL ? rtlCSSJS(style) : style).filter(([_, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => {
719
+ if (value || value === 0) {
720
+ const cssKey = key.startsWith("--") ? key : key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
721
+ return `${cssKey}:${value}`;
722
+ }
723
+ return "";
724
+ }).filter(Boolean).join(";");
725
+ };
726
+
727
+ export { TemplateRenderer, clsx, createRenderer, defineTemplate, objectToStyle, renderTemplate, validateTemplate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogify/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Core types and utilities for OGify Open Graph image generator",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -15,6 +15,16 @@
15
15
  "files": [
16
16
  "dist"
17
17
  ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "dev": "tsup --watch",
21
+ "lint": "tsc --noEmit",
22
+ "clean": "rm -rf dist",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "test:ui": "vitest --ui",
26
+ "test:coverage": "vitest run --coverage"
27
+ },
18
28
  "author": {
19
29
  "name": "Hung Pham - @revolabs-io",
20
30
  "email": "info@revolabs.io"
@@ -22,25 +32,17 @@
22
32
  "license": "MIT",
23
33
  "dependencies": {
24
34
  "@resvg/resvg-js": "^2.6.2",
35
+ "lru-cache": "^11.2.4",
36
+ "rtl-css-js": "^1.16.1",
25
37
  "satori": "^0.18.3",
26
38
  "satori-html": "^0.3.2"
27
39
  },
28
40
  "devDependencies": {
29
- "@types/node": "^18",
41
+ "@types/node": "^20",
30
42
  "@vitest/ui": "^4.0.15",
31
43
  "happy-dom": "^20.0.11",
32
44
  "tsup": "^8.5.1",
33
45
  "typescript": "^5.9.3",
34
46
  "vitest": "^4.0.15"
35
- },
36
- "scripts": {
37
- "build": "tsup",
38
- "dev": "tsup --watch",
39
- "lint": "tsc --noEmit",
40
- "clean": "rm -rf dist",
41
- "test": "vitest run",
42
- "test:watch": "vitest",
43
- "test:ui": "vitest --ui",
44
- "test:coverage": "vitest run --coverage"
45
47
  }
46
- }
48
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 RevoLabs
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.