diff --git a/packages/og/.gitignore b/packages/og/.gitignore new file mode 100644 index 0000000..9c1150c --- /dev/null +++ b/packages/og/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +src/fonts-base64.ts diff --git a/packages/og/package.json b/packages/og/package.json index 0e8a320..b5ebfe4 100644 --- a/packages/og/package.json +++ b/packages/og/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/og", - "version": "0.1.5", + "version": "0.1.6", "description": "Dynamic OpenGraph image generator with noise backgrounds, bold typography, and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes, and build scripts.", "author": "Ewan Croft", "license": "AGPL-3.0-only", @@ -46,8 +46,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { + "prebuild": "node scripts/prebuild.mjs", "build": "tsup", - "dev": "tsup --watch", + "dev": "node scripts/prebuild.mjs && tsup --watch", "check": "tsc --noEmit" }, "dependencies": { diff --git a/packages/og/scripts/prebuild.mjs b/packages/og/scripts/prebuild.mjs new file mode 100644 index 0000000..c4a71ce --- /dev/null +++ b/packages/og/scripts/prebuild.mjs @@ -0,0 +1,27 @@ +/** + * Prebuild script: inlines Inter font files as base64 into src/fonts-base64.ts + * so the distributed package works in serverless environments (Vercel etc.) + * without relying on filesystem access to node_modules. + */ + +import { readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') + +const bold = await readFile(resolve(root, 'fonts/Inter-Bold.ttf')) +const regular = await readFile(resolve(root, 'fonts/Inter-Regular.ttf')) + +const output = `/** + * Auto-generated by scripts/prebuild.mjs — do not edit. + * Inter font files inlined as base64 for serverless compatibility. + */ + +export const INTER_BOLD_B64 = '${bold.toString('base64')}' +export const INTER_REGULAR_B64 = '${regular.toString('base64')}' +` + +await writeFile(resolve(root, 'src/fonts-base64.ts'), output) +console.log('fonts-base64.ts written') diff --git a/packages/og/src/fonts-data.ts b/packages/og/src/fonts-data.ts deleted file mode 100644 index 9d9c477..0000000 --- a/packages/og/src/fonts-data.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Font loading helper for serverless environments - */ - -import { readFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -// Declare __dirname for CJS contexts (injected by bundlers) -declare const __dirname: string | undefined - -function getModuleDir(): string { - if (typeof import.meta !== 'undefined' && import.meta.url) { - return dirname(fileURLToPath(import.meta.url)) - } - if (typeof __dirname !== 'undefined') { - return __dirname - } - return resolve(process.cwd(), 'node_modules/@ewanc26/og/dist') -} - -export interface FontData { - heading: ArrayBuffer - body: ArrayBuffer -} - -/** - * Try loading fonts from dist/fonts directory - */ -export async function loadEmbeddedFonts(): Promise { - const moduleDir = getModuleDir() - - const paths = [ - { - heading: resolve(moduleDir, 'fonts/Inter-Bold.ttf'), - body: resolve(moduleDir, 'fonts/Inter-Regular.ttf'), - }, - { - heading: resolve(moduleDir, '../fonts/Inter-Bold.ttf'), - body: resolve(moduleDir, '../fonts/Inter-Regular.ttf'), - }, - ] - - for (const p of paths) { - try { - const [headingBuf, bodyBuf] = await Promise.all([ - readFile(p.heading), - readFile(p.body), - ]) - // Convert Buffer to ArrayBuffer - return { - heading: headingBuf.buffer.slice(headingBuf.byteOffset, headingBuf.byteOffset + headingBuf.byteLength), - body: bodyBuf.buffer.slice(bodyBuf.byteOffset, bodyBuf.byteOffset + bodyBuf.byteLength), - } - } catch { - continue - } - } - - return null -} diff --git a/packages/og/src/fonts.ts b/packages/og/src/fonts.ts index d517dfc..e2754aa 100644 --- a/packages/og/src/fonts.ts +++ b/packages/og/src/fonts.ts @@ -1,73 +1,13 @@ /** * @ewanc26/og fonts * - * Font loading utilities. Bundles Inter font by default. + * Font loading utilities. Fonts are inlined as base64 at build time so this + * works in serverless environments (Vercel, Netlify, etc.) without any + * filesystem access to node_modules. */ -import { readFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { INTER_BOLD_B64, INTER_REGULAR_B64 } from './fonts-base64.js' import type { OgFontConfig } from './types.js' -import { loadEmbeddedFonts } from './fonts-data.js' - -// Declare __dirname for CJS contexts (injected by bundlers) -declare const __dirname: string | undefined - -// ─── Paths ──────────────────────────────────────────────────────────────────── - -/** - * Get the directory of the current module. - * Works in both ESM and bundled contexts. - */ -function getModuleDir(): string { - // ESM context - if (typeof import.meta !== 'undefined' && import.meta.url) { - return dirname(fileURLToPath(import.meta.url)) - } - // Bundled CJS context - __dirname is injected by bundlers - if (typeof __dirname !== 'undefined') { - return __dirname - } - // Fallback - return resolve(process.cwd(), 'node_modules/@ewanc26/og/dist') -} - -/** - * Resolve the fonts directory relative to the installed package. - * Tries multiple possible locations for serverless compatibility. - */ -function getFontsDir(): string { - const candidates = [ - // Standard: fonts next to dist - resolve(getModuleDir(), '../fonts'), - // Vercel serverless: fonts inside dist - resolve(getModuleDir(), 'fonts'), - // Fallback: node_modules path - resolve(process.cwd(), 'node_modules/@ewanc26/og/fonts'), - ] - - for (const dir of candidates) { - if (existsSync(dir)) { - return dir - } - } - - // Return first candidate as fallback (will fail gracefully) - return candidates[0] -} - -/** - * Resolve bundled font paths. Uses getters to defer resolution until runtime. - */ -export const BUNDLED_FONTS = { - get heading() { - return resolve(getFontsDir(), 'Inter-Bold.ttf') - }, - get body() { - return resolve(getFontsDir(), 'Inter-Regular.ttf') - }, -} as const // ─── Font Loading ────────────────────────────────────────────────────────────── @@ -76,43 +16,36 @@ export interface LoadedFonts { body: ArrayBuffer } -/** - * Helper to convert Buffer to ArrayBuffer - */ -function toArrayBuffer(buf: Buffer): ArrayBuffer { +function base64ToArrayBuffer(b64: string): ArrayBuffer { + const buf = Buffer.from(b64, 'base64') return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer } /** - * Load fonts from config, falling back to bundled fonts. + * Load fonts from config, falling back to the bundled Inter font. + * If custom paths are provided they are loaded from disk; otherwise the + * pre-inlined base64 data is used (safe in any serverless runtime). */ export async function loadFonts(config?: OgFontConfig): Promise { - const headingPath = config?.heading ?? BUNDLED_FONTS.heading - const bodyPath = config?.body ?? BUNDLED_FONTS.body - - const [heading, body] = await Promise.all([ - loadFontFile(headingPath), - loadFontFile(bodyPath), - ]) - - return { heading, body } -} - -/** - * Load a font from file path. - * Falls back to alternative locations if local file not found. - */ -async function loadFontFile(source: string): Promise { - try { - const buffer = await readFile(source) - return toArrayBuffer(buffer) - } catch (error) { - // Try embedded fonts (loaded from alternative locations) - const embedded = await loadEmbeddedFonts() - if (embedded) { - return source.includes('Bold') ? embedded.heading : embedded.body + if (config?.heading || config?.body) { + const { readFile } = await import('node:fs/promises') + const [headingBuf, bodyBuf] = await Promise.all([ + config.heading ? readFile(config.heading) : Buffer.from(INTER_BOLD_B64, 'base64'), + config.body ? readFile(config.body) : Buffer.from(INTER_REGULAR_B64, 'base64'), + ]) + return { + heading: headingBuf instanceof Buffer + ? (headingBuf.buffer.slice(headingBuf.byteOffset, headingBuf.byteOffset + headingBuf.byteLength) as ArrayBuffer) + : headingBuf.buffer, + body: bodyBuf instanceof Buffer + ? (bodyBuf.buffer.slice(bodyBuf.byteOffset, bodyBuf.byteOffset + bodyBuf.byteLength) as ArrayBuffer) + : bodyBuf.buffer, } - throw new Error(`Failed to load font from ${source}`) + } + + return { + heading: base64ToArrayBuffer(INTER_BOLD_B64), + body: base64ToArrayBuffer(INTER_REGULAR_B64), } } @@ -127,21 +60,10 @@ export type SatoriFontConfig = { /** * Create Satori-compatible font config from loaded fonts. - * Uses Inter font family with weight 700 for headings and 400 for body. */ export function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] { return [ - { - name: 'Inter', - data: fonts.heading, - weight: 700, - style: 'normal', - }, - { - name: 'Inter', - data: fonts.body, - weight: 400, - style: 'normal', - }, + { name: 'Inter', data: fonts.heading, weight: 700, style: 'normal' }, + { name: 'Inter', data: fonts.body, weight: 400, style: 'normal' }, ] } diff --git a/packages/og/src/index.ts b/packages/og/src/index.ts index e0d8520..cbf60c2 100644 --- a/packages/og/src/index.ts +++ b/packages/og/src/index.ts @@ -41,7 +41,7 @@ export { defaultColors } from './types.js' export { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js' // Fonts (for advanced customization) -export { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js' +export { loadFonts, createSatoriFonts } from './fonts.js' // Endpoint helpers export { createOgEndpoint } from './endpoint.js' diff --git a/packages/og/tsup.config.ts b/packages/og/tsup.config.ts index b3825b1..bb7bc5f 100644 --- a/packages/og/tsup.config.ts +++ b/packages/og/tsup.config.ts @@ -14,13 +14,4 @@ export default defineConfig({ options.jsxFactory = 'h' options.jsxFragment = 'Fragment' }, - onSuccess: async () => { - // Copy font files to dist after build - const { mkdir, cp } = await import('node:fs/promises') - const { resolve } = await import('node:path') - const fontsDir = resolve('fonts') - const distFontsDir = resolve('dist/fonts') - await mkdir(distFontsDir, { recursive: true }) - await cp(fontsDir, distFontsDir, { recursive: true }) - }, })