@ewanc26/og 0.1.1 → 0.1.3

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.cjs CHANGED
@@ -54,6 +54,7 @@ var import_resvg_js = require("@resvg/resvg-js");
54
54
 
55
55
  // src/fonts.ts
56
56
  var import_promises = require("fs/promises");
57
+ var import_node_fs = require("fs");
57
58
  var import_node_path = require("path");
58
59
  var import_node_url = require("url");
59
60
  var import_meta = {};
@@ -67,7 +68,20 @@ function getModuleDir() {
67
68
  return (0, import_node_path.resolve)(process.cwd(), "node_modules/@ewanc26/og/dist");
68
69
  }
69
70
  function getFontsDir() {
70
- return (0, import_node_path.resolve)(getModuleDir(), "../fonts");
71
+ const candidates = [
72
+ // Standard: fonts next to dist
73
+ (0, import_node_path.resolve)(getModuleDir(), "../fonts"),
74
+ // Vercel serverless: fonts inside dist
75
+ (0, import_node_path.resolve)(getModuleDir(), "fonts"),
76
+ // Fallback: node_modules path
77
+ (0, import_node_path.resolve)(process.cwd(), "node_modules/@ewanc26/og/fonts")
78
+ ];
79
+ for (const dir of candidates) {
80
+ if ((0, import_node_fs.existsSync)(dir)) {
81
+ return dir;
82
+ }
83
+ }
84
+ return candidates[0];
71
85
  }
