@ewanc26/og 0.1.4 → 0.1.6

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/src/fonts.ts CHANGED
@@ -1,73 +1,14 @@
1
1
  /**
2
2
  * @ewanc26/og fonts
3
3
  *
4
- * Font loading utilities. Bundles Inter font by default.
4
+ * Font loading utilities. Fonts are inlined as base64 at build time so this
5
+ * works in serverless environments (Vercel, Netlify, etc.) without any
6
+ * filesystem access to node_modules.
5
7
  */
6
8
 
7
- import { readFile } from 'node:fs/promises'
8
- import { existsSync } from 'node:fs'
9
- import { dirname, resolve } from 'node:path'
10
- import { fileURLToPath } from 'node:url'
9
+ import { INTER_BOLD_B64, INTER_REGULAR_B64 } from './fonts-base64.js'
11
10
  import type { OgFontConfig } from './types.js'
12
11
 
13
- // Declare __dirname for CJS contexts (injected by bundlers)
14
- declare const __dirname: string | undefined
15
-
16
- // ─── Paths ────────────────────────────────────────────────────────────────────
17
-
18
- /**
19
- * Get the directory of the current module.
20
- * Works in both ESM and bundled contexts.
21
- */
22
- function getModuleDir(): string {
23
- // ESM context
24
- if (typeof import.meta !== 'undefined' && import.meta.url) {
25
- return dirname(fileURLToPath(import.meta.url))
26
- }
27
- // Bundled CJS context - __dirname is injected by bundlers
28
- if (typeof __dirname !== 'undefined') {
29
- return __dirname
30
- }
31
- // Fallback
32
- return resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')
33
- }
34
-
35
- /**
36
- * Resolve the fonts directory relative to the installed package.
37
- * Tries multiple possible locations for serverless compatibility.
38
- */
39
- function getFontsDir(): string {
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]
57
- }
58
-
59
- /**
60
- * Resolve bundled font paths. Uses getters to defer resolution until runtime.
61
- */
62
- export const BUNDLED_FONTS = {
63
- get heading() {
64
- return resolve(getFontsDir(), 'Inter-Bold.ttf')
65
- },
66
- get body() {
67
- return resolve(getFontsDir(), 'Inter-Regular.ttf')
68
- },
69
- } as const
70
-
71
12
  // ─── Font Loading ──────────────────────────────────────────────────────────────
72
13
 
