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