72
86
  var BUNDLED_FONTS = {
73
87
  get heading() {
@@ -77,15 +91,31 @@ var BUNDLED_FONTS = {
77
91
  return (0, import_node_path.resolve)(getFontsDir(), "Inter-Regular.ttf");
78
92
  }
79
93
  };
94
+ var FONT_FALLBACKS = {
95
+ heading: "https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Bold.ttf",
96
+ body: "https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Regular.ttf"
97
+ };
80
98
  async function loadFonts(config) {
81
99
  const headingPath = config?.heading ?? BUNDLED_FONTS.heading;
82
100
  const bodyPath = config?.body ?? BUNDLED_FONTS.body;
83
101
  const [heading, body] = await Promise.all([
84
- loadFontFile(headingPath),
85
- loadFontFile(bodyPath)
102
+ loadFontFileWithFallback(headingPath, FONT_FALLBACKS.heading),
103
+ loadFontFileWithFallback(bodyPath, FONT_FALLBACKS.body)
86
104
  ]);
87
105
  return { heading, body };
88
106
  }
107
+ async function loadFontFileWithFallback(path, fallbackUrl) {
108
+ try {
109
+ return await loadFontFile(path);
110
+ } catch (error) {
111
+ console.warn(`Failed to load local font at ${path}, trying CDN fallback:`, error);
112
+ try {
113
+ return await loadFontFile(fallbackUrl);
114
+ } catch (fallbackError) {
115
+ throw new Error(`Failed to load font from both local path (${path}) and CDN (${fallbackUrl}): ${fallbackError}`);
116
+ }
117
+ }
118
+ }
89
119
  async function loadFontFile(source) {
90
120
  if (source.startsWith("http://") || source.startsWith("https://")) {
91
121
  const response = await fetch(source);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/generate.ts","../src/fonts.ts","../src/noise.ts","../src/png-encoder.ts","../src/templates/blog.ts","../src/templates/profile.ts","../src/templates/default.ts","../src/templates/index.ts","../src/types.ts","../src/endpoint.ts","../src/svg.ts"],"sourcesContent":["/**\n * @ewanc26/og\n *\n * Dynamic OpenGraph image generator with noise backgrounds, bold typography,\n * and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes,\n * and build scripts.\n *\n * @example\n * ```ts\n * import { generateOgImage, createOgEndpoint } from '@ewanc26/og';\n * import { blogTemplate } from '@ewanc26/og/templates';\n *\n * // Generate PNG\n * const png = await generateOgImage({\n * title: 'My Blog Post',\n * description: 'A description',\n * siteName: 'ewancroft.uk',\n * });\n *\n * // SvelteKit endpoint\n * export const GET = createOgEndpoint({ siteName: 'ewancroft.uk' });\n * ```\n */\n\n// Core generation\nexport { generateOgImage, generateOgImageDataUrl, generateOgResponse, OG_WIDTH, OG_HEIGHT } from './generate.js'\n\n// Types\nexport type {\n\tOgColorConfig,\n\tOgFontConfig,\n\tOgNoiseConfig,\n\tOgTemplateProps,\n\tOgTemplate,\n\tOgGenerateOptions,\n\tOgEndpointOptions,\n} from './types.js'\nexport { defaultColors } from './types.js'\n\n// Noise (for advanced customization)\nexport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\n\n// Fonts (for advanced customization)\nexport { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js'\n\n// Endpoint helpers\nexport { createOgEndpoint } from './endpoint.js'\n\n// SVG to PNG conversion\nexport { svgToPng, svgToPngDataUrl, svgToPngResponse } from './svg.js'\nexport type { SvgToPngOptions } from './svg.js'\n","/**\n * Core OG image generation.\n * Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.\n */\n\nimport satori from 'satori'\nimport { Resvg } from '@resvg/resvg-js'\nimport { loadFonts, createSatoriFonts } from './fonts.js'\nimport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\nimport { getTemplate } from './templates/index.js'\nimport { defaultColors } from './types.js'\nimport type {\n\tOgGenerateOptions,\n\tOgColorConfig,\n\tOgTemplateProps,\n} from './types.js'\n\n// Standard OG image dimensions\nexport const OG_WIDTH = 1200\nexport const OG_HEIGHT = 630\n\n/**\n * Generate an OG image as PNG Buffer.\n */\nexport async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {\n\tconst {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\ttemplate = 'blog',\n\t\tcolors: colorOverrides,\n\t\tfonts: fontConfig,\n\t\tnoise: noiseConfig,\n\t\tnoiseSeed,\n\t\twidth = OG_WIDTH,\n\t\theight = OG_HEIGHT,\n\t\tdebugSvg = false,\n\t} = options\n\n\t// Merge colours\n\tconst colors: OgColorConfig = {\n\t\t...defaultColors,\n\t\t...colorOverrides,\n\t}\n\n\t// Load fonts\n\tconst fonts = await loadFonts(fontConfig)\n\tconst satoriFonts = createSatoriFonts(fonts)\n\n\t// Generate noise background\n\tconst noiseEnabled = noiseConfig?.enabled !== false\n\tconst noiseSeedValue = noiseSeed || noiseConfig?.seed || title\n\tconst noiseDataUrl = noiseEnabled\n\t\t? generateNoiseDataUrl({\n\t\t\t\tseed: noiseSeedValue,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.4,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Generate circular noise decoration\n\tconst circleNoiseDataUrl = noiseEnabled\n\t\t? generateCircleNoiseDataUrl({\n\t\t\t\tseed: `${noiseSeedValue}-circle`,\n\t\t\t\tsize: 200,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.15,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Get template function\n\tconst templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])\n\n\t// Build template props\n\tconst props: OgTemplateProps = {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\tcolors,\n\t\tnoiseDataUrl,\n\t\tcircleNoiseDataUrl,\n\t\twidth,\n\t\theight,\n\t}\n\n\t// Render template to Satori-compatible structure\n\tconst element = templateFn(props)\n\n\t// Generate SVG with satori\n\tconst svg = await satori(element as Parameters<typeof satori>[0], {\n\t\twidth,\n\t\theight,\n\t\tfonts: satoriFonts,\n\t})\n\n\t// Debug: return SVG string\n\tif (debugSvg) {\n\t\treturn Buffer.from(svg)\n\t}\n\n\t// Convert SVG to PNG with resvg-js\n\tconst resvg = new Resvg(svg, {\n\t\tfitTo: {\n\t\t\tmode: 'width',\n\t\t\tvalue: width,\n\t\t},\n\t})\n\tconst pngData = resvg.render()\n\n\treturn Buffer.from(pngData.asPng())\n}\n\n/**\n * Generate OG image and return as base64 data URL.\n */\nexport async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {\n\tconst png = await generateOgImage(options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Generate OG image and return as Response (for SvelteKit endpoints).\n */\nexport async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {\n\tconst png = await generateOgImage(options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n","/**\n * @ewanc26/og fonts\n *\n * Font loading utilities. Bundles Inter font by default.\n */\n\nimport { readFile } from 'node:fs/promises'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { OgFontConfig } from './types.js'\n\n// Declare __dirname for CJS contexts (injected by bundlers)\ndeclare const __dirname: string | undefined\n\n// ─── Paths ────────────────────────────────────────────────────────────────────\n\n/**\n * Get the directory of the current module.\n * Works in both ESM and bundled contexts.\n */\nfunction getModuleDir(): string {\n\t// ESM context\n\tif (typeof import.meta !== 'undefined' && import.meta.url) {\n\t\treturn dirname(fileURLToPath(import.meta.url))\n\t}\n\t// Bundled CJS context - __dirname is injected by bundlers\n\tif (typeof __dirname !== 'undefined') {\n\t\treturn __dirname\n\t}\n\t// Fallback\n\treturn resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')\n}\n\n/**\n * Resolve the fonts directory relative to the installed package.\n */\nfunction getFontsDir(): string {\n\treturn resolve(getModuleDir(), '../fonts')\n}\n\n/**\n * Resolve bundled font paths. Uses getters to defer resolution until runtime.\n */\nexport const BUNDLED_FONTS = {\n\tget heading() {\n\t\treturn resolve(getFontsDir(), 'Inter-Bold.ttf')\n\t},\n\tget body() {\n\t\treturn resolve(getFontsDir(), 'Inter-Regular.ttf')\n\t},\n} as const\n\n// ─── Font Loading ──────────────────────────────────────────────────────────────\n\nexport interface LoadedFonts {\n\theading: ArrayBuffer\n\tbody: ArrayBuffer\n}\n\n/**\n * Load fonts from config, falling back to bundled DM Sans.\n */\nexport async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {\n\tconst headingPath = config?.heading ?? BUNDLED_FONTS.heading\n\tconst bodyPath = config?.body ?? BUNDLED_FONTS.body\n\n\tconst [heading, body] = await Promise.all([\n\t\tloadFontFile(headingPath),\n\t\tloadFontFile(bodyPath),\n\t])\n\n\treturn { heading, body }\n}\n\n/**\n * Load a font from file path or URL.\n */\nasync function loadFontFile(source: string): Promise<ArrayBuffer> {\n\t// Handle URLs\n\tif (source.startsWith('http://') || source.startsWith('https://')) {\n\t\tconst response = await fetch(source)\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to load font from URL: ${source}`)\n\t\t}\n\t\treturn response.arrayBuffer()\n\t}\n\n\t// Handle file paths\n\tconst buffer = await readFile(source)\n\treturn buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)\n}\n\n// ─── Font Registration for Satori ─────────────────────────────────────────────\n\nexport type SatoriFontConfig = {\n\tname: string\n\tdata: ArrayBuffer\n\tweight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900\n\tstyle: 'normal' | 'italic'\n}\n\n/**\n * Create Satori-compatible font config from loaded fonts.\n * Uses Inter font family with weight 700 for headings and 400 for body.\n */\nexport function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {\n\treturn [\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.heading,\n\t\t\tweight: 700,\n\t\t\tstyle: 'normal',\n\t\t},\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.body,\n\t\t\tweight: 400,\n\t\t\tstyle: 'normal',\n\t\t},\n\t]\n}\n","/**\n * @ewanc26/og noise\n */\n\nimport { generateNoisePixels } from '@ewanc26/noise'\nimport { PNGEncoder } from './png-encoder.js'\nimport type { OgNoiseConfig } from './types.js'\n\nexport interface NoiseOptions {\n\tseed: string\n\twidth: number\n\theight: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\nexport interface CircleNoiseOptions {\n\tseed: string\n\tsize: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\n/**\n * Generate a noise PNG as a data URL.\n */\nexport function generateNoiseDataUrl(options: NoiseOptions): string {\n\tconst { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(width, height, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [20, 60] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tif (opacity < 1) {\n\t\tfor (let i = 3; i < pixels.length; i += 4) {\n\t\t\tpixels[i] = Math.round(pixels[i] * opacity)\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, width, height)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n\n/**\n * Generate a circular noise PNG as a data URL.\n * Creates a square image with circular transparency mask.\n */\nexport function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {\n\tconst { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(size, size, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [30, 70] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tconst center = size / 2\n\tconst radius = size / 2\n\n\t// Apply circular mask\n\tfor (let y = 0; y < size; y++) {\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst idx = (y * size + x) * 4\n\t\t\tconst dx = x - center + 0.5\n\t\t\tconst dy = y - center + 0.5\n\t\t\tconst dist = Math.sqrt(dx * dx + dy * dy)\n\n\t\t\tif (dist > radius) {\n\t\t\t\t// Outside circle - fully transparent\n\t\t\t\tpixels[idx + 3] = 0\n\t\t\t} else if (dist > radius - 2) {\n\t\t\t\t// Anti-alias edge\n\t\t\t\tconst edgeOpacity = (radius - dist) / 2\n\t\t\t\tpixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)\n\t\t\t} else {\n\t\t\t\t// Inside circle - apply opacity\n\t\t\t\tpixels[idx + 3] = Math.round(255 * opacity)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, size, size)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n","/**\n * Minimal PNG encoder for noise backgrounds.\n * Uses node:zlib for deflate compression.\n */\n\nimport { deflateSync } from 'node:zlib'\n\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])\n\nfunction crc32(data: Buffer): number {\n\tlet crc = 0xffffffff\n\tconst table: number[] = []\n\n\t// Build CRC table\n\tfor (let n = 0; n < 256; n++) {\n\t\tlet c = n\n\t\tfor (let k = 0; k < 8; k++) {\n\t\t\tc = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n\t\t}\n\t\ttable[n] = c\n\t}\n\n\t// Calculate CRC\n\tfor (let i = 0; i < data.length; i++) {\n\t\tcrc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)\n\t}\n\n\treturn (crc ^ 0xffffffff) >>> 0\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n\tconst length = Buffer.alloc(4)\n\tlength.writeUInt32BE(data.length, 0)\n\n\tconst typeBuffer = Buffer.from(type, 'ascii')\n\tconst crcData = Buffer.concat([typeBuffer, data])\n\tconst crc = Buffer.alloc(4)\n\tcrc.writeUInt32BE(crc32(crcData), 0)\n\n\treturn Buffer.concat([length, typeBuffer, data, crc])\n}\n\nfunction createIHDR(width: number, height: number): Buffer {\n\tconst data = Buffer.alloc(13)\n\tdata.writeUInt32BE(width, 0) // Width\n\tdata.writeUInt32BE(height, 4) // Height\n\tdata.writeUInt8(8, 8) // Bit depth: 8 bits\n\tdata.writeUInt8(2, 9) // Colour type: 2 (RGB)\n\tdata.writeUInt8(0, 10) // Compression method\n\tdata.writeUInt8(0, 11) // Filter method\n\tdata.writeUInt8(0, 12) // Interlace method\n\n\treturn createChunk('IHDR', data)\n}\n\nfunction createIDAT(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\t// Apply filter (none filter = 0) per row\n\tconst rawData = Buffer.alloc(height * (width * 3 + 1))\n\n\tlet srcOffset = 0\n\tlet dstOffset = 0\n\n\tfor (let y = 0; y < height; y++) {\n\t\trawData[dstOffset++] = 0 // Filter type: none\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst r = pixels[srcOffset++]\n\t\t\tconst g = pixels[srcOffset++]\n\t\t\tconst b = pixels[srcOffset++]\n\t\t\tsrcOffset++ // Skip alpha\n\t\t\trawData[dstOffset++] = r\n\t\t\trawData[dstOffset++] = g\n\t\t\trawData[dstOffset++] = b\n\t\t}\n\t}\n\n\tconst compressed = deflateSync(rawData)\n\treturn createChunk('IDAT', compressed)\n}\n\nfunction createIEND(): Buffer {\n\treturn createChunk('IEND', Buffer.alloc(0))\n}\n\n/**\n * Encode raw RGBA pixel data as a PNG Buffer.\n */\nexport function encodePNG(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\treturn Buffer.concat([\n\t\tPNG_SIGNATURE,\n\t\tcreateIHDR(width, height),\n\t\tcreateIDAT(pixels, width, height),\n\t\tcreateIEND(),\n\t])\n}\n\n/**\n * PNGEncoder namespace for cleaner imports.\n */\nexport const PNGEncoder = {\n\tencode: encodePNG,\n}\n","/**\n * Blog OG template.\n * Clean centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function blogTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\ttype: 'h1',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 64,\n\t\t\t\t\t\t\tfontWeight: 700,\n\t\t\t\t\t\t\tcolor: colors.text,\n\t\t\t\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\t\t\t\tmargin: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tlineHeight: 1.1,\n\t\t\t\t\t\t\tmaxWidth: 1000,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: title,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tdescription ? {\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 28,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 28,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tlineHeight: 1.4,\n\t\t\t\t\t\t\tmaxWidth: 900,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: description,\n\t\t\t\t\t},\n\t\t\t\t} : null,\n\t\t\t\t{\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 24,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 56,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\topacity: 0.7,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: siteName,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t].filter(Boolean),\n\t\t},\n\t}\n}\n","/**\n * Profile OG template.\n * Centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function profileTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\timage,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\tconst children: unknown[] = []\n\n\tif (image) {\n\t\tchildren.push({\n\t\t\ttype: 'img',\n\t\t\tprops: {\n\t\t\t\tsrc: image,\n\t\t\t\twidth: 120,\n\t\t\t\theight: 120,\n\t\t\t\tstyle: {\n\t\t\t\t\tborderRadius: '50%',\n\t\t\t\t\tmarginBottom: 32,\n\t\t\t\t\tobjectFit: 'cover',\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\n\tchildren.push({\n\t\ttype: 'h1',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tfontSize: 56,\n\t\t\t\tfontWeight: 700,\n\t\t\t\tcolor: colors.text,\n\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\tmargin: 0,\n\t\t\t\ttextAlign: 'center',\n\t\t\t\tlineHeight: 1.1,\n\t\t\t\tmaxWidth: 900,\n\t\t\t},\n\t\t\tchildren: title,\n\t\t},\n\t})\n\n\tif (description) {\n\t\tchildren.push({\n\t\t\ttype: 'p',\n\t\t\tprops: {\n\t\t\t\tstyle: {\n\t\t\t\t\tfontSize: 26,\n\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\tmarginTop: 20,\n\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\tlineHeight: 1.4,\n\t\t\t\t\tmaxWidth: 700,\n\t\t\t\t},\n\t\t\t\tchildren: description,\n\t\t\t},\n\t\t})\n\t}\n\n\tchildren.push({\n\t\ttype: 'p',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tfontSize: 24,\n\t\t\t\tfontWeight: 400,\n\t\t\t\tcolor: colors.accent,\n\t\t\t\tmarginTop: 48,\n\t\t\t\tmarginBottom: 0,\n\t\t\t\ttextAlign: 'center',\n\t\t\t\topacity: 0.7,\n\t\t\t},\n\t\t\tchildren: siteName,\n\t\t},\n\t})\n\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren,\n\t\t},\n\t}\n}\n","/**\n * Default OG template.\n * Clean, centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function defaultTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\ttype: 'h1',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 72,\n\t\t\t\t\t\t\tfontWeight: 700,\n\t\t\t\t\t\t\tcolor: colors.text,\n\t\t\t\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\t\t\t\tmargin: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: title,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tdescription ? {\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 32,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 24,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tmaxWidth: 900,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: description,\n\t\t\t\t\t},\n\t\t\t\t} : null,\n\t\t\t\t{\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 28,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 64,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\topacity: 0.7,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: siteName,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t].filter(Boolean),\n\t\t},\n\t}\n}\n","/**\n * Built-in OG templates.\n */\n\nexport { blogTemplate } from './blog.js'\nexport { profileTemplate } from './profile.js'\nexport { defaultTemplate } from './default.js'\n\nimport { blogTemplate } from './blog.js'\nimport { profileTemplate } from './profile.js'\nimport { defaultTemplate } from './default.js'\nimport type { OgTemplate } from '../types.js'\n\nexport const templates = {\n\tblog: blogTemplate,\n\tprofile: profileTemplate,\n\tdefault: defaultTemplate,\n} as const\n\nexport type TemplateName = keyof typeof templates\n\nexport function getTemplate(name: TemplateName | OgTemplate): OgTemplate {\n\tif (typeof name === 'function') {\n\t\treturn name\n\t}\n\treturn templates[name]\n}\n","/**\n * @ewanc26/og types\n */\n\n// ─── Colour Configuration ─────────────────────────────────────────────────────\n\nexport interface OgColorConfig {\n\t/** Background color (very dark). @default '#0f1a15' */\n\tbackground: string\n\t/** Primary text color. @default '#e8f5e9' */\n\ttext: string\n\t/** Secondary/accent text (mint). @default '#86efac' */\n\taccent: string\n}\n\nexport const defaultColors: OgColorConfig = {\n\tbackground: '#0f1a15',\n\ttext: '#e8f5e9',\n\taccent: '#86efac',\n}\n\n// ─── Font Configuration ───────────────────────────────────────────────────────\n\nexport interface OgFontConfig {\n\theading?: string\n\tbody?: string\n}\n\n// ─── Noise Configuration ──────────────────────────────────────────────────────\n\nexport interface OgNoiseConfig {\n\tenabled?: boolean\n\tseed?: string\n\topacity?: number\n\tcolorMode?: 'grayscale' | 'hsl'\n}\n\n// ─── Template Props ────────────────────────────────────────────────────────────\n\nexport interface OgTemplateProps {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n\tcircleNoiseDataUrl?: string\n\twidth: number\n\theight: number\n}\n\nexport type OgTemplate = (props: OgTemplateProps) => unknown\n\n// ─── Generation Options ───────────────────────────────────────────────────────\n\nexport interface OgGenerateOptions {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\ttemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tnoiseSeed?: string\n\twidth?: number\n\theight?: number\n\tdebugSvg?: boolean\n}\n\n// ─── SvelteKit Endpoint Options ───────────────────────────────────────────────\n\nexport interface OgEndpointOptions {\n\tsiteName: string\n\tdefaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tcacheMaxAge?: number\n\twidth?: number\n\theight?: number\n}\n\n// ─── Internal Types ────────────────────────────────────────────────────────────\n\nexport interface InternalGenerateContext {\n\twidth: number\n\theight: number\n\tfonts: { heading: ArrayBuffer; body: ArrayBuffer }\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n}\n","/**\n * SvelteKit endpoint helpers.\n */\n\nimport { generateOgResponse } from './generate.js'\nimport type { OgEndpointOptions, OgTemplate } from './types.js'\n\n/**\n * Create a SvelteKit GET handler for OG image generation.\n *\n * @example\n * ```ts\n * // src/routes/og/[title]/+server.ts\n * import { createOgEndpoint } from '@ewanc26/og';\n *\n * export const GET = createOgEndpoint({\n * siteName: 'ewancroft.uk',\n * defaultTemplate: 'blog',\n * });\n * ```\n *\n * The endpoint expects query parameters:\n * - `title` (required): Page title\n * - `description`: Optional description\n * - `image`: Optional avatar/logo URL\n * - `seed`: Optional noise seed\n */\nexport function createOgEndpoint(options: OgEndpointOptions) {\n\tconst {\n\t\tsiteName,\n\t\tdefaultTemplate: template = 'default',\n\t\tcolors,\n\t\tfonts,\n\t\tnoise,\n\t\tcacheMaxAge = 3600,\n\t\twidth,\n\t\theight,\n\t} = options\n\n\treturn async ({ url }: { url: URL }) => {\n\t\tconst title = url.searchParams.get('title')\n\t\tconst description = url.searchParams.get('description') ?? undefined\n\t\tconst image = url.searchParams.get('image') ?? undefined\n\t\tconst noiseSeed = url.searchParams.get('seed') ?? undefined\n\n\t\tif (!title) {\n\t\t\treturn new Response('Missing title parameter', { status: 400 })\n\t\t}\n\n\t\ttry {\n\t\t\treturn await generateOgResponse(\n\t\t\t\t{\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tsiteName,\n\t\t\t\t\timage,\n\t\t\t\t\ttemplate: template as OgTemplate,\n\t\t\t\t\tcolors,\n\t\t\t\t\tfonts,\n\t\t\t\t\tnoise,\n\t\t\t\t\tnoiseSeed,\n\t\t\t\t\twidth,\n\t\t\t\t\theight,\n\t\t\t\t},\n\t\t\t\tcacheMaxAge\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to generate OG image:', error)\n\t\t\treturn new Response('Failed to generate image', { status: 500 })\n\t\t}\n\t}\n}\n\n/**\n * Create a typed OG image URL for use in meta tags.\n */\nexport function createOgImageUrl(\n\tbaseUrl: string,\n\tparams: {\n\t\ttitle: string\n\t\tdescription?: string\n\t\timage?: string\n\t\tseed?: string\n\t}\n): string {\n\tconst url = new URL(baseUrl)\n\turl.searchParams.set('title', params.title)\n\tif (params.description) url.searchParams.set('description', params.description)\n\tif (params.image) url.searchParams.set('image', params.image)\n\tif (params.seed) url.searchParams.set('seed', params.seed)\n\treturn url.toString()\n}\n","/**\n * SVG to PNG conversion using @resvg/resvg-js.\n */\n\nimport { Resvg } from '@resvg/resvg-js'\n\nexport interface SvgToPngOptions {\n\t/** Scale to fit width in pixels */\n\tfitWidth?: number\n\t/** Background colour for transparent areas */\n\tbackgroundColor?: string\n}\n\n/**\n * Convert an SVG string to PNG Buffer.\n */\nexport function svgToPng(svg: string, options: SvgToPngOptions = {}): Buffer {\n\tconst opts = {\n\t\tfitTo: options.fitWidth\n\t\t\t? { mode: 'width' as const, value: options.fitWidth }\n\t\t\t: undefined,\n\t\tbackground: options.backgroundColor,\n\t}\n\n\tconst resvg = new Resvg(svg, opts)\n\tconst rendered = resvg.render()\n\n\treturn Buffer.from(rendered.asPng())\n}\n\n/**\n * Convert an SVG string to PNG data URL.\n */\nexport function svgToPngDataUrl(svg: string, options: SvgToPngOptions = {}): string {\n\tconst png = svgToPng(svg, options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Convert an SVG string to PNG Response (for SvelteKit endpoints).\n */\nexport function svgToPngResponse(svg: string, options: SvgToPngOptions = {}, cacheMaxAge = 3600): Response {\n\tconst png = svgToPng(svg, options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n"],"mappings":"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","names":["satori","import_resvg_js"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/generate.ts","../src/fonts.ts","../src/noise.ts","../src/png-encoder.ts","../src/templates/blog.ts","../src/templates/profile.ts","../src/templates/default.ts","../src/templates/index.ts","../src/types.ts","../src/endpoint.ts","../src/svg.ts"],"sourcesContent":["/**\n * @ewanc26/og\n *\n * Dynamic OpenGraph image generator with noise backgrounds, bold typography,\n * and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes,\n * and build scripts.\n *\n * @example\n * ```ts\n * import { generateOgImage, createOgEndpoint } from '@ewanc26/og';\n * import { blogTemplate } from '@ewanc26/og/templates';\n *\n * // Generate PNG\n * const png = await generateOgImage({\n * title: 'My Blog Post',\n * description: 'A description',\n * siteName: 'ewancroft.uk',\n * });\n *\n * // SvelteKit endpoint\n * export const GET = createOgEndpoint({ siteName: 'ewancroft.uk' });\n * ```\n */\n\n// Core generation\nexport { generateOgImage, generateOgImageDataUrl, generateOgResponse, OG_WIDTH, OG_HEIGHT } from './generate.js'\n\n// Types\nexport type {\n\tOgColorConfig,\n\tOgFontConfig,\n\tOgNoiseConfig,\n\tOgTemplateProps,\n\tOgTemplate,\n\tOgGenerateOptions,\n\tOgEndpointOptions,\n} from './types.js'\nexport { defaultColors } from './types.js'\n\n// Noise (for advanced customization)\nexport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\n\n// Fonts (for advanced customization)\nexport { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js'\n\n// Endpoint helpers\nexport { createOgEndpoint } from './endpoint.js'\n\n// SVG to PNG conversion\nexport { svgToPng, svgToPngDataUrl, svgToPngResponse } from './svg.js'\nexport type { SvgToPngOptions } from './svg.js'\n","/**\n * Core OG image generation.\n * Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.\n */\n\nimport satori from 'satori'\nimport { Resvg } from '@resvg/resvg-js'\nimport { loadFonts, createSatoriFonts } from './fonts.js'\nimport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\nimport { getTemplate } from './templates/index.js'\nimport { defaultColors } from './types.js'\nimport type {\n\tOgGenerateOptions,\n\tOgColorConfig,\n\tOgTemplateProps,\n} from './types.js'\n\n// Standard OG image dimensions\nexport const OG_WIDTH = 1200\nexport const OG_HEIGHT = 630\n\n/**\n * Generate an OG image as PNG Buffer.\n */\nexport async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {\n\tconst {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\ttemplate = 'blog',\n\t\tcolors: colorOverrides,\n\t\tfonts: fontConfig,\n\t\tnoise: noiseConfig,\n\t\tnoiseSeed,\n\t\twidth = OG_WIDTH,\n\t\theight = OG_HEIGHT,\n\t\tdebugSvg = false,\n\t} = options\n\n\t// Merge colours\n\tconst colors: OgColorConfig = {\n\t\t...defaultColors,\n\t\t...colorOverrides,\n\t}\n\n\t// Load fonts\n\tconst fonts = await loadFonts(fontConfig)\n\tconst satoriFonts = createSatoriFonts(fonts)\n\n\t// Generate noise background\n\tconst noiseEnabled = noiseConfig?.enabled !== false\n\tconst noiseSeedValue = noiseSeed || noiseConfig?.seed || title\n\tconst noiseDataUrl = noiseEnabled\n\t\t? generateNoiseDataUrl({\n\t\t\t\tseed: noiseSeedValue,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.4,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Generate circular noise decoration\n\tconst circleNoiseDataUrl = noiseEnabled\n\t\t? generateCircleNoiseDataUrl({\n\t\t\t\tseed: `${noiseSeedValue}-circle`,\n\t\t\t\tsize: 200,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.15,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Get template function\n\tconst templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])\n\n\t// Build template props\n\tconst props: OgTemplateProps = {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\tcolors,\n\t\tnoiseDataUrl,\n\t\tcircleNoiseDataUrl,\n\t\twidth,\n\t\theight,\n\t}\n\n\t// Render template to Satori-compatible structure\n\tconst element = templateFn(props)\n\n\t// Generate SVG with satori\n\tconst svg = await satori(element as Parameters<typeof satori>[0], {\n\t\twidth,\n\t\theight,\n\t\tfonts: satoriFonts,\n\t})\n\n\t// Debug: return SVG string\n\tif (debugSvg) {\n\t\treturn Buffer.from(svg)\n\t}\n\n\t// Convert SVG to PNG with resvg-js\n\tconst resvg = new Resvg(svg, {\n\t\tfitTo: {\n\t\t\tmode: 'width',\n\t\t\tvalue: width,\n\t\t},\n\t})\n\tconst pngData = resvg.render()\n\n\treturn Buffer.from(pngData.asPng())\n}\n\n/**\n * Generate OG image and return as base64 data URL.\n */\nexport async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {\n\tconst png = await generateOgImage(options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Generate OG image and return as Response (for SvelteKit endpoints).\n */\nexport async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {\n\tconst png = await generateOgImage(options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n","/**\n * @ewanc26/og fonts\n *\n * Font loading utilities. Bundles Inter font by default.\n */\n\nimport { readFile } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { OgFontConfig } from './types.js'\n\n// Declare __dirname for CJS contexts (injected by bundlers)\ndeclare const __dirname: string | undefined\n\n// ─── Paths ────────────────────────────────────────────────────────────────────\n\n/**\n * Get the directory of the current module.\n * Works in both ESM and bundled contexts.\n */\nfunction getModuleDir(): string {\n\t// ESM context\n\tif (typeof import.meta !== 'undefined' && import.meta.url) {\n\t\treturn dirname(fileURLToPath(import.meta.url))\n\t}\n\t// Bundled CJS context - __dirname is injected by bundlers\n\tif (typeof __dirname !== 'undefined') {\n\t\treturn __dirname\n\t}\n\t// Fallback\n\treturn resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')\n}\n\n/**\n * Resolve the fonts directory relative to the installed package.\n * Tries multiple possible locations for serverless compatibility.\n */\nfunction getFontsDir(): string {\n\tconst candidates = [\n\t\t// Standard: fonts next to dist\n\t\tresolve(getModuleDir(), '../fonts'),\n\t\t// Vercel serverless: fonts inside dist\n\t\tresolve(getModuleDir(), 'fonts'),\n\t\t// Fallback: node_modules path\n\t\tresolve(process.cwd(), 'node_modules/@ewanc26/og/fonts'),\n\t]\n\n\tfor (const dir of candidates) {\n\t\tif (existsSync(dir)) {\n\t\t\treturn dir\n\t\t}\n\t}\n\n\t// Return first candidate as fallback (will fail gracefully)\n\treturn candidates[0]\n}\n\n/**\n * Resolve bundled font paths. Uses getters to defer resolution until runtime.\n */\nexport const BUNDLED_FONTS = {\n\tget heading() {\n\t\treturn resolve(getFontsDir(), 'Inter-Bold.ttf')\n\t},\n\tget body() {\n\t\treturn resolve(getFontsDir(), 'Inter-Regular.ttf')\n\t},\n} as const\n\n// Google Fonts CDN fallback URLs\nconst FONT_FALLBACKS = {\n\theading: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Bold.ttf',\n\tbody: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Regular.ttf',\n}\n\n// ─── Font Loading ──────────────────────────────────────────────────────────────\n\nexport interface LoadedFonts {\n\theading: ArrayBuffer\n\tbody: ArrayBuffer\n}\n\n/**\n * Load fonts from config, falling back to bundled fonts,\n * with CDN fallback for serverless environments.\n */\nexport async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {\n\tconst headingPath = config?.heading ?? BUNDLED_FONTS.heading\n\tconst bodyPath = config?.body ?? BUNDLED_FONTS.body\n\n\tconst [heading, body] = await Promise.all([\n\t\tloadFontFileWithFallback(headingPath, FONT_FALLBACKS.heading),\n\t\tloadFontFileWithFallback(bodyPath, FONT_FALLBACKS.body),\n\t])\n\n\treturn { heading, body }\n}\n\n/**\n * Load a font file, falling back to CDN if local file fails.\n */\nasync function loadFontFileWithFallback(path: string, fallbackUrl: string): Promise<ArrayBuffer> {\n\ttry {\n\t\treturn await loadFontFile(path)\n\t} catch (error) {\n\t\tconsole.warn(`Failed to load local font at ${path}, trying CDN fallback:`, error)\n\t\ttry {\n\t\t\treturn await loadFontFile(fallbackUrl)\n\t\t} catch (fallbackError) {\n\t\t\tthrow new Error(`Failed to load font from both local path (${path}) and CDN (${fallbackUrl}): ${fallbackError}`)\n\t\t}\n\t}\n}\n\n/**\n * Load a font from file path or URL.\n */\nasync function loadFontFile(source: string): Promise<ArrayBuffer> {\n\t// Handle URLs\n\tif (source.startsWith('http://') || source.startsWith('https://')) {\n\t\tconst response = await fetch(source)\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to load font from URL: ${source}`)\n\t\t}\n\t\treturn response.arrayBuffer()\n\t}\n\n\t// Handle file paths\n\tconst buffer = await readFile(source)\n\treturn buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)\n}\n\n// ─── Font Registration for Satori ─────────────────────────────────────────────\n\nexport type SatoriFontConfig = {\n\tname: string\n\tdata: ArrayBuffer\n\tweight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900\n\tstyle: 'normal' | 'italic'\n}\n\n/**\n * Create Satori-compatible font config from loaded fonts.\n * Uses Inter font family with weight 700 for headings and 400 for body.\n */\nexport function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {\n\treturn [\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.heading,\n\t\t\tweight: 700,\n\t\t\tstyle: 'normal',\n\t\t},\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.body,\n\t\t\tweight: 400,\n\t\t\tstyle: 'normal',\n\t\t},\n\t]\n}\n","/**\n * @ewanc26/og noise\n */\n\nimport { generateNoisePixels } from '@ewanc26/noise'\nimport { PNGEncoder } from './png-encoder.js'\nimport type { OgNoiseConfig } from './types.js'\n\nexport interface NoiseOptions {\n\tseed: string\n\twidth: number\n\theight: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\nexport interface CircleNoiseOptions {\n\tseed: string\n\tsize: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\n/**\n * Generate a noise PNG as a data URL.\n */\nexport function generateNoiseDataUrl(options: NoiseOptions): string {\n\tconst { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(width, height, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [20, 60] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tif (opacity < 1) {\n\t\tfor (let i = 3; i < pixels.length; i += 4) {\n\t\t\tpixels[i] = Math.round(pixels[i] * opacity)\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, width, height)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n\n/**\n * Generate a circular noise PNG as a data URL.\n * Creates a square image with circular transparency mask.\n */\nexport function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {\n\tconst { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(size, size, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [30, 70] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tconst center = size / 2\n\tconst radius = size / 2\n\n\t// Apply circular mask\n\tfor (let y = 0; y < size; y++) {\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst idx = (y * size + x) * 4\n\t\t\tconst dx = x - center + 0.5\n\t\t\tconst dy = y - center + 0.5\n\t\t\tconst dist = Math.sqrt(dx * dx + dy * dy)\n\n\t\t\tif (dist > radius) {\n\t\t\t\t// Outside circle - fully transparent\n\t\t\t\tpixels[idx + 3] = 0\n\t\t\t} else if (dist > radius - 2) {\n\t\t\t\t// Anti-alias edge\n\t\t\t\tconst edgeOpacity = (radius - dist) / 2\n\t\t\t\tpixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)\n\t\t\t} else {\n\t\t\t\t// Inside circle - apply opacity\n\t\t\t\tpixels[idx + 3] = Math.round(255 * opacity)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, size, size)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n","/**\n * Minimal PNG encoder for noise backgrounds.\n * Uses node:zlib for deflate compression.\n */\n\nimport { deflateSync } from 'node:zlib'\n\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])\n\nfunction crc32(data: Buffer): number {\n\tlet crc = 0xffffffff\n\tconst table: number[] = []\n\n\t// Build CRC table\n\tfor (let n = 0; n < 256; n++) {\n\t\tlet c = n\n\t\tfor (let k = 0; k < 8; k++) {\n\t\t\tc = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n\t\t}\n\t\ttable[n] = c\n\t}\n\n\t// Calculate CRC\n\tfor (let i = 0; i < data.length; i++) {\n\t\tcrc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)\n\t}\n\n\treturn (crc ^ 0xffffffff) >>> 0\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n\tconst length = Buffer.alloc(4)\n\tlength.writeUInt32BE(data.length, 0)\n\n\tconst typeBuffer = Buffer.from(type, 'ascii')\n\tconst crcData = Buffer.concat([typeBuffer, data])\n\tconst crc = Buffer.alloc(4)\n\tcrc.writeUInt32BE(crc32(crcData), 0)\n\n\treturn Buffer.concat([length, typeBuffer, data, crc])\n}\n\nfunction createIHDR(width: number, height: number): Buffer {\n\tconst data = Buffer.alloc(13)\n\tdata.writeUInt32BE(width, 0) // Width\n\tdata.writeUInt32BE(height, 4) // Height\n\tdata.writeUInt8(8, 8) // Bit depth: 8 bits\n\tdata.writeUInt8(2, 9) // Colour type: 2 (RGB)\n\tdata.writeUInt8(0, 10) // Compression method\n\tdata.writeUInt8(0, 11) // Filter method\n\tdata.writeUInt8(0, 12) // Interlace method\n\n\treturn createChunk('IHDR', data)\n}\n\nfunction createIDAT(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\t// Apply filter (none filter = 0) per row\n\tconst rawData = Buffer.alloc(height * (width * 3 + 1))\n\n\tlet srcOffset = 0\n\tlet dstOffset = 0\n\n\tfor (let y = 0; y < height; y++) {\n\t\trawData[dstOffset++] = 0 // Filter type: none\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst r = pixels[srcOffset++]\n\t\t\tconst g = pixels[srcOffset++]\n\t\t\tconst b = pixels[srcOffset++]\n\t\t\tsrcOffset++ // Skip alpha\n\t\t\trawData[dstOffset++] = r\n\t\t\trawData[dstOffset++] = g\n\t\t\trawData[dstOffset++] = b\n\t\t}\n\t}\n\n\tconst compressed = deflateSync(rawData)\n\treturn createChunk('IDAT', compressed)\n}\n\nfunction createIEND(): Buffer {\n\treturn createChunk('IEND', Buffer.alloc(0))\n}\n\n/**\n * Encode raw RGBA pixel data as a PNG Buffer.\n */\nexport function encodePNG(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\treturn Buffer.concat([\n\t\tPNG_SIGNATURE,\n\t\tcreateIHDR(width, height),\n\t\tcreateIDAT(pixels, width, height),\n\t\tcreateIEND(),\n\t])\n}\n\n/**\n * PNGEncoder namespace for cleaner imports.\n */\nexport const PNGEncoder = {\n\tencode: encodePNG,\n}\n","/**\n * Blog OG template.\n * Clean centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function blogTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\ttype: 'h1',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 64,\n\t\t\t\t\t\t\tfontWeight: 700,\n\t\t\t\t\t\t\tcolor: colors.text,\n\t\t\t\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\t\t\t\tmargin: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tlineHeight: 1.1,\n\t\t\t\t\t\t\tmaxWidth: 1000,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: title,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tdescription ? {\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 28,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 28,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tlineHeight: 1.4,\n\t\t\t\t\t\t\tmaxWidth: 900,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: description,\n\t\t\t\t\t},\n\t\t\t\t} : null,\n\t\t\t\t{\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 24,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 56,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\topacity: 0.7,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: siteName,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t].filter(Boolean),\n\t\t},\n\t}\n}\n","/**\n * Profile OG template.\n * Centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function profileTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\timage,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\tconst children: unknown[] = []\n\n\tif (image) {\n\t\tchildren.push({\n\t\t\ttype: 'img',\n\t\t\tprops: {\n\t\t\t\tsrc: image,\n\t\t\t\twidth: 120,\n\t\t\t\theight: 120,\n\t\t\t\tstyle: {\n\t\t\t\t\tborderRadius: '50%',\n\t\t\t\t\tmarginBottom: 32,\n\t\t\t\t\tobjectFit: 'cover',\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\n\tchildren.push({\n\t\ttype: 'h1',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tfontSize: 56,\n\t\t\t\tfontWeight: 700,\n\t\t\t\tcolor: colors.text,\n\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\tmargin: 0,\n\t\t\t\ttextAlign: 'center',\n\t\t\t\tlineHeight: 1.1,\n\t\t\t\tmaxWidth: 900,\n\t\t\t},\n\t\t\tchildren: title,\n\t\t},\n\t})\n\n\tif (description) {\n\t\tchildren.push({\n\t\t\ttype: 'p',\n\t\t\tprops: {\n\t\t\t\tstyle: {\n\t\t\t\t\tfontSize: 26,\n\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\tmarginTop: 20,\n\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\tlineHeight: 1.4,\n\t\t\t\t\tmaxWidth: 700,\n\t\t\t\t},\n\t\t\t\tchildren: description,\n\t\t\t},\n\t\t})\n\t}\n\n\tchildren.push({\n\t\ttype: 'p',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tfontSize: 24,\n\t\t\t\tfontWeight: 400,\n\t\t\t\tcolor: colors.accent,\n\t\t\t\tmarginTop: 48,\n\t\t\t\tmarginBottom: 0,\n\t\t\t\ttextAlign: 'center',\n\t\t\t\topacity: 0.7,\n\t\t\t},\n\t\t\tchildren: siteName,\n\t\t},\n\t})\n\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren,\n\t\t},\n\t}\n}\n","/**\n * Default OG template.\n * Clean, centered layout.\n */\n\nimport type { OgTemplateProps } from '../types.js'\n\nexport function defaultTemplate({\n\ttitle,\n\tdescription,\n\tsiteName,\n\tcolors,\n\twidth,\n\theight,\n}: OgTemplateProps) {\n\treturn {\n\t\ttype: 'div',\n\t\tprops: {\n\t\t\tstyle: {\n\t\t\t\tdisplay: 'flex',\n\t\t\t\tflexDirection: 'column',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tbackgroundColor: colors.background,\n\t\t\t},\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\ttype: 'h1',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 72,\n\t\t\t\t\t\t\tfontWeight: 700,\n\t\t\t\t\t\t\tcolor: colors.text,\n\t\t\t\t\t\t\tletterSpacing: '-0.02em',\n\t\t\t\t\t\t\tmargin: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: title,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tdescription ? {\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 32,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 24,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\tmaxWidth: 900,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: description,\n\t\t\t\t\t},\n\t\t\t\t} : null,\n\t\t\t\t{\n\t\t\t\t\ttype: 'p',\n\t\t\t\t\tprops: {\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\tfontSize: 28,\n\t\t\t\t\t\t\tfontWeight: 400,\n\t\t\t\t\t\t\tcolor: colors.accent,\n\t\t\t\t\t\t\tmarginTop: 64,\n\t\t\t\t\t\t\tmarginBottom: 0,\n\t\t\t\t\t\t\ttextAlign: 'center',\n\t\t\t\t\t\t\topacity: 0.7,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: siteName,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t].filter(Boolean),\n\t\t},\n\t}\n}\n","/**\n * Built-in OG templates.\n */\n\nexport { blogTemplate } from './blog.js'\nexport { profileTemplate } from './profile.js'\nexport { defaultTemplate } from './default.js'\n\nimport { blogTemplate } from './blog.js'\nimport { profileTemplate } from './profile.js'\nimport { defaultTemplate } from './default.js'\nimport type { OgTemplate } from '../types.js'\n\nexport const templates = {\n\tblog: blogTemplate,\n\tprofile: profileTemplate,\n\tdefault: defaultTemplate,\n} as const\n\nexport type TemplateName = keyof typeof templates\n\nexport function getTemplate(name: TemplateName | OgTemplate): OgTemplate {\n\tif (typeof name === 'function') {\n\t\treturn name\n\t}\n\treturn templates[name]\n}\n","/**\n * @ewanc26/og types\n */\n\n// ─── Colour Configuration ─────────────────────────────────────────────────────\n\nexport interface OgColorConfig {\n\t/** Background color (very dark). @default '#0f1a15' */\n\tbackground: string\n\t/** Primary text color. @default '#e8f5e9' */\n\ttext: string\n\t/** Secondary/accent text (mint). @default '#86efac' */\n\taccent: string\n}\n\nexport const defaultColors: OgColorConfig = {\n\tbackground: '#0f1a15',\n\ttext: '#e8f5e9',\n\taccent: '#86efac',\n}\n\n// ─── Font Configuration ───────────────────────────────────────────────────────\n\nexport interface OgFontConfig {\n\theading?: string\n\tbody?: string\n}\n\n// ─── Noise Configuration ──────────────────────────────────────────────────────\n\nexport interface OgNoiseConfig {\n\tenabled?: boolean\n\tseed?: string\n\topacity?: number\n\tcolorMode?: 'grayscale' | 'hsl'\n}\n\n// ─── Template Props ────────────────────────────────────────────────────────────\n\nexport interface OgTemplateProps {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n\tcircleNoiseDataUrl?: string\n\twidth: number\n\theight: number\n}\n\nexport type OgTemplate = (props: OgTemplateProps) => unknown\n\n// ─── Generation Options ───────────────────────────────────────────────────────\n\nexport interface OgGenerateOptions {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\ttemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tnoiseSeed?: string\n\twidth?: number\n\theight?: number\n\tdebugSvg?: boolean\n}\n\n// ─── SvelteKit Endpoint Options ───────────────────────────────────────────────\n\nexport interface OgEndpointOptions {\n\tsiteName: string\n\tdefaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tcacheMaxAge?: number\n\twidth?: number\n\theight?: number\n}\n\n// ─── Internal Types ────────────────────────────────────────────────────────────\n\nexport interface InternalGenerateContext {\n\twidth: number\n\theight: number\n\tfonts: { heading: ArrayBuffer; body: ArrayBuffer }\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n}\n","/**\n * SvelteKit endpoint helpers.\n */\n\nimport { generateOgResponse } from './generate.js'\nimport type { OgEndpointOptions, OgTemplate } from './types.js'\n\n/**\n * Create a SvelteKit GET handler for OG image generation.\n *\n * @example\n * ```ts\n * // src/routes/og/[title]/+server.ts\n * import { createOgEndpoint } from '@ewanc26/og';\n *\n * export const GET = createOgEndpoint({\n * siteName: 'ewancroft.uk',\n * defaultTemplate: 'blog',\n * });\n * ```\n *\n * The endpoint expects query parameters:\n * - `title` (required): Page title\n * - `description`: Optional description\n * - `image`: Optional avatar/logo URL\n * - `seed`: Optional noise seed\n */\nexport function createOgEndpoint(options: OgEndpointOptions) {\n\tconst {\n\t\tsiteName,\n\t\tdefaultTemplate: template = 'default',\n\t\tcolors,\n\t\tfonts,\n\t\tnoise,\n\t\tcacheMaxAge = 3600,\n\t\twidth,\n\t\theight,\n\t} = options\n\n\treturn async ({ url }: { url: URL }) => {\n\t\tconst title = url.searchParams.get('title')\n\t\tconst description = url.searchParams.get('description') ?? undefined\n\t\tconst image = url.searchParams.get('image') ?? undefined\n\t\tconst noiseSeed = url.searchParams.get('seed') ?? undefined\n\n\t\tif (!title) {\n\t\t\treturn new Response('Missing title parameter', { status: 400 })\n\t\t}\n\n\t\ttry {\n\t\t\treturn await generateOgResponse(\n\t\t\t\t{\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tsiteName,\n\t\t\t\t\timage,\n\t\t\t\t\ttemplate: template as OgTemplate,\n\t\t\t\t\tcolors,\n\t\t\t\t\tfonts,\n\t\t\t\t\tnoise,\n\t\t\t\t\tnoiseSeed,\n\t\t\t\t\twidth,\n\t\t\t\t\theight,\n\t\t\t\t},\n\t\t\t\tcacheMaxAge\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to generate OG image:', error)\n\t\t\treturn new Response('Failed to generate image', { status: 500 })\n\t\t}\n\t}\n}\n\n/**\n * Create a typed OG image URL for use in meta tags.\n */\nexport function createOgImageUrl(\n\tbaseUrl: string,\n\tparams: {\n\t\ttitle: string\n\t\tdescription?: string\n\t\timage?: string\n\t\tseed?: string\n\t}\n): string {\n\tconst url = new URL(baseUrl)\n\turl.searchParams.set('title', params.title)\n\tif (params.description) url.searchParams.set('description', params.description)\n\tif (params.image) url.searchParams.set('image', params.image)\n\tif (params.seed) url.searchParams.set('seed', params.seed)\n\treturn url.toString()\n}\n","/**\n * SVG to PNG conversion using @resvg/resvg-js.\n */\n\nimport { Resvg } from '@resvg/resvg-js'\n\nexport interface SvgToPngOptions {\n\t/** Scale to fit width in pixels */\n\tfitWidth?: number\n\t/** Background colour for transparent areas */\n\tbackgroundColor?: string\n}\n\n/**\n * Convert an SVG string to PNG Buffer.\n */\nexport function svgToPng(svg: string, options: SvgToPngOptions = {}): Buffer {\n\tconst opts = {\n\t\tfitTo: options.fitWidth\n\t\t\t? { mode: 'width' as const, value: options.fitWidth }\n\t\t\t: undefined,\n\t\tbackground: options.backgroundColor,\n\t}\n\n\tconst resvg = new Resvg(svg, opts)\n\tconst rendered = resvg.render()\n\n\treturn Buffer.from(rendered.asPng())\n}\n\n/**\n * Convert an SVG string to PNG data URL.\n */\nexport function svgToPngDataUrl(svg: string, options: SvgToPngOptions = {}): string {\n\tconst png = svgToPng(svg, options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Convert an SVG string to PNG Response (for SvelteKit endpoints).\n */\nexport function svgToPngResponse(svg: string, options: SvgToPngOptions = {}, cacheMaxAge = 3600): Response {\n\tconst png = svgToPng(svg, options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n"],"mappings":"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","names":["satori","import_resvg_js"]}
package/dist/index.d.cts CHANGED
@@ -66,7 +66,8 @@ interface LoadedFonts {
66
66
  body: ArrayBuffer;
67
67
  }
68
68
  /**
69
- * Load fonts from config, falling back to bundled DM Sans.
69
+ * Load fonts from config, falling back to bundled fonts,
70
+ * with CDN fallback for serverless environments.
70
71
  */
71
72
  declare function loadFonts(config?: OgFontConfig): Promise<LoadedFonts>;
72
73
  type SatoriFontConfig = {
package/dist/index.d.ts CHANGED
@@ -66,7 +66,8 @@ interface LoadedFonts {
66
66
  body: ArrayBuffer;
67
67
  }
68
68
  /**
69
- * Load fonts from config, falling back to bundled DM Sans.
69
+ * Load fonts from config, falling back to bundled fonts,
70
+ * with CDN fallback for serverless environments.
70
71
  */
71
72
  declare function loadFonts(config?: OgFontConfig): Promise<LoadedFonts>;
72
73
  type SatoriFontConfig = {
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { Resvg } from "@resvg/resvg-js";
8
8
 
9
9
  // src/fonts.ts
10
10
  import { readFile } from "fs/promises";
11
+ import { existsSync } from "fs";
11
12
  import { dirname, resolve } from "path";
12
13
  import { fileURLToPath } from "url";
13
14
  function getModuleDir() {
@@ -20,7 +21,20 @@ function getModuleDir() {
20
21
  return resolve(process.cwd(), "node_modules/@ewanc26/og/dist");
21
22
  }
22
23
  function getFontsDir() {
23
- return resolve(getModuleDir(), "../fonts");
24
+ const candidates = [
25
+ // Standard: fonts next to dist
26
+ resolve(getModuleDir(), "../fonts"),
27
+ // Vercel serverless: fonts inside dist
28
+ resolve(getModuleDir(), "fonts"),
29
+ // Fallback: node_modules path
30
+ resolve(process.cwd(), "node_modules/@ewanc26/og/fonts")
31
+ ];
32
+ for (const dir of candidates) {
33
+ if (existsSync(dir)) {
34
+ return dir;
35
+ }
36
+ }
37
+ return candidates[0];
24
38
  }
25
39
  var BUNDLED_FONTS = {
26
40
  get heading() {
@@ -30,15 +44,31 @@ var BUNDLED_FONTS = {
30
44
  return resolve(getFontsDir(), "Inter-Regular.ttf");
31
45
  }
32
46
  };
47
+ var FONT_FALLBACKS = {
48
+ heading: "https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Bold.ttf",
49
+ body: "https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Regular.ttf"
50
+ };
33
51
  async function loadFonts(config) {
34
52
  const headingPath = config?.heading ?? BUNDLED_FONTS.heading;
35
53
  const bodyPath = config?.body ?? BUNDLED_FONTS.body;
36
54
  const [heading, body] = await Promise.all([
37
- loadFontFile(headingPath),
38
- loadFontFile(bodyPath)
55
+ loadFontFileWithFallback(headingPath, FONT_FALLBACKS.heading),
56
+ loadFontFileWithFallback(bodyPath, FONT_FALLBACKS.body)
39
57
  ]);
40
58
  return { heading, body };
41
59
  }
60
+ async function loadFontFileWithFallback(path, fallbackUrl) {
61
+ try {
62
+ return await loadFontFile(path);
63
+ } catch (error) {
64
+ console.warn(`Failed to load local font at ${path}, trying CDN fallback:`, error);
65
+ try {
66
+ return await loadFontFile(fallbackUrl);
67
+ } catch (fallbackError) {
68
+ throw new Error(`Failed to load font from both local path (${path}) and CDN (${fallbackUrl}): ${fallbackError}`);
69
+ }
70
+ }
71
+ }
42
72
  async function loadFontFile(source) {
43
73
  if (source.startsWith("http://") || source.startsWith("https://")) {
44
74
  const response = await fetch(source);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/generate.ts","../src/fonts.ts","../src/noise.ts","../src/png-encoder.ts","../src/types.ts","../src/endpoint.ts","../src/svg.ts"],"sourcesContent":["/**\n * Core OG image generation.\n * Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.\n */\n\nimport satori from 'satori'\nimport { Resvg } from '@resvg/resvg-js'\nimport { loadFonts, createSatoriFonts } from './fonts.js'\nimport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\nimport { getTemplate } from './templates/index.js'\nimport { defaultColors } from './types.js'\nimport type {\n\tOgGenerateOptions,\n\tOgColorConfig,\n\tOgTemplateProps,\n} from './types.js'\n\n// Standard OG image dimensions\nexport const OG_WIDTH = 1200\nexport const OG_HEIGHT = 630\n\n/**\n * Generate an OG image as PNG Buffer.\n */\nexport async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {\n\tconst {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\ttemplate = 'blog',\n\t\tcolors: colorOverrides,\n\t\tfonts: fontConfig,\n\t\tnoise: noiseConfig,\n\t\tnoiseSeed,\n\t\twidth = OG_WIDTH,\n\t\theight = OG_HEIGHT,\n\t\tdebugSvg = false,\n\t} = options\n\n\t// Merge colours\n\tconst colors: OgColorConfig = {\n\t\t...defaultColors,\n\t\t...colorOverrides,\n\t}\n\n\t// Load fonts\n\tconst fonts = await loadFonts(fontConfig)\n\tconst satoriFonts = createSatoriFonts(fonts)\n\n\t// Generate noise background\n\tconst noiseEnabled = noiseConfig?.enabled !== false\n\tconst noiseSeedValue = noiseSeed || noiseConfig?.seed || title\n\tconst noiseDataUrl = noiseEnabled\n\t\t? generateNoiseDataUrl({\n\t\t\t\tseed: noiseSeedValue,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.4,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Generate circular noise decoration\n\tconst circleNoiseDataUrl = noiseEnabled\n\t\t? generateCircleNoiseDataUrl({\n\t\t\t\tseed: `${noiseSeedValue}-circle`,\n\t\t\t\tsize: 200,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.15,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Get template function\n\tconst templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])\n\n\t// Build template props\n\tconst props: OgTemplateProps = {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\tcolors,\n\t\tnoiseDataUrl,\n\t\tcircleNoiseDataUrl,\n\t\twidth,\n\t\theight,\n\t}\n\n\t// Render template to Satori-compatible structure\n\tconst element = templateFn(props)\n\n\t// Generate SVG with satori\n\tconst svg = await satori(element as Parameters<typeof satori>[0], {\n\t\twidth,\n\t\theight,\n\t\tfonts: satoriFonts,\n\t})\n\n\t// Debug: return SVG string\n\tif (debugSvg) {\n\t\treturn Buffer.from(svg)\n\t}\n\n\t// Convert SVG to PNG with resvg-js\n\tconst resvg = new Resvg(svg, {\n\t\tfitTo: {\n\t\t\tmode: 'width',\n\t\t\tvalue: width,\n\t\t},\n\t})\n\tconst pngData = resvg.render()\n\n\treturn Buffer.from(pngData.asPng())\n}\n\n/**\n * Generate OG image and return as base64 data URL.\n */\nexport async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {\n\tconst png = await generateOgImage(options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Generate OG image and return as Response (for SvelteKit endpoints).\n */\nexport async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {\n\tconst png = await generateOgImage(options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n","/**\n * @ewanc26/og fonts\n *\n * Font loading utilities. Bundles Inter font by default.\n */\n\nimport { readFile } from 'node:fs/promises'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { OgFontConfig } from './types.js'\n\n// Declare __dirname for CJS contexts (injected by bundlers)\ndeclare const __dirname: string | undefined\n\n// ─── Paths ────────────────────────────────────────────────────────────────────\n\n/**\n * Get the directory of the current module.\n * Works in both ESM and bundled contexts.\n */\nfunction getModuleDir(): string {\n\t// ESM context\n\tif (typeof import.meta !== 'undefined' && import.meta.url) {\n\t\treturn dirname(fileURLToPath(import.meta.url))\n\t}\n\t// Bundled CJS context - __dirname is injected by bundlers\n\tif (typeof __dirname !== 'undefined') {\n\t\treturn __dirname\n\t}\n\t// Fallback\n\treturn resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')\n}\n\n/**\n * Resolve the fonts directory relative to the installed package.\n */\nfunction getFontsDir(): string {\n\treturn resolve(getModuleDir(), '../fonts')\n}\n\n/**\n * Resolve bundled font paths. Uses getters to defer resolution until runtime.\n */\nexport const BUNDLED_FONTS = {\n\tget heading() {\n\t\treturn resolve(getFontsDir(), 'Inter-Bold.ttf')\n\t},\n\tget body() {\n\t\treturn resolve(getFontsDir(), 'Inter-Regular.ttf')\n\t},\n} as const\n\n// ─── Font Loading ──────────────────────────────────────────────────────────────\n\nexport interface LoadedFonts {\n\theading: ArrayBuffer\n\tbody: ArrayBuffer\n}\n\n/**\n * Load fonts from config, falling back to bundled DM Sans.\n */\nexport async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {\n\tconst headingPath = config?.heading ?? BUNDLED_FONTS.heading\n\tconst bodyPath = config?.body ?? BUNDLED_FONTS.body\n\n\tconst [heading, body] = await Promise.all([\n\t\tloadFontFile(headingPath),\n\t\tloadFontFile(bodyPath),\n\t])\n\n\treturn { heading, body }\n}\n\n/**\n * Load a font from file path or URL.\n */\nasync function loadFontFile(source: string): Promise<ArrayBuffer> {\n\t// Handle URLs\n\tif (source.startsWith('http://') || source.startsWith('https://')) {\n\t\tconst response = await fetch(source)\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to load font from URL: ${source}`)\n\t\t}\n\t\treturn response.arrayBuffer()\n\t}\n\n\t// Handle file paths\n\tconst buffer = await readFile(source)\n\treturn buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)\n}\n\n// ─── Font Registration for Satori ─────────────────────────────────────────────\n\nexport type SatoriFontConfig = {\n\tname: string\n\tdata: ArrayBuffer\n\tweight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900\n\tstyle: 'normal' | 'italic'\n}\n\n/**\n * Create Satori-compatible font config from loaded fonts.\n * Uses Inter font family with weight 700 for headings and 400 for body.\n */\nexport function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {\n\treturn [\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.heading,\n\t\t\tweight: 700,\n\t\t\tstyle: 'normal',\n\t\t},\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.body,\n\t\t\tweight: 400,\n\t\t\tstyle: 'normal',\n\t\t},\n\t]\n}\n","/**\n * @ewanc26/og noise\n */\n\nimport { generateNoisePixels } from '@ewanc26/noise'\nimport { PNGEncoder } from './png-encoder.js'\nimport type { OgNoiseConfig } from './types.js'\n\nexport interface NoiseOptions {\n\tseed: string\n\twidth: number\n\theight: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\nexport interface CircleNoiseOptions {\n\tseed: string\n\tsize: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\n/**\n * Generate a noise PNG as a data URL.\n */\nexport function generateNoiseDataUrl(options: NoiseOptions): string {\n\tconst { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(width, height, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [20, 60] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tif (opacity < 1) {\n\t\tfor (let i = 3; i < pixels.length; i += 4) {\n\t\t\tpixels[i] = Math.round(pixels[i] * opacity)\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, width, height)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n\n/**\n * Generate a circular noise PNG as a data URL.\n * Creates a square image with circular transparency mask.\n */\nexport function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {\n\tconst { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(size, size, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [30, 70] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tconst center = size / 2\n\tconst radius = size / 2\n\n\t// Apply circular mask\n\tfor (let y = 0; y < size; y++) {\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst idx = (y * size + x) * 4\n\t\t\tconst dx = x - center + 0.5\n\t\t\tconst dy = y - center + 0.5\n\t\t\tconst dist = Math.sqrt(dx * dx + dy * dy)\n\n\t\t\tif (dist > radius) {\n\t\t\t\t// Outside circle - fully transparent\n\t\t\t\tpixels[idx + 3] = 0\n\t\t\t} else if (dist > radius - 2) {\n\t\t\t\t// Anti-alias edge\n\t\t\t\tconst edgeOpacity = (radius - dist) / 2\n\t\t\t\tpixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)\n\t\t\t} else {\n\t\t\t\t// Inside circle - apply opacity\n\t\t\t\tpixels[idx + 3] = Math.round(255 * opacity)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, size, size)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n","/**\n * Minimal PNG encoder for noise backgrounds.\n * Uses node:zlib for deflate compression.\n */\n\nimport { deflateSync } from 'node:zlib'\n\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])\n\nfunction crc32(data: Buffer): number {\n\tlet crc = 0xffffffff\n\tconst table: number[] = []\n\n\t// Build CRC table\n\tfor (let n = 0; n < 256; n++) {\n\t\tlet c = n\n\t\tfor (let k = 0; k < 8; k++) {\n\t\t\tc = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n\t\t}\n\t\ttable[n] = c\n\t}\n\n\t// Calculate CRC\n\tfor (let i = 0; i < data.length; i++) {\n\t\tcrc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)\n\t}\n\n\treturn (crc ^ 0xffffffff) >>> 0\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n\tconst length = Buffer.alloc(4)\n\tlength.writeUInt32BE(data.length, 0)\n\n\tconst typeBuffer = Buffer.from(type, 'ascii')\n\tconst crcData = Buffer.concat([typeBuffer, data])\n\tconst crc = Buffer.alloc(4)\n\tcrc.writeUInt32BE(crc32(crcData), 0)\n\n\treturn Buffer.concat([length, typeBuffer, data, crc])\n}\n\nfunction createIHDR(width: number, height: number): Buffer {\n\tconst data = Buffer.alloc(13)\n\tdata.writeUInt32BE(width, 0) // Width\n\tdata.writeUInt32BE(height, 4) // Height\n\tdata.writeUInt8(8, 8) // Bit depth: 8 bits\n\tdata.writeUInt8(2, 9) // Colour type: 2 (RGB)\n\tdata.writeUInt8(0, 10) // Compression method\n\tdata.writeUInt8(0, 11) // Filter method\n\tdata.writeUInt8(0, 12) // Interlace method\n\n\treturn createChunk('IHDR', data)\n}\n\nfunction createIDAT(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\t// Apply filter (none filter = 0) per row\n\tconst rawData = Buffer.alloc(height * (width * 3 + 1))\n\n\tlet srcOffset = 0\n\tlet dstOffset = 0\n\n\tfor (let y = 0; y < height; y++) {\n\t\trawData[dstOffset++] = 0 // Filter type: none\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst r = pixels[srcOffset++]\n\t\t\tconst g = pixels[srcOffset++]\n\t\t\tconst b = pixels[srcOffset++]\n\t\t\tsrcOffset++ // Skip alpha\n\t\t\trawData[dstOffset++] = r\n\t\t\trawData[dstOffset++] = g\n\t\t\trawData[dstOffset++] = b\n\t\t}\n\t}\n\n\tconst compressed = deflateSync(rawData)\n\treturn createChunk('IDAT', compressed)\n}\n\nfunction createIEND(): Buffer {\n\treturn createChunk('IEND', Buffer.alloc(0))\n}\n\n/**\n * Encode raw RGBA pixel data as a PNG Buffer.\n */\nexport function encodePNG(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\treturn Buffer.concat([\n\t\tPNG_SIGNATURE,\n\t\tcreateIHDR(width, height),\n\t\tcreateIDAT(pixels, width, height),\n\t\tcreateIEND(),\n\t])\n}\n\n/**\n * PNGEncoder namespace for cleaner imports.\n */\nexport const PNGEncoder = {\n\tencode: encodePNG,\n}\n","/**\n * @ewanc26/og types\n */\n\n// ─── Colour Configuration ─────────────────────────────────────────────────────\n\nexport interface OgColorConfig {\n\t/** Background color (very dark). @default '#0f1a15' */\n\tbackground: string\n\t/** Primary text color. @default '#e8f5e9' */\n\ttext: string\n\t/** Secondary/accent text (mint). @default '#86efac' */\n\taccent: string\n}\n\nexport const defaultColors: OgColorConfig = {\n\tbackground: '#0f1a15',\n\ttext: '#e8f5e9',\n\taccent: '#86efac',\n}\n\n// ─── Font Configuration ───────────────────────────────────────────────────────\n\nexport interface OgFontConfig {\n\theading?: string\n\tbody?: string\n}\n\n// ─── Noise Configuration ──────────────────────────────────────────────────────\n\nexport interface OgNoiseConfig {\n\tenabled?: boolean\n\tseed?: string\n\topacity?: number\n\tcolorMode?: 'grayscale' | 'hsl'\n}\n\n// ─── Template Props ────────────────────────────────────────────────────────────\n\nexport interface OgTemplateProps {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n\tcircleNoiseDataUrl?: string\n\twidth: number\n\theight: number\n}\n\nexport type OgTemplate = (props: OgTemplateProps) => unknown\n\n// ─── Generation Options ───────────────────────────────────────────────────────\n\nexport interface OgGenerateOptions {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\ttemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tnoiseSeed?: string\n\twidth?: number\n\theight?: number\n\tdebugSvg?: boolean\n}\n\n// ─── SvelteKit Endpoint Options ───────────────────────────────────────────────\n\nexport interface OgEndpointOptions {\n\tsiteName: string\n\tdefaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tcacheMaxAge?: number\n\twidth?: number\n\theight?: number\n}\n\n// ─── Internal Types ────────────────────────────────────────────────────────────\n\nexport interface InternalGenerateContext {\n\twidth: number\n\theight: number\n\tfonts: { heading: ArrayBuffer; body: ArrayBuffer }\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n}\n","/**\n * SvelteKit endpoint helpers.\n */\n\nimport { generateOgResponse } from './generate.js'\nimport type { OgEndpointOptions, OgTemplate } from './types.js'\n\n/**\n * Create a SvelteKit GET handler for OG image generation.\n *\n * @example\n * ```ts\n * // src/routes/og/[title]/+server.ts\n * import { createOgEndpoint } from '@ewanc26/og';\n *\n * export const GET = createOgEndpoint({\n * siteName: 'ewancroft.uk',\n * defaultTemplate: 'blog',\n * });\n * ```\n *\n * The endpoint expects query parameters:\n * - `title` (required): Page title\n * - `description`: Optional description\n * - `image`: Optional avatar/logo URL\n * - `seed`: Optional noise seed\n */\nexport function createOgEndpoint(options: OgEndpointOptions) {\n\tconst {\n\t\tsiteName,\n\t\tdefaultTemplate: template = 'default',\n\t\tcolors,\n\t\tfonts,\n\t\tnoise,\n\t\tcacheMaxAge = 3600,\n\t\twidth,\n\t\theight,\n\t} = options\n\n\treturn async ({ url }: { url: URL }) => {\n\t\tconst title = url.searchParams.get('title')\n\t\tconst description = url.searchParams.get('description') ?? undefined\n\t\tconst image = url.searchParams.get('image') ?? undefined\n\t\tconst noiseSeed = url.searchParams.get('seed') ?? undefined\n\n\t\tif (!title) {\n\t\t\treturn new Response('Missing title parameter', { status: 400 })\n\t\t}\n\n\t\ttry {\n\t\t\treturn await generateOgResponse(\n\t\t\t\t{\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tsiteName,\n\t\t\t\t\timage,\n\t\t\t\t\ttemplate: template as OgTemplate,\n\t\t\t\t\tcolors,\n\t\t\t\t\tfonts,\n\t\t\t\t\tnoise,\n\t\t\t\t\tnoiseSeed,\n\t\t\t\t\twidth,\n\t\t\t\t\theight,\n\t\t\t\t},\n\t\t\t\tcacheMaxAge\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to generate OG image:', error)\n\t\t\treturn new Response('Failed to generate image', { status: 500 })\n\t\t}\n\t}\n}\n\n/**\n * Create a typed OG image URL for use in meta tags.\n */\nexport function createOgImageUrl(\n\tbaseUrl: string,\n\tparams: {\n\t\ttitle: string\n\t\tdescription?: string\n\t\timage?: string\n\t\tseed?: string\n\t}\n): string {\n\tconst url = new URL(baseUrl)\n\turl.searchParams.set('title', params.title)\n\tif (params.description) url.searchParams.set('description', params.description)\n\tif (params.image) url.searchParams.set('image', params.image)\n\tif (params.seed) url.searchParams.set('seed', params.seed)\n\treturn url.toString()\n}\n","/**\n * SVG to PNG conversion using @resvg/resvg-js.\n */\n\nimport { Resvg } from '@resvg/resvg-js'\n\nexport interface SvgToPngOptions {\n\t/** Scale to fit width in pixels */\n\tfitWidth?: number\n\t/** Background colour for transparent areas */\n\tbackgroundColor?: string\n}\n\n/**\n * Convert an SVG string to PNG Buffer.\n */\nexport function svgToPng(svg: string, options: SvgToPngOptions = {}): Buffer {\n\tconst opts = {\n\t\tfitTo: options.fitWidth\n\t\t\t? { mode: 'width' as const, value: options.fitWidth }\n\t\t\t: undefined,\n\t\tbackground: options.backgroundColor,\n\t}\n\n\tconst resvg = new Resvg(svg, opts)\n\tconst rendered = resvg.render()\n\n\treturn Buffer.from(rendered.asPng())\n}\n\n/**\n * Convert an SVG string to PNG data URL.\n */\nexport function svgToPngDataUrl(svg: string, options: SvgToPngOptions = {}): string {\n\tconst png = svgToPng(svg, options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Convert an SVG string to PNG Response (for SvelteKit endpoints).\n */\nexport function svgToPngResponse(svg: string, options: SvgToPngOptions = {}, cacheMaxAge = 3600): Response {\n\tconst png = svgToPng(svg, options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n"],"mappings":";;;;;AAKA,OAAO,YAAY;AACnB,SAAS,aAAa;;;ACAtB,SAAS,gBAAgB;AACzB,SAAS,SAAS,eAAe;AACjC,SAAS,qBAAqB;AAY9B,SAAS,eAAuB;AAE/B,MAAI,OAAO,gBAAgB,eAAe,YAAY,KAAK;AAC1D,WAAO,QAAQ,cAAc,YAAY,GAAG,CAAC;AAAA,EAC9C;AAEA,MAAI,OAAO,cAAc,aAAa;AACrC,WAAO;AAAA,EACR;AAEA,SAAO,QAAQ,QAAQ,IAAI,GAAG,+BAA+B;AAC9D;AAKA,SAAS,cAAsB;AAC9B,SAAO,QAAQ,aAAa,GAAG,UAAU;AAC1C;AAKO,IAAM,gBAAgB;AAAA,EAC5B,IAAI,UAAU;AACb,WAAO,QAAQ,YAAY,GAAG,gBAAgB;AAAA,EAC/C;AAAA,EACA,IAAI,OAAO;AACV,WAAO,QAAQ,YAAY,GAAG,mBAAmB;AAAA,EAClD;AACD;AAYA,eAAsB,UAAU,QAA6C;AAC5E,QAAM,cAAc,QAAQ,WAAW,cAAc;AACrD,QAAM,WAAW,QAAQ,QAAQ,cAAc;AAE/C,QAAM,CAAC,SAAS,IAAI,IAAI,MAAM,QAAQ,IAAI;AAAA,IACzC,aAAa,WAAW;AAAA,IACxB,aAAa,QAAQ;AAAA,EACtB,CAAC;AAED,SAAO,EAAE,SAAS,KAAK;AACxB;AAKA,eAAe,aAAa,QAAsC;AAEjE,MAAI,OAAO,WAAW,SAAS,KAAK,OAAO,WAAW,UAAU,GAAG;AAClE,UAAM,WAAW,MAAM,MAAM,MAAM;AACnC,QAAI,CAAC,SAAS,IAAI;AACjB,YAAM,IAAI,MAAM,iCAAiC,MAAM,EAAE;AAAA,IAC1D;AACA,WAAO,SAAS,YAAY;AAAA,EAC7B;AAGA,QAAM,SAAS,MAAM,SAAS,MAAM;AACpC,SAAO,OAAO,OAAO,MAAM,OAAO,YAAY,OAAO,aAAa,OAAO,UAAU;AACpF;AAeO,SAAS,kBAAkB,OAAwC;AACzE,SAAO;AAAA,IACN;AAAA,MACC,MAAM;AAAA,MACN,MAAM,MAAM;AAAA,MACZ,QAAQ;AAAA,MACR,OAAO;AAAA,IACR;AAAA,IACA;AAAA,MACC,MAAM;AAAA,MACN,MAAM,MAAM;AAAA,MACZ,QAAQ;AAAA,MACR,OAAO;AAAA,IACR;AAAA,EACD;AACD;;;ACpHA,SAAS,2BAA2B;;;ACCpC,SAAS,mBAAmB;AAE5B,IAAM,gBAAgB,OAAO,KAAK,CAAC,KAAM,IAAM,IAAM,IAAM,IAAM,IAAM,IAAM,EAAI,CAAC;AAElF,SAAS,MAAM,MAAsB;AACpC,MAAI,MAAM;AACV,QAAM,QAAkB,CAAC;AAGzB,WAAS,IAAI,GAAG,IAAI,KAAK,KAAK;AAC7B,QAAI,IAAI;AACR,aAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC3B,UAAI,IAAI,IAAI,aAAc,MAAM,IAAK,MAAM;AAAA,IAC5C;AACA,UAAM,CAAC,IAAI;AAAA,EACZ;AAGA,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACrC,UAAM,OAAO,MAAM,KAAK,CAAC,KAAK,GAAI,IAAK,QAAQ;AAAA,EAChD;AAEA,UAAQ,MAAM,gBAAgB;AAC/B;AAEA,SAAS,YAAY,MAAc,MAAsB;AACxD,QAAM,SAAS,OAAO,MAAM,CAAC;AAC7B,SAAO,cAAc,KAAK,QAAQ,CAAC;AAEnC,QAAM,aAAa,OAAO,KAAK,MAAM,OAAO;AAC5C,QAAM,UAAU,OAAO,OAAO,CAAC,YAAY,IAAI,CAAC;AAChD,QAAM,MAAM,OAAO,MAAM,CAAC;AAC1B,MAAI,cAAc,MAAM,OAAO,GAAG,CAAC;AAEnC,SAAO,OAAO,OAAO,CAAC,QAAQ,YAAY,MAAM,GAAG,CAAC;AACrD;AAEA,SAAS,WAAW,OAAe,QAAwB;AAC1D,QAAM,OAAO,OAAO,MAAM,EAAE;AAC5B,OAAK,cAAc,OAAO,CAAC;AAC3B,OAAK,cAAc,QAAQ,CAAC;AAC5B,OAAK,WAAW,GAAG,CAAC;AACpB,OAAK,WAAW,GAAG,CAAC;AACpB,OAAK,WAAW,GAAG,EAAE;AACrB,OAAK,WAAW,GAAG,EAAE;AACrB,OAAK,WAAW,GAAG,EAAE;AAErB,SAAO,YAAY,QAAQ,IAAI;AAChC;AAEA,SAAS,WAAW,QAA2B,OAAe,QAAwB;AAErF,QAAM,UAAU,OAAO,MAAM,UAAU,QAAQ,IAAI,EAAE;AAErD,MAAI,YAAY;AAChB,MAAI,YAAY;AAEhB,WAAS,IAAI,GAAG,IAAI,QAAQ,KAAK;AAChC,YAAQ,WAAW,IAAI;AACvB,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC/B,YAAM,IAAI,OAAO,WAAW;AAC5B,YAAM,IAAI,OAAO,WAAW;AAC5B,YAAM,IAAI,OAAO,WAAW;AAC5B;AACA,cAAQ,WAAW,IAAI;AACvB,cAAQ,WAAW,IAAI;AACvB,cAAQ,WAAW,IAAI;AAAA,IACxB;AAAA,EACD;AAEA,QAAM,aAAa,YAAY,OAAO;AACtC,SAAO,YAAY,QAAQ,UAAU;AACtC;AAEA,SAAS,aAAqB;AAC7B,SAAO,YAAY,QAAQ,OAAO,MAAM,CAAC,CAAC;AAC3C;AAKO,SAAS,UAAU,QAA2B,OAAe,QAAwB;AAC3F,SAAO,OAAO,OAAO;AAAA,IACpB;AAAA,IACA,WAAW,OAAO,MAAM;AAAA,IACxB,WAAW,QAAQ,OAAO,MAAM;AAAA,IAChC,WAAW;AAAA,EACZ,CAAC;AACF;AAKO,IAAM,aAAa;AAAA,EACzB,QAAQ;AACT;;;AD1EO,SAAS,qBAAqB,SAA+B;AACnE,QAAM,EAAE,MAAM,OAAO,QAAQ,UAAU,KAAK,YAAY,YAAY,IAAI;AAExE,QAAM,SAAS,oBAAoB,OAAO,QAAQ,MAAM;AAAA,IACvD,UAAU;AAAA,IACV,SAAS;AAAA,IACT,WAAW,cAAc,cACtB,EAAE,MAAM,aAAa,OAAO,CAAC,IAAI,EAAE,EAAE,IACrC,EAAE,MAAM,OAAO,UAAU,IAAI,iBAAiB,CAAC,IAAI,EAAE,GAAG,gBAAgB,CAAC,IAAI,EAAE,EAAE;AAAA,EACrF,CAAC;AAED,MAAI,UAAU,GAAG;AAChB,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,GAAG;AAC1C,aAAO,CAAC,IAAI,KAAK,MAAM,OAAO,CAAC,IAAI,OAAO;AAAA,IAC3C;AAAA,EACD;AAEA,QAAM,YAAY,WAAW,OAAO,QAAQ,OAAO,MAAM;AACzD,SAAO,yBAAyB,UAAU,SAAS,QAAQ,CAAC;AAC7D;AAMO,SAAS,2BAA2B,SAAqC;AAC/E,QAAM,EAAE,MAAM,MAAM,UAAU,MAAM,YAAY,YAAY,IAAI;AAEhE,QAAM,SAAS,oBAAoB,MAAM,MAAM,MAAM;AAAA,IACpD,UAAU;AAAA,IACV,SAAS;AAAA,IACT,WAAW,cAAc,cACtB,EAAE,MAAM,aAAa,OAAO,CAAC,IAAI,EAAE,EAAE,IACrC,EAAE,MAAM,OAAO,UAAU,IAAI,iBAAiB,CAAC,IAAI,EAAE,GAAG,gBAAgB,CAAC,IAAI,EAAE,EAAE;AAAA,EACrF,CAAC;AAED,QAAM,SAAS,OAAO;AACtB,QAAM,SAAS,OAAO;AAGtB,WAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC9B,aAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC9B,YAAM,OAAO,IAAI,OAAO,KAAK;AAC7B,YAAM,KAAK,IAAI,SAAS;AACxB,YAAM,KAAK,IAAI,SAAS;AACxB,YAAM,OAAO,KAAK,KAAK,KAAK,KAAK,KAAK,EAAE;AAExC,UAAI,OAAO,QAAQ;AAElB,eAAO,MAAM,CAAC,IAAI;AAAA,MACnB,WAAW,OAAO,SAAS,GAAG;AAE7B,cAAM,eAAe,SAAS,QAAQ;AACtC,eAAO,MAAM,CAAC,IAAI,KAAK,MAAM,MAAM,cAAc,OAAO;AAAA,MACzD,OAAO;AAEN,eAAO,MAAM,CAAC,IAAI,KAAK,MAAM,MAAM,OAAO;AAAA,MAC3C;AAAA,IACD;AAAA,EACD;AAEA,QAAM,YAAY,WAAW,OAAO,QAAQ,MAAM,IAAI;AACtD,SAAO,yBAAyB,UAAU,SAAS,QAAQ,CAAC;AAC7D;;;AE1EO,IAAM,gBAA+B;AAAA,EAC3C,YAAY;AAAA,EACZ,MAAM;AAAA,EACN,QAAQ;AACT;;;AJDO,IAAM,WAAW;AACjB,IAAM,YAAY;AAKzB,eAAsB,gBAAgB,SAA6C;AAClF,QAAM;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX,QAAQ;AAAA,IACR,OAAO;AAAA,IACP,OAAO;AAAA,IACP;AAAA,IACA,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,WAAW;AAAA,EACZ,IAAI;AAGJ,QAAM,SAAwB;AAAA,IAC7B,GAAG;AAAA,IACH,GAAG;AAAA,EACJ;AAGA,QAAM,QAAQ,MAAM,UAAU,UAAU;AACxC,QAAM,cAAc,kBAAkB,KAAK;AAG3C,QAAM,eAAe,aAAa,YAAY;AAC9C,QAAM,iBAAiB,aAAa,aAAa,QAAQ;AACzD,QAAM,eAAe,eAClB,qBAAqB;AAAA,IACrB,MAAM;AAAA,IACN;AAAA,IACA;AAAA,IACA,SAAS,aAAa,WAAW;AAAA,IACjC,WAAW,aAAa,aAAa;AAAA,EACtC,CAAC,IACA;AAGH,QAAM,qBAAqB,eACxB,2BAA2B;AAAA,IAC3B,MAAM,GAAG,cAAc;AAAA,IACvB,MAAM;AAAA,IACN,SAAS,aAAa,WAAW;AAAA,IACjC,WAAW,aAAa,aAAa;AAAA,EACtC,CAAC,IACA;AAGH,QAAM,aAAa,YAAY,QAA6C;AAG5E,QAAM,QAAyB;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACD;AAGA,QAAM,UAAU,WAAW,KAAK;AAGhC,QAAM,MAAM,MAAM,OAAO,SAAyC;AAAA,IACjE;AAAA,IACA;AAAA,IACA,OAAO;AAAA,EACR,CAAC;AAGD,MAAI,UAAU;AACb,WAAO,OAAO,KAAK,GAAG;AAAA,EACvB;AAGA,QAAM,QAAQ,IAAI,MAAM,KAAK;AAAA,IAC5B,OAAO;AAAA,MACN,MAAM;AAAA,MACN,OAAO;AAAA,IACR;AAAA,EACD,CAAC;AACD,QAAM,UAAU,MAAM,OAAO;AAE7B,SAAO,OAAO,KAAK,QAAQ,MAAM,CAAC;AACnC;AAKA,eAAsB,uBAAuB,SAA6C;AACzF,QAAM,MAAM,MAAM,gBAAgB,OAAO;AACzC,SAAO,yBAAyB,IAAI,SAAS,QAAQ,CAAC;AACvD;AAKA,eAAsB,mBAAmB,SAA4B,cAAc,MAAyB;AAC3G,QAAM,MAAM,MAAM,gBAAgB,OAAO;AAEzC,SAAO,IAAI,SAAS,KAAK;AAAA,IACxB,SAAS;AAAA,MACR,gBAAgB;AAAA,MAChB,iBAAiB,mBAAmB,WAAW;AAAA,IAChD;AAAA,EACD,CAAC;AACF;;;AK7GO,SAAS,iBAAiB,SAA4B;AAC5D,QAAM;AAAA,IACL;AAAA,IACA,iBAAiB,WAAW;AAAA,IAC5B;AAAA,IACA;AAAA,IACA;AAAA,IACA,cAAc;AAAA,IACd;AAAA,IACA;AAAA,EACD,IAAI;AAEJ,SAAO,OAAO,EAAE,IAAI,MAAoB;AACvC,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,UAAM,cAAc,IAAI,aAAa,IAAI,aAAa,KAAK;AAC3D,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO,KAAK;AAC/C,UAAM,YAAY,IAAI,aAAa,IAAI,MAAM,KAAK;AAElD,QAAI,CAAC,OAAO;AACX,aAAO,IAAI,SAAS,2BAA2B,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC/D;AAEA,QAAI;AACH,aAAO,MAAM;AAAA,QACZ;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QACD;AAAA,QACA;AAAA,MACD;AAAA,IACD,SAAS,OAAO;AACf,cAAQ,MAAM,gCAAgC,KAAK;AACnD,aAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AAAA,IAChE;AAAA,EACD;AACD;;;ACnEA,SAAS,SAAAA,cAAa;AAYf,SAAS,SAAS,KAAa,UAA2B,CAAC,GAAW;AAC5E,QAAM,OAAO;AAAA,IACZ,OAAO,QAAQ,WACZ,EAAE,MAAM,SAAkB,OAAO,QAAQ,SAAS,IAClD;AAAA,IACH,YAAY,QAAQ;AAAA,EACrB;AAEA,QAAM,QAAQ,IAAIA,OAAM,KAAK,IAAI;AACjC,QAAM,WAAW,MAAM,OAAO;AAE9B,SAAO,OAAO,KAAK,SAAS,MAAM,CAAC;AACpC;AAKO,SAAS,gBAAgB,KAAa,UAA2B,CAAC,GAAW;AACnF,QAAM,MAAM,SAAS,KAAK,OAAO;AACjC,SAAO,yBAAyB,IAAI,SAAS,QAAQ,CAAC;AACvD;AAKO,SAAS,iBAAiB,KAAa,UAA2B,CAAC,GAAG,cAAc,MAAgB;AAC1G,QAAM,MAAM,SAAS,KAAK,OAAO;AAEjC,SAAO,IAAI,SAAS,KAAK;AAAA,IACxB,SAAS;AAAA,MACR,gBAAgB;AAAA,MAChB,iBAAiB,mBAAmB,WAAW;AAAA,IAChD;AAAA,EACD,CAAC;AACF;","names":["Resvg"]}
1
+ {"version":3,"sources":["../src/generate.ts","../src/fonts.ts","../src/noise.ts","../src/png-encoder.ts","../src/types.ts","../src/endpoint.ts","../src/svg.ts"],"sourcesContent":["/**\n * Core OG image generation.\n * Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.\n */\n\nimport satori from 'satori'\nimport { Resvg } from '@resvg/resvg-js'\nimport { loadFonts, createSatoriFonts } from './fonts.js'\nimport { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'\nimport { getTemplate } from './templates/index.js'\nimport { defaultColors } from './types.js'\nimport type {\n\tOgGenerateOptions,\n\tOgColorConfig,\n\tOgTemplateProps,\n} from './types.js'\n\n// Standard OG image dimensions\nexport const OG_WIDTH = 1200\nexport const OG_HEIGHT = 630\n\n/**\n * Generate an OG image as PNG Buffer.\n */\nexport async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {\n\tconst {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\ttemplate = 'blog',\n\t\tcolors: colorOverrides,\n\t\tfonts: fontConfig,\n\t\tnoise: noiseConfig,\n\t\tnoiseSeed,\n\t\twidth = OG_WIDTH,\n\t\theight = OG_HEIGHT,\n\t\tdebugSvg = false,\n\t} = options\n\n\t// Merge colours\n\tconst colors: OgColorConfig = {\n\t\t...defaultColors,\n\t\t...colorOverrides,\n\t}\n\n\t// Load fonts\n\tconst fonts = await loadFonts(fontConfig)\n\tconst satoriFonts = createSatoriFonts(fonts)\n\n\t// Generate noise background\n\tconst noiseEnabled = noiseConfig?.enabled !== false\n\tconst noiseSeedValue = noiseSeed || noiseConfig?.seed || title\n\tconst noiseDataUrl = noiseEnabled\n\t\t? generateNoiseDataUrl({\n\t\t\t\tseed: noiseSeedValue,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.4,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Generate circular noise decoration\n\tconst circleNoiseDataUrl = noiseEnabled\n\t\t? generateCircleNoiseDataUrl({\n\t\t\t\tseed: `${noiseSeedValue}-circle`,\n\t\t\t\tsize: 200,\n\t\t\t\topacity: noiseConfig?.opacity ?? 0.15,\n\t\t\t\tcolorMode: noiseConfig?.colorMode ?? 'grayscale',\n\t\t\t})\n\t\t: undefined\n\n\t// Get template function\n\tconst templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])\n\n\t// Build template props\n\tconst props: OgTemplateProps = {\n\t\ttitle,\n\t\tdescription,\n\t\tsiteName,\n\t\timage,\n\t\tcolors,\n\t\tnoiseDataUrl,\n\t\tcircleNoiseDataUrl,\n\t\twidth,\n\t\theight,\n\t}\n\n\t// Render template to Satori-compatible structure\n\tconst element = templateFn(props)\n\n\t// Generate SVG with satori\n\tconst svg = await satori(element as Parameters<typeof satori>[0], {\n\t\twidth,\n\t\theight,\n\t\tfonts: satoriFonts,\n\t})\n\n\t// Debug: return SVG string\n\tif (debugSvg) {\n\t\treturn Buffer.from(svg)\n\t}\n\n\t// Convert SVG to PNG with resvg-js\n\tconst resvg = new Resvg(svg, {\n\t\tfitTo: {\n\t\t\tmode: 'width',\n\t\t\tvalue: width,\n\t\t},\n\t})\n\tconst pngData = resvg.render()\n\n\treturn Buffer.from(pngData.asPng())\n}\n\n/**\n * Generate OG image and return as base64 data URL.\n */\nexport async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {\n\tconst png = await generateOgImage(options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Generate OG image and return as Response (for SvelteKit endpoints).\n */\nexport async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {\n\tconst png = await generateOgImage(options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n","/**\n * @ewanc26/og fonts\n *\n * Font loading utilities. Bundles Inter font by default.\n */\n\nimport { readFile } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { OgFontConfig } from './types.js'\n\n// Declare __dirname for CJS contexts (injected by bundlers)\ndeclare const __dirname: string | undefined\n\n// ─── Paths ────────────────────────────────────────────────────────────────────\n\n/**\n * Get the directory of the current module.\n * Works in both ESM and bundled contexts.\n */\nfunction getModuleDir(): string {\n\t// ESM context\n\tif (typeof import.meta !== 'undefined' && import.meta.url) {\n\t\treturn dirname(fileURLToPath(import.meta.url))\n\t}\n\t// Bundled CJS context - __dirname is injected by bundlers\n\tif (typeof __dirname !== 'undefined') {\n\t\treturn __dirname\n\t}\n\t// Fallback\n\treturn resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')\n}\n\n/**\n * Resolve the fonts directory relative to the installed package.\n * Tries multiple possible locations for serverless compatibility.\n */\nfunction getFontsDir(): string {\n\tconst candidates = [\n\t\t// Standard: fonts next to dist\n\t\tresolve(getModuleDir(), '../fonts'),\n\t\t// Vercel serverless: fonts inside dist\n\t\tresolve(getModuleDir(), 'fonts'),\n\t\t// Fallback: node_modules path\n\t\tresolve(process.cwd(), 'node_modules/@ewanc26/og/fonts'),\n\t]\n\n\tfor (const dir of candidates) {\n\t\tif (existsSync(dir)) {\n\t\t\treturn dir\n\t\t}\n\t}\n\n\t// Return first candidate as fallback (will fail gracefully)\n\treturn candidates[0]\n}\n\n/**\n * Resolve bundled font paths. Uses getters to defer resolution until runtime.\n */\nexport const BUNDLED_FONTS = {\n\tget heading() {\n\t\treturn resolve(getFontsDir(), 'Inter-Bold.ttf')\n\t},\n\tget body() {\n\t\treturn resolve(getFontsDir(), 'Inter-Regular.ttf')\n\t},\n} as const\n\n// Google Fonts CDN fallback URLs\nconst FONT_FALLBACKS = {\n\theading: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Bold.ttf',\n\tbody: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Regular.ttf',\n}\n\n// ─── Font Loading ──────────────────────────────────────────────────────────────\n\nexport interface LoadedFonts {\n\theading: ArrayBuffer\n\tbody: ArrayBuffer\n}\n\n/**\n * Load fonts from config, falling back to bundled fonts,\n * with CDN fallback for serverless environments.\n */\nexport async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {\n\tconst headingPath = config?.heading ?? BUNDLED_FONTS.heading\n\tconst bodyPath = config?.body ?? BUNDLED_FONTS.body\n\n\tconst [heading, body] = await Promise.all([\n\t\tloadFontFileWithFallback(headingPath, FONT_FALLBACKS.heading),\n\t\tloadFontFileWithFallback(bodyPath, FONT_FALLBACKS.body),\n\t])\n\n\treturn { heading, body }\n}\n\n/**\n * Load a font file, falling back to CDN if local file fails.\n */\nasync function loadFontFileWithFallback(path: string, fallbackUrl: string): Promise<ArrayBuffer> {\n\ttry {\n\t\treturn await loadFontFile(path)\n\t} catch (error) {\n\t\tconsole.warn(`Failed to load local font at ${path}, trying CDN fallback:`, error)\n\t\ttry {\n\t\t\treturn await loadFontFile(fallbackUrl)\n\t\t} catch (fallbackError) {\n\t\t\tthrow new Error(`Failed to load font from both local path (${path}) and CDN (${fallbackUrl}): ${fallbackError}`)\n\t\t}\n\t}\n}\n\n/**\n * Load a font from file path or URL.\n */\nasync function loadFontFile(source: string): Promise<ArrayBuffer> {\n\t// Handle URLs\n\tif (source.startsWith('http://') || source.startsWith('https://')) {\n\t\tconst response = await fetch(source)\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to load font from URL: ${source}`)\n\t\t}\n\t\treturn response.arrayBuffer()\n\t}\n\n\t// Handle file paths\n\tconst buffer = await readFile(source)\n\treturn buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)\n}\n\n// ─── Font Registration for Satori ─────────────────────────────────────────────\n\nexport type SatoriFontConfig = {\n\tname: string\n\tdata: ArrayBuffer\n\tweight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900\n\tstyle: 'normal' | 'italic'\n}\n\n/**\n * Create Satori-compatible font config from loaded fonts.\n * Uses Inter font family with weight 700 for headings and 400 for body.\n */\nexport function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {\n\treturn [\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.heading,\n\t\t\tweight: 700,\n\t\t\tstyle: 'normal',\n\t\t},\n\t\t{\n\t\t\tname: 'Inter',\n\t\t\tdata: fonts.body,\n\t\t\tweight: 400,\n\t\t\tstyle: 'normal',\n\t\t},\n\t]\n}\n","/**\n * @ewanc26/og noise\n */\n\nimport { generateNoisePixels } from '@ewanc26/noise'\nimport { PNGEncoder } from './png-encoder.js'\nimport type { OgNoiseConfig } from './types.js'\n\nexport interface NoiseOptions {\n\tseed: string\n\twidth: number\n\theight: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\nexport interface CircleNoiseOptions {\n\tseed: string\n\tsize: number\n\topacity?: number\n\tcolorMode?: OgNoiseConfig['colorMode']\n}\n\n/**\n * Generate a noise PNG as a data URL.\n */\nexport function generateNoiseDataUrl(options: NoiseOptions): string {\n\tconst { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(width, height, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [20, 60] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tif (opacity < 1) {\n\t\tfor (let i = 3; i < pixels.length; i += 4) {\n\t\t\tpixels[i] = Math.round(pixels[i] * opacity)\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, width, height)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n\n/**\n * Generate a circular noise PNG as a data URL.\n * Creates a square image with circular transparency mask.\n */\nexport function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {\n\tconst { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options\n\n\tconst pixels = generateNoisePixels(size, size, seed, {\n\t\tgridSize: 4,\n\t\toctaves: 3,\n\t\tcolorMode: colorMode === 'grayscale'\n\t\t\t? { type: 'grayscale', range: [30, 70] }\n\t\t\t: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }\n\t})\n\n\tconst center = size / 2\n\tconst radius = size / 2\n\n\t// Apply circular mask\n\tfor (let y = 0; y < size; y++) {\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst idx = (y * size + x) * 4\n\t\t\tconst dx = x - center + 0.5\n\t\t\tconst dy = y - center + 0.5\n\t\t\tconst dist = Math.sqrt(dx * dx + dy * dy)\n\n\t\t\tif (dist > radius) {\n\t\t\t\t// Outside circle - fully transparent\n\t\t\t\tpixels[idx + 3] = 0\n\t\t\t} else if (dist > radius - 2) {\n\t\t\t\t// Anti-alias edge\n\t\t\t\tconst edgeOpacity = (radius - dist) / 2\n\t\t\t\tpixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)\n\t\t\t} else {\n\t\t\t\t// Inside circle - apply opacity\n\t\t\t\tpixels[idx + 3] = Math.round(255 * opacity)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst pngBuffer = PNGEncoder.encode(pixels, size, size)\n\treturn `data:image/png;base64,${pngBuffer.toString('base64')}`\n}\n","/**\n * Minimal PNG encoder for noise backgrounds.\n * Uses node:zlib for deflate compression.\n */\n\nimport { deflateSync } from 'node:zlib'\n\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])\n\nfunction crc32(data: Buffer): number {\n\tlet crc = 0xffffffff\n\tconst table: number[] = []\n\n\t// Build CRC table\n\tfor (let n = 0; n < 256; n++) {\n\t\tlet c = n\n\t\tfor (let k = 0; k < 8; k++) {\n\t\t\tc = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n\t\t}\n\t\ttable[n] = c\n\t}\n\n\t// Calculate CRC\n\tfor (let i = 0; i < data.length; i++) {\n\t\tcrc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)\n\t}\n\n\treturn (crc ^ 0xffffffff) >>> 0\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n\tconst length = Buffer.alloc(4)\n\tlength.writeUInt32BE(data.length, 0)\n\n\tconst typeBuffer = Buffer.from(type, 'ascii')\n\tconst crcData = Buffer.concat([typeBuffer, data])\n\tconst crc = Buffer.alloc(4)\n\tcrc.writeUInt32BE(crc32(crcData), 0)\n\n\treturn Buffer.concat([length, typeBuffer, data, crc])\n}\n\nfunction createIHDR(width: number, height: number): Buffer {\n\tconst data = Buffer.alloc(13)\n\tdata.writeUInt32BE(width, 0) // Width\n\tdata.writeUInt32BE(height, 4) // Height\n\tdata.writeUInt8(8, 8) // Bit depth: 8 bits\n\tdata.writeUInt8(2, 9) // Colour type: 2 (RGB)\n\tdata.writeUInt8(0, 10) // Compression method\n\tdata.writeUInt8(0, 11) // Filter method\n\tdata.writeUInt8(0, 12) // Interlace method\n\n\treturn createChunk('IHDR', data)\n}\n\nfunction createIDAT(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\t// Apply filter (none filter = 0) per row\n\tconst rawData = Buffer.alloc(height * (width * 3 + 1))\n\n\tlet srcOffset = 0\n\tlet dstOffset = 0\n\n\tfor (let y = 0; y < height; y++) {\n\t\trawData[dstOffset++] = 0 // Filter type: none\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst r = pixels[srcOffset++]\n\t\t\tconst g = pixels[srcOffset++]\n\t\t\tconst b = pixels[srcOffset++]\n\t\t\tsrcOffset++ // Skip alpha\n\t\t\trawData[dstOffset++] = r\n\t\t\trawData[dstOffset++] = g\n\t\t\trawData[dstOffset++] = b\n\t\t}\n\t}\n\n\tconst compressed = deflateSync(rawData)\n\treturn createChunk('IDAT', compressed)\n}\n\nfunction createIEND(): Buffer {\n\treturn createChunk('IEND', Buffer.alloc(0))\n}\n\n/**\n * Encode raw RGBA pixel data as a PNG Buffer.\n */\nexport function encodePNG(pixels: Uint8ClampedArray, width: number, height: number): Buffer {\n\treturn Buffer.concat([\n\t\tPNG_SIGNATURE,\n\t\tcreateIHDR(width, height),\n\t\tcreateIDAT(pixels, width, height),\n\t\tcreateIEND(),\n\t])\n}\n\n/**\n * PNGEncoder namespace for cleaner imports.\n */\nexport const PNGEncoder = {\n\tencode: encodePNG,\n}\n","/**\n * @ewanc26/og types\n */\n\n// ─── Colour Configuration ─────────────────────────────────────────────────────\n\nexport interface OgColorConfig {\n\t/** Background color (very dark). @default '#0f1a15' */\n\tbackground: string\n\t/** Primary text color. @default '#e8f5e9' */\n\ttext: string\n\t/** Secondary/accent text (mint). @default '#86efac' */\n\taccent: string\n}\n\nexport const defaultColors: OgColorConfig = {\n\tbackground: '#0f1a15',\n\ttext: '#e8f5e9',\n\taccent: '#86efac',\n}\n\n// ─── Font Configuration ───────────────────────────────────────────────────────\n\nexport interface OgFontConfig {\n\theading?: string\n\tbody?: string\n}\n\n// ─── Noise Configuration ──────────────────────────────────────────────────────\n\nexport interface OgNoiseConfig {\n\tenabled?: boolean\n\tseed?: string\n\topacity?: number\n\tcolorMode?: 'grayscale' | 'hsl'\n}\n\n// ─── Template Props ────────────────────────────────────────────────────────────\n\nexport interface OgTemplateProps {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n\tcircleNoiseDataUrl?: string\n\twidth: number\n\theight: number\n}\n\nexport type OgTemplate = (props: OgTemplateProps) => unknown\n\n// ─── Generation Options ───────────────────────────────────────────────────────\n\nexport interface OgGenerateOptions {\n\ttitle: string\n\tdescription?: string\n\tsiteName: string\n\timage?: string\n\ttemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tnoiseSeed?: string\n\twidth?: number\n\theight?: number\n\tdebugSvg?: boolean\n}\n\n// ─── SvelteKit Endpoint Options ───────────────────────────────────────────────\n\nexport interface OgEndpointOptions {\n\tsiteName: string\n\tdefaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate\n\tcolors?: Partial<OgColorConfig>\n\tfonts?: OgFontConfig\n\tnoise?: OgNoiseConfig\n\tcacheMaxAge?: number\n\twidth?: number\n\theight?: number\n}\n\n// ─── Internal Types ────────────────────────────────────────────────────────────\n\nexport interface InternalGenerateContext {\n\twidth: number\n\theight: number\n\tfonts: { heading: ArrayBuffer; body: ArrayBuffer }\n\tcolors: OgColorConfig\n\tnoiseDataUrl?: string\n}\n","/**\n * SvelteKit endpoint helpers.\n */\n\nimport { generateOgResponse } from './generate.js'\nimport type { OgEndpointOptions, OgTemplate } from './types.js'\n\n/**\n * Create a SvelteKit GET handler for OG image generation.\n *\n * @example\n * ```ts\n * // src/routes/og/[title]/+server.ts\n * import { createOgEndpoint } from '@ewanc26/og';\n *\n * export const GET = createOgEndpoint({\n * siteName: 'ewancroft.uk',\n * defaultTemplate: 'blog',\n * });\n * ```\n *\n * The endpoint expects query parameters:\n * - `title` (required): Page title\n * - `description`: Optional description\n * - `image`: Optional avatar/logo URL\n * - `seed`: Optional noise seed\n */\nexport function createOgEndpoint(options: OgEndpointOptions) {\n\tconst {\n\t\tsiteName,\n\t\tdefaultTemplate: template = 'default',\n\t\tcolors,\n\t\tfonts,\n\t\tnoise,\n\t\tcacheMaxAge = 3600,\n\t\twidth,\n\t\theight,\n\t} = options\n\n\treturn async ({ url }: { url: URL }) => {\n\t\tconst title = url.searchParams.get('title')\n\t\tconst description = url.searchParams.get('description') ?? undefined\n\t\tconst image = url.searchParams.get('image') ?? undefined\n\t\tconst noiseSeed = url.searchParams.get('seed') ?? undefined\n\n\t\tif (!title) {\n\t\t\treturn new Response('Missing title parameter', { status: 400 })\n\t\t}\n\n\t\ttry {\n\t\t\treturn await generateOgResponse(\n\t\t\t\t{\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tsiteName,\n\t\t\t\t\timage,\n\t\t\t\t\ttemplate: template as OgTemplate,\n\t\t\t\t\tcolors,\n\t\t\t\t\tfonts,\n\t\t\t\t\tnoise,\n\t\t\t\t\tnoiseSeed,\n\t\t\t\t\twidth,\n\t\t\t\t\theight,\n\t\t\t\t},\n\t\t\t\tcacheMaxAge\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to generate OG image:', error)\n\t\t\treturn new Response('Failed to generate image', { status: 500 })\n\t\t}\n\t}\n}\n\n/**\n * Create a typed OG image URL for use in meta tags.\n */\nexport function createOgImageUrl(\n\tbaseUrl: string,\n\tparams: {\n\t\ttitle: string\n\t\tdescription?: string\n\t\timage?: string\n\t\tseed?: string\n\t}\n): string {\n\tconst url = new URL(baseUrl)\n\turl.searchParams.set('title', params.title)\n\tif (params.description) url.searchParams.set('description', params.description)\n\tif (params.image) url.searchParams.set('image', params.image)\n\tif (params.seed) url.searchParams.set('seed', params.seed)\n\treturn url.toString()\n}\n","/**\n * SVG to PNG conversion using @resvg/resvg-js.\n */\n\nimport { Resvg } from '@resvg/resvg-js'\n\nexport interface SvgToPngOptions {\n\t/** Scale to fit width in pixels */\n\tfitWidth?: number\n\t/** Background colour for transparent areas */\n\tbackgroundColor?: string\n}\n\n/**\n * Convert an SVG string to PNG Buffer.\n */\nexport function svgToPng(svg: string, options: SvgToPngOptions = {}): Buffer {\n\tconst opts = {\n\t\tfitTo: options.fitWidth\n\t\t\t? { mode: 'width' as const, value: options.fitWidth }\n\t\t\t: undefined,\n\t\tbackground: options.backgroundColor,\n\t}\n\n\tconst resvg = new Resvg(svg, opts)\n\tconst rendered = resvg.render()\n\n\treturn Buffer.from(rendered.asPng())\n}\n\n/**\n * Convert an SVG string to PNG data URL.\n */\nexport function svgToPngDataUrl(svg: string, options: SvgToPngOptions = {}): string {\n\tconst png = svgToPng(svg, options)\n\treturn `data:image/png;base64,${png.toString('base64')}`\n}\n\n/**\n * Convert an SVG string to PNG Response (for SvelteKit endpoints).\n */\nexport function svgToPngResponse(svg: string, options: SvgToPngOptions = {}, cacheMaxAge = 3600): Response {\n\tconst png = svgToPng(svg, options)\n\n\treturn new Response(png, {\n\t\theaders: {\n\t\t\t'Content-Type': 'image/png',\n\t\t\t'Cache-Control': `public, max-age=${cacheMaxAge}`,\n\t\t},\n\t})\n}\n"],"mappings":"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","names":["Resvg"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ewanc26/og",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Dynamic OpenGraph image generator with noise backgrounds, bold typography, and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes, and build scripts.",
5
5
  "author": "Ewan Croft",
6
6
  "license": "AGPL-3.0-only",
@@ -51,7 +51,7 @@
51
51
  "check": "tsc --noEmit"
52
52
  },
53
53
  "dependencies": {
54
- "@ewanc26/noise": "workspace:*",
54
+ "@ewanc26/noise": "^0.1.3",
55
55
  "@resvg/resvg-js": "^2.6.0",
56
56
  "satori": "^0.15.2"
57
57
  },
package/src/fonts.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { readFile } from 'node:fs/promises'
8
+ import { existsSync } from 'node:fs'
8
9
  import { dirname, resolve } from 'node:path'
9
10
  import { fileURLToPath } from 'node:url'
10
11
  import type { OgFontConfig } from './types.js'
@@ -33,9 +34,26 @@ function getModuleDir(): string {
33
34
 
34
35
  /**
35
36
  * Resolve the fonts directory relative to the installed package.
37
+ * Tries multiple possible locations for serverless compatibility.
36
38
  */
37
39
  function getFontsDir(): string {
38
- return resolve(getModuleDir(), '../fonts')
40
+ const candidates = [
41
+ // Standard: fonts next to dist
42
+ resolve(getModuleDir(), '../fonts'),
43
+ // Vercel serverless: fonts inside dist
44
+ resolve(getModuleDir(), 'fonts'),
45
+ // Fallback: node_modules path
46
+ resolve(process.cwd(), 'node_modules/@ewanc26/og/fonts'),
47
+ ]
48
+
49
+ for (const dir of candidates) {
50
+ if (existsSync(dir)) {
51
+ return dir
52
+ }
53
+ }
54
+
55
+ // Return first candidate as fallback (will fail gracefully)
56
+ return candidates[0]
39
57
  }
40
58
 
41
59
  /**
@@ -50,6 +68,12 @@ export const BUNDLED_FONTS = {
50
68
  },
51
69
  } as const
52
70
 
71
+ // Google Fonts CDN fallback URLs
72
+ const FONT_FALLBACKS = {
73
+ heading: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Bold.ttf',
74
+ body: 'https://github.com/rsms/inter/raw/refs/heads/main/docs/font-files/Inter-Regular.ttf',
75
+ }
76
+
53
77
  // ─── Font Loading ──────────────────────────────────────────────────────────────
54
78
 
55
79
  export interface LoadedFonts {
@@ -58,20 +82,37 @@ export interface LoadedFonts {
58
82
  }
59
83
 
60
84
  /**
61
- * Load fonts from config, falling back to bundled DM Sans.
85
+ * Load fonts from config, falling back to bundled fonts,
86
+ * with CDN fallback for serverless environments.
62
87
  */
63
88
  export async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {
64
89
  const headingPath = config?.heading ?? BUNDLED_FONTS.heading
65
90
  const bodyPath = config?.body ?? BUNDLED_FONTS.body
66
91
 
67
92
  const [heading, body] = await Promise.all([
68
- loadFontFile(headingPath),
69
- loadFontFile(bodyPath),
93
+ loadFontFileWithFallback(headingPath, FONT_FALLBACKS.heading),
94
+ loadFontFileWithFallback(bodyPath, FONT_FALLBACKS.body),
70
95
  ])
71
96
 
72
97
  return { heading, body }
73
98
  }
74
99
 
100
+ /**
101
+ * Load a font file, falling back to CDN if local file fails.
102
+ */
103
+ async function loadFontFileWithFallback(path: string, fallbackUrl: string): Promise<ArrayBuffer> {
104
+ try {
105
+ return await loadFontFile(path)
106
+ } catch (error) {
107
+ console.warn(`Failed to load local font at ${path}, trying CDN fallback:`, error)
108
+ try {
109
+ return await loadFontFile(fallbackUrl)
110
+ } catch (fallbackError) {
111
+ throw new Error(`Failed to load font from both local path (${path}) and CDN (${fallbackUrl}): ${fallbackError}`)
112
+ }
113
+ }
114
+ }
115
+
75
116
  /**
76
117
  * Load a font from file path or URL.
77
118
  */