73
14
  export interface LoadedFonts {
@@ -75,45 +16,37 @@ export interface LoadedFonts {
75
16
  body: ArrayBuffer
76
17
  }
77
18
 
78
- /**
79
- * Load fonts from config, falling back to bundled fonts.
80
- * In serverless environments, falls back to fetching from upstream CDN.
81
- */
82
- export async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {
83
- const headingPath = config?.heading ?? BUNDLED_FONTS.heading
84
- const bodyPath = config?.body ?? BUNDLED_FONTS.body
85
-
86
- const [heading, body] = await Promise.all([
87
- loadFontFile(headingPath),
88
- loadFontFile(bodyPath),
89
- ])
90
-
91
- return { heading, body }
19
+ function base64ToArrayBuffer(b64: string): ArrayBuffer {
20
+ const buf = Buffer.from(b64, 'base64')
21
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
92
22
  }
93
23
 
94
24
  /**
95
- * Load a font from file path.
96
- * Falls back to fetching from github raw if local file not found.
25
+ * Load fonts from config, falling back to the bundled Inter font.
26
+ * If custom paths are provided they are loaded from disk; otherwise the
27
+ * pre-inlined base64 data is used (safe in any serverless runtime).
97
28
  */
98
- async function loadFontFile(source: string): Promise<ArrayBuffer> {
99
- try {
100
- const buffer = await readFile(source)
101
- return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
102
- } catch (error) {
103
- // In serverless, fonts might not be at expected path - fetch from CDN
104
- const filename = source.split('/').pop()
105
- const cdnUrl = `https://raw.githubusercontent.com/rsms/inter/master/docs/font-files/${filename}`
106
-
107
- try {
108
- const response = await fetch(cdnUrl)
109
- if (!response.ok) {
110
- throw new Error(`CDN fetch failed: ${response.status}`)
111
- }
112
- return response.arrayBuffer()
113
- } catch (cdnError) {
114
- throw new Error(`Failed to load font ${filename} from both local path and CDN: ${cdnError}`)
29
+ export async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {
30
+ if (config?.heading || config?.body) {
31
+ const { readFile } = await import('node:fs/promises')
32
+ const [headingBuf, bodyBuf] = await Promise.all([
33
+ config.heading ? readFile(config.heading) : Buffer.from(INTER_BOLD_B64, 'base64'),
34
+ config.body ? readFile(config.body) : Buffer.from(INTER_REGULAR_B64, 'base64'),
35
+ ])
36
+ return {
37
+ heading: headingBuf instanceof Buffer
38
+ ? (headingBuf.buffer.slice(headingBuf.byteOffset, headingBuf.byteOffset + headingBuf.byteLength) as ArrayBuffer)
39
+ : headingBuf.buffer,
40
+ body: bodyBuf instanceof Buffer
41
+ ? (bodyBuf.buffer.slice(bodyBuf.byteOffset, bodyBuf.byteOffset + bodyBuf.byteLength) as ArrayBuffer)
42
+ : bodyBuf.buffer,
115
43
  }
116
44
  }
45
+
46
+ return {
47
+ heading: base64ToArrayBuffer(INTER_BOLD_B64),
48
+ body: base64ToArrayBuffer(INTER_REGULAR_B64),
49
+ }
117
50
  }
118
51
 
119
52
  // ─── Font Registration for Satori ─────────────────────────────────────────────
@@ -127,21 +60,10 @@ export type SatoriFontConfig = {
127
60
 
128
61
  /**
129
62
  * Create Satori-compatible font config from loaded fonts.
130
- * Uses Inter font family with weight 700 for headings and 400 for body.
131
63
  */
132
64
  export function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {
133
65
  return [
134
- {
135
- name: 'Inter',
136
- data: fonts.heading,
137
- weight: 700,
138
- style: 'normal',
139
- },
140
- {
141
- name: 'Inter',
142
- data: fonts.body,
143
- weight: 400,
144
- style: 'normal',
145
- },
66
+ { name: 'Inter', data: fonts.heading, weight: 700, style: 'normal' },
67
+ { name: 'Inter', data: fonts.body, weight: 400, style: 'normal' },
146
68
  ]
147
69
  }
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ export { defaultColors } from './types.js'
41
41
  export { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'
42
42
 
43
43
  // Fonts (for advanced customization)
44
- export { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js'
44
+ export { loadFonts, createSatoriFonts } from './fonts.js'
45
45
 
46
46
  // Endpoint helpers
47
47
  export { createOgEndpoint } from './endpoint.js'
@@ -10,6 +10,7 @@ export function blogTemplate({
10
10
  description,
11
11
  siteName,
12
12
  colors,
13
+ noiseDataUrl,
13
14
  width,
14
15
  height,
15
16
  }: OgTemplateProps) {
@@ -17,6 +18,7 @@ export function blogTemplate({
17
18
  type: 'div',
18
19
  props: {
19
20
  style: {
21
+ position: 'relative',
20
22
  display: 'flex',
21
23
  flexDirection: 'column',
22
24
  alignItems: 'center',
@@ -26,51 +28,83 @@ export function blogTemplate({
26
28
  backgroundColor: colors.background,
27
29
  },
28
30
  children: [
29
- {
30
- type: 'h1',
31
- props: {
32
- style: {
33
- fontSize: 64,
34
- fontWeight: 700,
35
- color: colors.text,
36
- letterSpacing: '-0.02em',
37
- margin: 0,
38
- textAlign: 'center',
39
- lineHeight: 1.1,
40
- maxWidth: 1000,
41
- },
42
- children: title,
43
- },
44
- },
45
- description ? {
46
- type: 'p',
31
+ noiseDataUrl ? {
32
+ type: 'img',
47
33
  props: {
34
+ src: noiseDataUrl,
35
+ width,
36
+ height,
48
37
  style: {
49
- fontSize: 28,
50
- fontWeight: 400,
51
- color: colors.accent,
52
- marginTop: 28,
53
- marginBottom: 0,
54
- textAlign: 'center',
55
- lineHeight: 1.4,
56
- maxWidth: 900,
38
+ position: 'absolute',
39
+ top: 0,
40
+ left: 0,
41
+ width,
42
+ height,
57
43
  },
58
- children: description,
59
44
  },
60
45
  } : null,
61
46
  {
62
- type: 'p',
47
+ type: 'div',
63
48
  props: {
64
49
  style: {
65
- fontSize: 24,
66
- fontWeight: 400,
67
- color: colors.accent,
68
- marginTop: 56,
69
- marginBottom: 0,
70
- textAlign: 'center',
71
- opacity: 0.7,
50
+ position: 'relative',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ width,
56
+ height,
57
+ padding: '0 60px',
72
58
  },
73
- children: siteName,
59
+ children: [
60
+ {
61
+ type: 'h1',
62
+ props: {
63
+ style: {
64
+ fontSize: 64,
65
+ fontWeight: 700,
66
+ color: colors.text,
67
+ letterSpacing: '-0.02em',
68
+ margin: 0,
69
+ textAlign: 'center',
70
+ lineHeight: 1.1,
71
+ maxWidth: 1000,
72
+ },
73
+ children: title,
74
+ },
75
+ },
76
+ description ? {
77
+ type: 'p',
78
+ props: {
79
+ style: {
80
+ fontSize: 28,
81
+ fontWeight: 400,
82
+ color: colors.accent,
83
+ marginTop: 28,
84
+ marginBottom: 0,
85
+ textAlign: 'center',
86
+ lineHeight: 1.4,
87
+ maxWidth: 900,
88
+ },
89
+ children: description,
90
+ },
91
+ } : null,
92
+ {
93
+ type: 'p',
94
+ props: {
95
+ style: {
96
+ fontSize: 24,
97
+ fontWeight: 400,
98
+ color: colors.accent,
99
+ marginTop: 56,
100
+ marginBottom: 0,
101
+ textAlign: 'center',
102
+ opacity: 0.7,
103
+ },
104
+ children: siteName,
105
+ },
106
+ },
107
+ ].filter(Boolean),
74
108
  },
75
109
  },
76
110
  ].filter(Boolean),
@@ -10,6 +10,7 @@ export function defaultTemplate({
10
10
  description,
11
11
  siteName,
12
12
  colors,
13
+ noiseDataUrl,
13
14
  width,
14
15
  height,
15
16
  }: OgTemplateProps) {
@@ -17,6 +18,7 @@ export function defaultTemplate({
17
18
  type: 'div',
18
19
  props: {
19
20
  style: {
21
+ position: 'relative',
20
22
  display: 'flex',
21
23
  flexDirection: 'column',
22
24
  alignItems: 'center',
@@ -26,48 +28,80 @@ export function defaultTemplate({
26
28
  backgroundColor: colors.background,
27
29
  },
28
30
  children: [
29
- {
30
- type: 'h1',
31
- props: {
32
- style: {
33
- fontSize: 72,
34
- fontWeight: 700,
35
- color: colors.text,
36
- letterSpacing: '-0.02em',
37
- margin: 0,
38
- textAlign: 'center',
39
- },
40
- children: title,
41
- },
42
- },
43
- description ? {
44
- type: 'p',
31
+ noiseDataUrl ? {
32
+ type: 'img',
45
33
  props: {
34
+ src: noiseDataUrl,
35
+ width,
36
+ height,
46
37
  style: {
47
- fontSize: 32,
48
- fontWeight: 400,
49
- color: colors.accent,
50
- marginTop: 24,
51
- marginBottom: 0,
52
- textAlign: 'center',
53
- maxWidth: 900,
38
+ position: 'absolute',
39
+ top: 0,
40
+ left: 0,
41
+ width,
42
+ height,
54
43
  },
55
- children: description,
56
44
  },
57
45
  } : null,
58
46
  {
59
- type: 'p',
47
+ type: 'div',
60
48
  props: {
61
49
  style: {
62
- fontSize: 28,
63
- fontWeight: 400,
64
- color: colors.accent,
65
- marginTop: 64,
66
- marginBottom: 0,
67
- textAlign: 'center',
68
- opacity: 0.7,
50
+ position: 'relative',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ width,
56
+ height,
57
+ padding: '0 60px',
69
58
  },
70
- children: siteName,
59
+ children: [
60
+ {
61
+ type: 'h1',
62
+ props: {
63
+ style: {
64
+ fontSize: 72,
65
+ fontWeight: 700,
66
+ color: colors.text,
67
+ letterSpacing: '-0.02em',
68
+ margin: 0,
69
+ textAlign: 'center',
70
+ },
71
+ children: title,
72
+ },
73
+ },
74
+ description ? {
75
+ type: 'p',
76
+ props: {
77
+ style: {
78
+ fontSize: 32,
79
+ fontWeight: 400,
80
+ color: colors.accent,
81
+ marginTop: 24,
82
+ marginBottom: 0,
83
+ textAlign: 'center',
84
+ maxWidth: 900,
85
+ },
86
+ children: description,
87
+ },
88
+ } : null,
89
+ {
90
+ type: 'p',
91
+ props: {
92
+ style: {
93
+ fontSize: 28,
94
+ fontWeight: 400,
95
+ color: colors.accent,
96
+ marginTop: 64,
97
+ marginBottom: 0,
98
+ textAlign: 'center',
99
+ opacity: 0.7,
100
+ },
101
+ children: siteName,
102
+ },
103
+ },
104
+ ].filter(Boolean),
71
105
  },
72
106
  },
73
107
  ].filter(Boolean),
@@ -11,13 +11,14 @@ export function profileTemplate({
11
11
  siteName,
12
12
  image,
13
13
  colors,
14
+ noiseDataUrl,
14
15
  width,
15
16
  height,
16
17
  }: OgTemplateProps) {
17
- const children: unknown[] = []
18
+ const contentChildren: unknown[] = []
18
19
 
19
20
  if (image) {
20
- children.push({
21
+ contentChildren.push({
21
22
  type: 'img',
22
23
  props: {
23
24
  src: image,
@@ -32,7 +33,7 @@ export function profileTemplate({
32
33
  })
33
34
  }
34
35
 
35
- children.push({
36
+ contentChildren.push({
36
37
  type: 'h1',
37
38
  props: {
38
39
  style: {
@@ -50,7 +51,7 @@ export function profileTemplate({
50
51
  })
51
52
 
52
53
  if (description) {
53
- children.push({
54
+ contentChildren.push({
54
55
  type: 'p',
55
56
  props: {
56
57
  style: {
@@ -68,7 +69,7 @@ export function profileTemplate({
68
69
  })
69
70
  }
70
71
 
71
- children.push({
72
+ contentChildren.push({
72
73
  type: 'p',
73
74
  props: {
74
75
  style: {
@@ -88,6 +89,7 @@ export function profileTemplate({
88
89
  type: 'div',
89
90
  props: {
90
91
  style: {
92
+ position: 'relative',
91
93
  display: 'flex',
92
94
  flexDirection: 'column',
93
95
  alignItems: 'center',
@@ -96,7 +98,39 @@ export function profileTemplate({
96
98
  height,
97
99
  backgroundColor: colors.background,
98
100
  },
99
- children,
101
+ children: [
102
+ noiseDataUrl ? {
103
+ type: 'img',
104
+ props: {
105
+ src: noiseDataUrl,
106
+ width,
107
+ height,
108
+ style: {
109
+ position: 'absolute',
110
+ top: 0,
111
+ left: 0,
112
+ width,
113
+ height,
114
+ },
115
+ },
116
+ } : null,
117
+ {
118
+ type: 'div',
119
+ props: {
120
+ style: {
121
+ position: 'relative',
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ width,
127
+ height,
128
+ padding: '0 60px',
129
+ },
130
+ children: contentChildren,
131
+ },
132
+ },
133
+ ].filter(Boolean),
100
134
  },
101
135
  }
102
136
  }