@ogify/core 0.1.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 ADDED
@@ -0,0 +1,481 @@
1
+ import satori from 'satori';
2
+ import { html } from 'satori-html';
3
+
4
+ // src/template.ts
5
+
6
+ // src/utils/google-font-detector.ts
7
+ var USER_AGENTS = {
8
+ ttf: "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.20) Gecko/20081217 Firefox/2.0.0.20",
9
+ woff: "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0"
10
+ };
11
+ var utils = {
12
+ /**
13
+ * Converts a CSS unicode-range string to an array of code points and ranges.
14
+ *
15
+ * @param input - CSS unicode-range value (e.g., "U+0041, U+0061-007A")
16
+ * @returns Array of code points and ranges
17
+ *
18
+ * @example
19
+ * toUnicodeRange("U+0041, U+0061-007A, U+20AC")
20
+ * // Returns: [65, [97, 122], 8364]
21
+ * // Represents: 'A', 'a-z', '€'
22
+ */
23
+ toUnicodeRange: (input) => {
24
+ return input.split(", ").map((range) => {
25
+ range = range.replaceAll("U+", "");
26
+ const [start, end] = range.split("-").map((hex) => parseInt(hex, 16));
27
+ if (isNaN(end)) {
28
+ return start;
29
+ }
30
+ return [start, end];
31
+ });
32
+ },
33
+ /**
34
+ * Checks if a character's code point falls within a Unicode range.
35
+ *
36
+ * @param segment - The character to check
37
+ * @param range - Array of code points and ranges to check against
38
+ * @returns true if the character is in the range, false otherwise
39
+ *
40
+ * @example
41
+ * checkSegmentInRange('A', [65, [97, 122]]) // true (65 is in the range)
42
+ * checkSegmentInRange('a', [65, [97, 122]]) // true (97 is in [97, 122])
43
+ * checkSegmentInRange('€', [65, [97, 122]]) // false (8364 is not in the range)
44
+ */
45
+ checkSegmentInRange: (segment, range) => {
46
+ if (range.length === 0) {
47
+ return false;
48
+ }
49
+ const codePoint = segment.codePointAt(0);
50
+ if (!codePoint) return false;
51
+ return range.some((val) => {
52
+ if (typeof val === "number") {
53
+ return codePoint === val;
54
+ } else {
55
+ const [start, end] = val;
56
+ return start <= codePoint && codePoint <= end;
57
+ }
58
+ });
59
+ }
60
+ };
61
+ var GoogleFontDetector = class {
62
+ /**
63
+ * Maps font URLs to their Unicode ranges.
64
+ * Key: Font file URL
65
+ * Value: Array of Unicode code points/ranges that the font supports
66
+ */
67
+ rangesByUrl = {};
68
+ /** The font configuration to detect */
69
+ font;
70
+ constructor(font) {
71
+ this.font = font;
72
+ }
73
+ /**
74
+ * Detects which Google Font URLs are needed to render the given text.
75
+ *
76
+ * This method:
77
+ * 1. Loads the font CSS from Google Fonts (if not already loaded)
78
+ * 2. Splits the text into individual characters
79
+ * 3. Finds the font URL for each character based on Unicode ranges
80
+ * 4. Returns unique URLs (no duplicates)
81
+ *
82
+ * @param text - The text to analyze
83
+ * @returns Promise resolving to an array of unique font file URLs
84
+ *
85
+ * @example
86
+ * const urls = await detector.detect('Hello 世界');
87
+ * // Returns: [
88
+ * // 'https://fonts.gstatic.com/.../latin.woff2',
89
+ * // 'https://fonts.gstatic.com/.../chinese.woff2'
90
+ * // ]
91
+ */
92
+ async detect(text) {
93
+ await this.load();
94
+ const detectedUrls = text.split("").map((segment) => {
95
+ return this.detectSegment(segment);
96
+ }).filter((url) => url !== null);
97
+ const uniqueUrls = [...new Set(detectedUrls)];
98
+ return uniqueUrls;
99
+ }
100
+ /**
101
+ * Finds the font URL that supports rendering a specific character.
102
+ *
103
+ * @param segment - A single character to check
104
+ * @returns The font URL that supports this character, or null if not found
105
+ */
106
+ detectSegment(segment) {
107
+ for (const url of Object.keys(this.rangesByUrl)) {
108
+ const range = this.rangesByUrl[url];
109
+ if (range.length === 0 || utils.checkSegmentInRange(segment, range)) {
110
+ return url;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ /**
116
+ * Fetches and parses the Google Fonts CSS to extract font URLs and Unicode ranges.
117
+ *
118
+ * This method:
119
+ * 1. Constructs the Google Fonts API URL with the font family, weight, and style
120
+ * 2. Fetches the CSS with an appropriate User-Agent to get the desired format
121
+ * 3. Parses each @font-face rule to extract URLs and unicode-range values
122
+ * 4. Stores the mapping in `rangesByUrl` for later character matching
123
+ */
124
+ async load() {
125
+ const { name, style, weight, format = "woff" } = this.font;
126
+ const apiUrl = `https://fonts.googleapis.com/css2?display=swap&family=${name.split(" ").join("+")}:${style === "italic" ? "ital," : ""}wght@${style === "italic" ? "1," : ""}${weight || 400}`;
127
+ const content = await (await fetch(apiUrl, {
128
+ headers: {
129
+ "User-Agent": format === "ttf" ? USER_AGENTS.ttf : USER_AGENTS.woff
130
+ }
131
+ })).text();
132
+ content.split("@font-face").forEach((fontFace) => {
133
+ this.extractUrlAndRange(fontFace);
134
+ });
135
+ }
136
+ /**
137
+ * Extracts the font URL and Unicode range from a @font-face CSS rule.
138
+ *
139
+ * @param input - A @font-face CSS rule as a string
140
+ *
141
+ * @example
142
+ * Input:
143
+ * ```
144
+ * {
145
+ * font-family: 'Inter';
146
+ * src: url(https://fonts.gstatic.com/.../inter.woff2) format('woff2');
147
+ * unicode-range: U+0041-005A, U+0061-007A;
148
+ * }
149
+ * ```
150
+ *
151
+ * Stores: rangesByUrl['https://fonts.gstatic.com/.../inter.woff2'] = [[65, 90], [97, 122]]
152
+ */
153
+ extractUrlAndRange(input) {
154
+ if (input && input.includes("url") && input.includes("format")) {
155
+ const [, url] = input.match(/url\((.*?)\)/) || [];
156
+ const [, format] = input.match(/format\(['"](.+?)['"]\)/) || [];
157
+ const [, unicodeRange] = input.match(/unicode-range:\s*(.*?);/) || [];
158
+ if (!["woff", "woff2", "truetype"].includes(format)) {
159
+ console.warn(`[Warning] Unsupported font format: ${format}`);
160
+ }
161
+ if (url) {
162
+ if (unicodeRange) {
163
+ this.rangesByUrl[url] = [...utils.toUnicodeRange(unicodeRange)];
164
+ } else {
165
+ this.rangesByUrl[url] = [];
166
+ }
167
+ }
168
+ }
169
+ }
170
+ };
171
+
172
+ // src/utils/emoji-loader.ts
173
+ var ZERO_WIDTH_JOINER = String.fromCharCode(8205);
174
+ var VARIATION_SELECTOR_REGEX = /\uFE0F/g;
175
+ function getIconCode(char) {
176
+ const normalizedChar = char.indexOf(ZERO_WIDTH_JOINER) < 0 ? char.replace(VARIATION_SELECTOR_REGEX, "") : char;
177
+ return toCodePoint(normalizedChar);
178
+ }
179
+ function toCodePoint(unicodeSurrogates) {
180
+ const codePoints = [];
181
+ let currentChar = 0;
182
+ let highSurrogate = 0;
183
+ let index = 0;
184
+ while (index < unicodeSurrogates.length) {
185
+ currentChar = unicodeSurrogates.charCodeAt(index++);
186
+ if (highSurrogate) {
187
+ const codePoint = 65536 + (highSurrogate - 55296 << 10) + (currentChar - 56320);
188
+ codePoints.push(codePoint.toString(16));
189
+ highSurrogate = 0;
190
+ } else if (currentChar >= 55296 && currentChar <= 56319) {
191
+ highSurrogate = currentChar;
192
+ } else {
193
+ codePoints.push(currentChar.toString(16));
194
+ }
195
+ }
196
+ return codePoints.join("-");
197
+ }
198
+ var apis = {
199
+ twemoji: (code) => `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`,
200
+ openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/",
201
+ blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/",
202
+ noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
203
+ fluent: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`,
204
+ fluentFlat: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`
205
+ };
206
+ var cache = {};
207
+ async function loadEmoji(type, text) {
208
+ const code = getIconCode(text);
209
+ const cacheKey = `${type}:${code}`;
210
+ if (cacheKey in cache) {
211
+ return cache[cacheKey];
212
+ }
213
+ if (!type || !apis[type]) {
214
+ type = "noto";
215
+ }
216
+ const api = apis[type];
217
+ const baseUrl = typeof api === "function" ? api(code) : api;
218
+ const fullUrl = typeof api === "function" ? baseUrl : `${baseUrl}${code.toUpperCase()}.svg`;
219
+ const emojiPromise = fetch(fullUrl).then((response) => response.text()).then((svgContent) => `data:image/svg+xml;base64,${btoa(svgContent)}`);
220
+ cache[cacheKey] = emojiPromise;
221
+ return emojiPromise;
222
+ }
223
+
224
+ // src/utils/fetcher.ts
225
+ var cache2 = {};
226
+ var loadFontFromUrl = async (url) => {
227
+ if (url in cache2) {
228
+ return cache2[url];
229
+ }
230
+ const fontData = fetch(url).then((response) => response.arrayBuffer());
231
+ cache2[url] = fontData;
232
+ return fontData;
233
+ };
234
+
235
+ // src/utils/additional-asset-loader.ts
236
+ var loadAdditionalAsset = async (options) => {
237
+ const { code, segment, fonts, emojiProvider } = options;
238
+ if (code === "emoji") {
239
+ return await loadEmoji(emojiProvider, segment);
240
+ }
241
+ const fallbackFonts = [];
242
+ await Promise.all(
243
+ fonts.map(async (fontConfig) => {
244
+ const detector = new GoogleFontDetector(fontConfig);
245
+ const detectedFontUrls = await detector.detect(segment);
246
+ const fontDataArray = await Promise.all(
247
+ detectedFontUrls.map(async (fontUrl) => {
248
+ return await loadFontFromUrl(fontUrl);
249
+ })
250
+ );
251
+ fontDataArray.forEach((fontData, index) => {
252
+ fallbackFonts.push({
253
+ // Generate unique name: e.g., "Inter-Fallback-0", "Inter-Fallback-1"
254
+ name: `${fontConfig.name}-Fallback-${index}`,
255
+ // The downloaded font binary data
256
+ data: fontData,
257
+ // Inherit style from the original font config (default: 'normal')
258
+ style: fontConfig.style || "normal",
259
+ // Inherit weight from the original font config (default: 400)
260
+ weight: fontConfig.weight || 400
261
+ });
262
+ });
263
+ })
264
+ );
265
+ return fallbackFonts;
266
+ };
267
+
268
+ // src/utils/font-loader.ts
269
+ var loadFonts = async (fonts) => {
270
+ const loadedFonts = await Promise.all(fonts.map((font) => loadFont(font)));
271
+ return loadedFonts.filter((font) => font !== null);
272
+ };
273
+ var loadFont = async (font) => {
274
+ if (font.data) {
275
+ return {
276
+ name: font.name,
277
+ data: font.data,
278
+ style: font.style || "normal",
279
+ weight: font.weight || 400
280
+ };
281
+ }
282
+ if (font.url) {
283
+ const buffer = await loadFontFromUrl(font.url);
284
+ return {
285
+ name: font.name,
286
+ data: Buffer.from(buffer),
287
+ style: font.style || "normal",
288
+ weight: font.weight || 400
289
+ };
290
+ }
291
+ const detector = new GoogleFontDetector(font);
292
+ const detectedFonts = await detector.detect("a");
293
+ if (detectedFonts.length === 0) {
294
+ return null;
295
+ }
296
+ return {
297
+ name: font.name,
298
+ data: await loadFontFromUrl(detectedFonts[0]),
299
+ style: font.style || "normal",
300
+ weight: font.weight || 400
301
+ };
302
+ };
303
+
304
+ // src/template.ts
305
+ var DEFAULT_WIDTH = 1200;
306
+ var DEFAULT_HEIGHT = 630;
307
+ async function renderTemplate(template, params, options) {
308
+ const width = options?.width || DEFAULT_WIDTH;
309
+ const height = options?.height || DEFAULT_HEIGHT;
310
+ const satoriFonts = await loadFonts(template.fonts);
311
+ const htmlString = template.renderer({
312
+ params,
313
+ width,
314
+ height
315
+ });
316
+ const element = html(htmlString);
317
+ const svg = await satori(element, {
318
+ // Image dimensions (customizable via options parameter)
319
+ width,
320
+ height,
321
+ // Loaded fonts for text rendering
322
+ fonts: satoriFonts,
323
+ // Embed fonts in the SVG for portability
324
+ embedFont: true,
325
+ // Dynamic asset loader for emojis and fallback fonts
326
+ // This is called when Satori encounters characters that need special handling
327
+ loadAdditionalAsset: async (code, segment) => {
328
+ return loadAdditionalAsset({
329
+ code,
330
+ // Asset type ('emoji' or other)
331
+ segment,
332
+ // The character(s) to load
333
+ fonts: template.fonts,
334
+ // Available fonts for fallback detection
335
+ emojiProvider: template.emojiProvider || "noto"
336
+ // Emoji provider (default: noto)
337
+ });
338
+ }
339
+ });
340
+ const { renderAsync } = await import('@resvg/resvg-js');
341
+ const pngData = await renderAsync(svg, {
342
+ fitTo: {
343
+ mode: "width",
344
+ // Scale based on width, maintain aspect ratio
345
+ value: width
346
+ }
347
+ });
348
+ return Buffer.from(pngData.asPng());
349
+ }
350
+
351
+ // src/renderer.ts
352
+ function validateTemplate(config) {
353
+ if (!config.id) {
354
+ throw new Error("Template must have an id");
355
+ }
356
+ if (!config.name) {
357
+ throw new Error("Template must have a name");
358
+ }
359
+ if (typeof config.renderer !== "function") {
360
+ throw new Error("Template must have a renderer function");
361
+ }
362
+ return true;
363
+ }
364
+ function defineTemplate(config) {
365
+ validateTemplate(config);
366
+ return config;
367
+ }
368
+ var TemplateRenderer = class {
369
+ /** Configuration including global settings and lifecycle hooks */
370
+ config;
371
+ /**
372
+ * Internal registry mapping template IDs to template definitions.
373
+ *
374
+ * Using a Map provides:
375
+ * - O(1) lookup performance
376
+ * - Guaranteed insertion order
377
+ * - Better memory efficiency than objects
378
+ */
379
+ templates = /* @__PURE__ */ new Map();
380
+ /**
381
+ * Creates a new TemplateRenderer instance.
382
+ *
383
+ * @param config - Handler configuration with templates and global settings
384
+ */
385
+ constructor(config) {
386
+ this.config = config;
387
+ this.registerTemplates(config.templates);
388
+ }
389
+ /**
390
+ * Registers multiple templates into the internal registry.
391
+ *
392
+ * Templates are indexed by their ID for fast lookup.
393
+ * If a template with the same ID already exists, it will be overwritten.
394
+ *
395
+ * @param templates - Array of template definitions to register
396
+ */
397
+ registerTemplates(templates) {
398
+ for (const template of templates) {
399
+ this.templates.set(template.id, template);
400
+ }
401
+ }
402
+ /**
403
+ * Retrieves a template by its unique ID.
404
+ *
405
+ * This is useful for:
406
+ * - Checking if a template exists before rendering
407
+ * - Inspecting template configuration
408
+ * - Building template selection UIs
409
+ *
410
+ * @param id - The template ID to look up
411
+ * @returns The template definition, or undefined if not found
412
+ */
413
+ getTemplate(id) {
414
+ return this.templates.get(id);
415
+ }
416
+ /**
417
+ * Gets all registered template IDs.
418
+ *
419
+ * Useful for:
420
+ * - Building template selection dropdowns
421
+ * - Listing available templates in documentation
422
+ * - Debugging template registration
423
+ *
424
+ * @returns Array of template IDs
425
+ */
426
+ getTemplateIds() {
427
+ return Array.from(this.templates.keys());
428
+ }
429
+ /**
430
+ * Renders a template to a PNG image buffer.
431
+ *
432
+ * This is the main method for generating OG images. It:
433
+ * 1. Looks up the template by ID
434
+ * 2. Merges default params with user params
435
+ * 3. Calls lifecycle hooks (if configured)
436
+ * 4. Delegates to the core rendering engine
437
+ *
438
+ * **Parameter Merging:**
439
+ * User-provided parameters take precedence over default parameters.
440
+ *
441
+ * **Custom Dimensions:**
442
+ * You can override the default 1200x630 dimensions by providing custom width/height.
443
+ * This is useful for platform-specific requirements (e.g., Twitter, Instagram).
444
+ *
445
+ * **Error Handling:**
446
+ * - Throws if template is not found
447
+ * - Throws if rendering fails (font loading, HTML generation, etc.)
448
+ *
449
+ * @param templateId - ID of the template to render
450
+ * @param params - Parameters to pass to the template
451
+ * @param options - Optional rendering options
452
+ * @param options.width - Custom image width in pixels (default: 1200)
453
+ * @param options.height - Custom image height in pixels (default: 630)
454
+ * @returns Promise resolving to a PNG image buffer
455
+ * @throws Error if template is not found or rendering fails
456
+ */
457
+ async renderToImage(templateId, params, options) {
458
+ const template = this.getTemplate(templateId);
459
+ if (!template) {
460
+ throw new Error(`Template '${templateId}' not found`);
461
+ }
462
+ const mergedParams = {
463
+ ...this.config.defaultParams,
464
+ ...params
465
+ };
466
+ if (this.config.beforeRender) {
467
+ await this.config.beforeRender(templateId, mergedParams);
468
+ }
469
+ const imageBuffer = await renderTemplate(template, mergedParams, options);
470
+ if (this.config.afterRender) {
471
+ await this.config.afterRender(templateId, mergedParams, imageBuffer);
472
+ }
473
+ return imageBuffer;
474
+ }
475
+ };
476
+ function createTemplateRenderer(config) {
477
+ return new TemplateRenderer(config);
478
+ }
479
+ /*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
480
+
481
+ export { TemplateRenderer, createTemplateRenderer, defineTemplate, loadFontFromUrl, renderTemplate, validateTemplate };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@ogify/core",
3
+ "version": "0.1.1",
4
+ "description": "Core types and utilities for OGify Open Graph image generator",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "author": {
19
+ "name": "Hung Pham - @revolabs-io",
20
+ "email": "info@revolabs.io"
21
+ },
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "satori": "^0.18.3",
25
+ "satori-html": "^0.3.2",
26
+ "@resvg/resvg-js": "^2.6.2"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^18",
30
+ "tsup": "^8.5.1",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "dev": "tsup --watch",
36
+ "lint": "tsc --noEmit",
37
+ "clean": "rm -rf dist"
38
+ }
39
+ }