@ewanc26/og 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/chunk-EPPJ2HBS.js +258 -0
- package/dist/chunk-EPPJ2HBS.js.map +1 -0
- package/dist/fonts/Inter-Bold.ttf +0 -0
- package/dist/fonts/Inter-Regular.ttf +0 -0
- package/dist/index.cjs +663 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +134 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +365 -0
- package/dist/index.js.map +1 -0
- package/dist/templates/index.cjs +288 -0
- package/dist/templates/index.cjs.map +1 -0
- package/dist/templates/index.d.cts +183 -0
- package/dist/templates/index.d.ts +183 -0
- package/dist/templates/index.js +15 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types-Bn2R50Vr.d.cts +60 -0
- package/dist/types-Bn2R50Vr.d.ts +60 -0
- package/fonts/Inter-Bold.ttf +0 -0
- package/fonts/Inter-Regular.ttf +0 -0
- package/package.json +63 -0
- package/src/endpoint.ts +92 -0
- package/src/fonts.ts +121 -0
- package/src/generate.ts +137 -0
- package/src/index.ts +51 -0
- package/src/noise.ts +90 -0
- package/src/png-encoder.ts +101 -0
- package/src/svg.ts +51 -0
- package/src/templates/blog.ts +79 -0
- package/src/templates/default.ts +76 -0
- package/src/templates/index.ts +27 -0
- package/src/templates/profile.ts +102 -0
- package/src/types.ts +92 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ewanc26/og types
|
|
3
|
+
*/
|
|
4
|
+
interface OgColorConfig {
|
|
5
|
+
/** Background color (very dark). @default '#0f1a15' */
|
|
6
|
+
background: string;
|
|
7
|
+
/** Primary text color. @default '#e8f5e9' */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Secondary/accent text (mint). @default '#86efac' */
|
|
10
|
+
accent: string;
|
|
11
|
+
}
|
|
12
|
+
declare const defaultColors: OgColorConfig;
|
|
13
|
+
interface OgFontConfig {
|
|
14
|
+
heading?: string;
|
|
15
|
+
body?: string;
|
|
16
|
+
}
|
|
17
|
+
interface OgNoiseConfig {
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
seed?: string;
|
|
20
|
+
opacity?: number;
|
|
21
|
+
colorMode?: 'grayscale' | 'hsl';
|
|
22
|
+
}
|
|
23
|
+
interface OgTemplateProps {
|
|
24
|
+
title: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
siteName: string;
|
|
27
|
+
image?: string;
|
|
28
|
+
colors: OgColorConfig;
|
|
29
|
+
noiseDataUrl?: string;
|
|
30
|
+
circleNoiseDataUrl?: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
}
|
|
34
|
+
type OgTemplate = (props: OgTemplateProps) => unknown;
|
|
35
|
+
interface OgGenerateOptions {
|
|
36
|
+
title: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
siteName: string;
|
|
39
|
+
image?: string;
|
|
40
|
+
template?: 'blog' | 'profile' | 'default' | OgTemplate;
|
|
41
|
+
colors?: Partial<OgColorConfig>;
|
|
42
|
+
fonts?: OgFontConfig;
|
|
43
|
+
noise?: OgNoiseConfig;
|
|
44
|
+
noiseSeed?: string;
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
47
|
+
debugSvg?: boolean;
|
|
48
|
+
}
|
|
49
|
+
interface OgEndpointOptions {
|
|
50
|
+
siteName: string;
|
|
51
|
+
defaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate;
|
|
52
|
+
colors?: Partial<OgColorConfig>;
|
|
53
|
+
fonts?: OgFontConfig;
|
|
54
|
+
noise?: OgNoiseConfig;
|
|
55
|
+
cacheMaxAge?: number;
|
|
56
|
+
width?: number;
|
|
57
|
+
height?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { type OgGenerateOptions as O, type OgNoiseConfig as a, type OgFontConfig as b, type OgEndpointOptions as c, type OgColorConfig as d, type OgTemplate as e, type OgTemplateProps as f, defaultColors as g };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ewanc26/og types
|
|
3
|
+
*/
|
|
4
|
+
interface OgColorConfig {
|
|
5
|
+
/** Background color (very dark). @default '#0f1a15' */
|
|
6
|
+
background: string;
|
|
7
|
+
/** Primary text color. @default '#e8f5e9' */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Secondary/accent text (mint). @default '#86efac' */
|
|
10
|
+
accent: string;
|
|
11
|
+
}
|
|
12
|
+
declare const defaultColors: OgColorConfig;
|
|
13
|
+
interface OgFontConfig {
|
|
14
|
+
heading?: string;
|
|
15
|
+
body?: string;
|
|
16
|
+
}
|
|
17
|
+
interface OgNoiseConfig {
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
seed?: string;
|
|
20
|
+
opacity?: number;
|
|
21
|
+
colorMode?: 'grayscale' | 'hsl';
|
|
22
|
+
}
|
|
23
|
+
interface OgTemplateProps {
|
|
24
|
+
title: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
siteName: string;
|
|
27
|
+
image?: string;
|
|
28
|
+
colors: OgColorConfig;
|
|
29
|
+
noiseDataUrl?: string;
|
|
30
|
+
circleNoiseDataUrl?: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
}
|
|
34
|
+
type OgTemplate = (props: OgTemplateProps) => unknown;
|
|
35
|
+
interface OgGenerateOptions {
|
|
36
|
+
title: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
siteName: string;
|
|
39
|
+
image?: string;
|
|
40
|
+
template?: 'blog' | 'profile' | 'default' | OgTemplate;
|
|
41
|
+
colors?: Partial<OgColorConfig>;
|
|
42
|
+
fonts?: OgFontConfig;
|
|
43
|
+
noise?: OgNoiseConfig;
|
|
44
|
+
noiseSeed?: string;
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
47
|
+
debugSvg?: boolean;
|
|
48
|
+
}
|
|
49
|
+
interface OgEndpointOptions {
|
|
50
|
+
siteName: string;
|
|
51
|
+
defaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate;
|
|
52
|
+
colors?: Partial<OgColorConfig>;
|
|
53
|
+
fonts?: OgFontConfig;
|
|
54
|
+
noise?: OgNoiseConfig;
|
|
55
|
+
cacheMaxAge?: number;
|
|
56
|
+
width?: number;
|
|
57
|
+
height?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { type OgGenerateOptions as O, type OgNoiseConfig as a, type OgFontConfig as b, type OgEndpointOptions as c, type OgColorConfig as d, type OgTemplate as e, type OgTemplateProps as f, defaultColors as g };
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ewanc26/og",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"author": "Ewan Croft",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"og",
|
|
10
|
+
"opengraph",
|
|
11
|
+
"image",
|
|
12
|
+
"satori",
|
|
13
|
+
"sveltekit",
|
|
14
|
+
"seo",
|
|
15
|
+
"social"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ewanc26/pkgs.git",
|
|
20
|
+
"directory": "packages/og"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/og",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/ewanc26/pkgs/issues"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src",
|
|
32
|
+
"fonts"
|
|
33
|
+
],
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/index.js",
|
|
38
|
+
"require": "./dist/index.cjs"
|
|
39
|
+
},
|
|
40
|
+
"./templates": {
|
|
41
|
+
"types": "./dist/templates/index.d.ts",
|
|
42
|
+
"import": "./dist/templates/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"main": "./dist/index.cjs",
|
|
46
|
+
"module": "./dist/index.js",
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"check": "tsc --noEmit"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@ewanc26/noise": "workspace:*",
|
|
55
|
+
"@resvg/resvg-js": "^2.6.0",
|
|
56
|
+
"satori": "^0.15.2"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^25.5.0",
|
|
60
|
+
"tsup": "^8.5.1",
|
|
61
|
+
"typescript": "^5.9.3"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit endpoint helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateOgResponse } from './generate.js'
|
|
6
|
+
import type { OgEndpointOptions, OgTemplate } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a SvelteKit GET handler for OG image generation.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // src/routes/og/[title]/+server.ts
|
|
14
|
+
* import { createOgEndpoint } from '@ewanc26/og';
|
|
15
|
+
*
|
|
16
|
+
* export const GET = createOgEndpoint({
|
|
17
|
+
* siteName: 'ewancroft.uk',
|
|
18
|
+
* defaultTemplate: 'blog',
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The endpoint expects query parameters:
|
|
23
|
+
* - `title` (required): Page title
|
|
24
|
+
* - `description`: Optional description
|
|
25
|
+
* - `image`: Optional avatar/logo URL
|
|
26
|
+
* - `seed`: Optional noise seed
|
|
27
|
+
*/
|
|
28
|
+
export function createOgEndpoint(options: OgEndpointOptions) {
|
|
29
|
+
const {
|
|
30
|
+
siteName,
|
|
31
|
+
defaultTemplate: template = 'default',
|
|
32
|
+
colors,
|
|
33
|
+
fonts,
|
|
34
|
+
noise,
|
|
35
|
+
cacheMaxAge = 3600,
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
} = options
|
|
39
|
+
|
|
40
|
+
return async ({ url }: { url: URL }) => {
|
|
41
|
+
const title = url.searchParams.get('title')
|
|
42
|
+
const description = url.searchParams.get('description') ?? undefined
|
|
43
|
+
const image = url.searchParams.get('image') ?? undefined
|
|
44
|
+
const noiseSeed = url.searchParams.get('seed') ?? undefined
|
|
45
|
+
|
|
46
|
+
if (!title) {
|
|
47
|
+
return new Response('Missing title parameter', { status: 400 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await generateOgResponse(
|
|
52
|
+
{
|
|
53
|
+
title,
|
|
54
|
+
description,
|
|
55
|
+
siteName,
|
|
56
|
+
image,
|
|
57
|
+
template: template as OgTemplate,
|
|
58
|
+
colors,
|
|
59
|
+
fonts,
|
|
60
|
+
noise,
|
|
61
|
+
noiseSeed,
|
|
62
|
+
width,
|
|
63
|
+
height,
|
|
64
|
+
},
|
|
65
|
+
cacheMaxAge
|
|
66
|
+
)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error('Failed to generate OG image:', error)
|
|
69
|
+
return new Response('Failed to generate image', { status: 500 })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a typed OG image URL for use in meta tags.
|
|
76
|
+
*/
|
|
77
|
+
export function createOgImageUrl(
|
|
78
|
+
baseUrl: string,
|
|
79
|
+
params: {
|
|
80
|
+
title: string
|
|
81
|
+
description?: string
|
|
82
|
+
image?: string
|
|
83
|
+
seed?: string
|
|
84
|
+
}
|
|
85
|
+
): string {
|
|
86
|
+
const url = new URL(baseUrl)
|
|
87
|
+
url.searchParams.set('title', params.title)
|
|
88
|
+
if (params.description) url.searchParams.set('description', params.description)
|
|
89
|
+
if (params.image) url.searchParams.set('image', params.image)
|
|
90
|
+
if (params.seed) url.searchParams.set('seed', params.seed)
|
|
91
|
+
return url.toString()
|
|
92
|
+
}
|
package/src/fonts.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ewanc26/og fonts
|
|
3
|
+
*
|
|
4
|
+
* Font loading utilities. Bundles Inter font by default.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { dirname, resolve } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import type { OgFontConfig } from './types.js'
|
|
11
|
+
|
|
12
|
+
// Declare __dirname for CJS contexts (injected by bundlers)
|
|
13
|
+
declare const __dirname: string | undefined
|
|
14
|
+
|
|
15
|
+
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the directory of the current module.
|
|
19
|
+
* Works in both ESM and bundled contexts.
|
|
20
|
+
*/
|
|
21
|
+
function getModuleDir(): string {
|
|
22
|
+
// ESM context
|
|
23
|
+
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
|
24
|
+
return dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
}
|
|
26
|
+
// Bundled CJS context - __dirname is injected by bundlers
|
|
27
|
+
if (typeof __dirname !== 'undefined') {
|
|
28
|
+
return __dirname
|
|
29
|
+
}
|
|
30
|
+
// Fallback
|
|
31
|
+
return resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the fonts directory relative to the installed package.
|
|
36
|
+
*/
|
|
37
|
+
function getFontsDir(): string {
|
|
38
|
+
return resolve(getModuleDir(), '../fonts')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve bundled font paths. Uses getters to defer resolution until runtime.
|
|
43
|
+
*/
|
|
44
|
+
export const BUNDLED_FONTS = {
|
|
45
|
+
get heading() {
|
|
46
|
+
return resolve(getFontsDir(), 'Inter-Bold.ttf')
|
|
47
|
+
},
|
|
48
|
+
get body() {
|
|
49
|
+
return resolve(getFontsDir(), 'Inter-Regular.ttf')
|
|
50
|
+
},
|
|
51
|
+
} as const
|
|
52
|
+
|
|
53
|
+
// ─── Font Loading ──────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface LoadedFonts {
|
|
56
|
+
heading: ArrayBuffer
|
|
57
|
+
body: ArrayBuffer
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load fonts from config, falling back to bundled DM Sans.
|
|
62
|
+
*/
|
|
63
|
+
export async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {
|
|
64
|
+
const headingPath = config?.heading ?? BUNDLED_FONTS.heading
|
|
65
|
+
const bodyPath = config?.body ?? BUNDLED_FONTS.body
|
|
66
|
+
|
|
67
|
+
const [heading, body] = await Promise.all([
|
|
68
|
+
loadFontFile(headingPath),
|
|
69
|
+
loadFontFile(bodyPath),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
return { heading, body }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load a font from file path or URL.
|
|
77
|
+
*/
|
|
78
|
+
async function loadFontFile(source: string): Promise<ArrayBuffer> {
|
|
79
|
+
// Handle URLs
|
|
80
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
81
|
+
const response = await fetch(source)
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(`Failed to load font from URL: ${source}`)
|
|
84
|
+
}
|
|
85
|
+
return response.arrayBuffer()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle file paths
|
|
89
|
+
const buffer = await readFile(source)
|
|
90
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Font Registration for Satori ─────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export type SatoriFontConfig = {
|
|
96
|
+
name: string
|
|
97
|
+
data: ArrayBuffer
|
|
98
|
+
weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
99
|
+
style: 'normal' | 'italic'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create Satori-compatible font config from loaded fonts.
|
|
104
|
+
* Uses Inter font family with weight 700 for headings and 400 for body.
|
|
105
|
+
*/
|
|
106
|
+
export function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
name: 'Inter',
|
|
110
|
+
data: fonts.heading,
|
|
111
|
+
weight: 700,
|
|
112
|
+
style: 'normal',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'Inter',
|
|
116
|
+
data: fonts.body,
|
|
117
|
+
weight: 400,
|
|
118
|
+
style: 'normal',
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core OG image generation.
|
|
3
|
+
* Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import satori from 'satori'
|
|
7
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
8
|
+
import { loadFonts, createSatoriFonts } from './fonts.js'
|
|
9
|
+
import { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'
|
|
10
|
+
import { getTemplate } from './templates/index.js'
|
|
11
|
+
import { defaultColors } from './types.js'
|
|
12
|
+
import type {
|
|
13
|
+
OgGenerateOptions,
|
|
14
|
+
OgColorConfig,
|
|
15
|
+
OgTemplateProps,
|
|
16
|
+
} from './types.js'
|
|
17
|
+
|
|
18
|
+
// Standard OG image dimensions
|
|
19
|
+
export const OG_WIDTH = 1200
|
|
20
|
+
export const OG_HEIGHT = 630
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate an OG image as PNG Buffer.
|
|
24
|
+
*/
|
|
25
|
+
export async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {
|
|
26
|
+
const {
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
siteName,
|
|
30
|
+
image,
|
|
31
|
+
template = 'blog',
|
|
32
|
+
colors: colorOverrides,
|
|
33
|
+
fonts: fontConfig,
|
|
34
|
+
noise: noiseConfig,
|
|
35
|
+
noiseSeed,
|
|
36
|
+
width = OG_WIDTH,
|
|
37
|
+
height = OG_HEIGHT,
|
|
38
|
+
debugSvg = false,
|
|
39
|
+
} = options
|
|
40
|
+
|
|
41
|
+
// Merge colours
|
|
42
|
+
const colors: OgColorConfig = {
|
|
43
|
+
...defaultColors,
|
|
44
|
+
...colorOverrides,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load fonts
|
|
48
|
+
const fonts = await loadFonts(fontConfig)
|
|
49
|
+
const satoriFonts = createSatoriFonts(fonts)
|
|
50
|
+
|
|
51
|
+
// Generate noise background
|
|
52
|
+
const noiseEnabled = noiseConfig?.enabled !== false
|
|
53
|
+
const noiseSeedValue = noiseSeed || noiseConfig?.seed || title
|
|
54
|
+
const noiseDataUrl = noiseEnabled
|
|
55
|
+
? generateNoiseDataUrl({
|
|
56
|
+
seed: noiseSeedValue,
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
opacity: noiseConfig?.opacity ?? 0.4,
|
|
60
|
+
colorMode: noiseConfig?.colorMode ?? 'grayscale',
|
|
61
|
+
})
|
|
62
|
+
: undefined
|
|
63
|
+
|
|
64
|
+
// Generate circular noise decoration
|
|
65
|
+
const circleNoiseDataUrl = noiseEnabled
|
|
66
|
+
? generateCircleNoiseDataUrl({
|
|
67
|
+
seed: `${noiseSeedValue}-circle`,
|
|
68
|
+
size: 200,
|
|
69
|
+
opacity: noiseConfig?.opacity ?? 0.15,
|
|
70
|
+
colorMode: noiseConfig?.colorMode ?? 'grayscale',
|
|
71
|
+
})
|
|
72
|
+
: undefined
|
|
73
|
+
|
|
74
|
+
// Get template function
|
|
75
|
+
const templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])
|
|
76
|
+
|
|
77
|
+
// Build template props
|
|
78
|
+
const props: OgTemplateProps = {
|
|
79
|
+
title,
|
|
80
|
+
description,
|
|
81
|
+
siteName,
|
|
82
|
+
image,
|
|
83
|
+
colors,
|
|
84
|
+
noiseDataUrl,
|
|
85
|
+
circleNoiseDataUrl,
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Render template to Satori-compatible structure
|
|
91
|
+
const element = templateFn(props)
|
|
92
|
+
|
|
93
|
+
// Generate SVG with satori
|
|
94
|
+
const svg = await satori(element as Parameters<typeof satori>[0], {
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
fonts: satoriFonts,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Debug: return SVG string
|
|
101
|
+
if (debugSvg) {
|
|
102
|
+
return Buffer.from(svg)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Convert SVG to PNG with resvg-js
|
|
106
|
+
const resvg = new Resvg(svg, {
|
|
107
|
+
fitTo: {
|
|
108
|
+
mode: 'width',
|
|
109
|
+
value: width,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
const pngData = resvg.render()
|
|
113
|
+
|
|
114
|
+
return Buffer.from(pngData.asPng())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate OG image and return as base64 data URL.
|
|
119
|
+
*/
|
|
120
|
+
export async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {
|
|
121
|
+
const png = await generateOgImage(options)
|
|
122
|
+
return `data:image/png;base64,${png.toString('base64')}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate OG image and return as Response (for SvelteKit endpoints).
|
|
127
|
+
*/
|
|
128
|
+
export async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {
|
|
129
|
+
const png = await generateOgImage(options)
|
|
130
|
+
|
|
131
|
+
return new Response(png, {
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'image/png',
|
|
134
|
+
'Cache-Control': `public, max-age=${cacheMaxAge}`,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ewanc26/og
|
|
3
|
+
*
|
|
4
|
+
* Dynamic OpenGraph image generator with noise backgrounds, bold typography,
|
|
5
|
+
* and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes,
|
|
6
|
+
* and build scripts.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { generateOgImage, createOgEndpoint } from '@ewanc26/og';
|
|
11
|
+
* import { blogTemplate } from '@ewanc26/og/templates';
|
|
12
|
+
*
|
|
13
|
+
* // Generate PNG
|
|
14
|
+
* const png = await generateOgImage({
|
|
15
|
+
* title: 'My Blog Post',
|
|
16
|
+
* description: 'A description',
|
|
17
|
+
* siteName: 'ewancroft.uk',
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // SvelteKit endpoint
|
|
21
|
+
* export const GET = createOgEndpoint({ siteName: 'ewancroft.uk' });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Core generation
|
|
26
|
+
export { generateOgImage, generateOgImageDataUrl, generateOgResponse, OG_WIDTH, OG_HEIGHT } from './generate.js'
|
|
27
|
+
|
|
28
|
+
// Types
|
|
29
|
+
export type {
|
|
30
|
+
OgColorConfig,
|
|
31
|
+
OgFontConfig,
|
|
32
|
+
OgNoiseConfig,
|
|
33
|
+
OgTemplateProps,
|
|
34
|
+
OgTemplate,
|
|
35
|
+
OgGenerateOptions,
|
|
36
|
+
OgEndpointOptions,
|
|
37
|
+
} from './types.js'
|
|
38
|
+
export { defaultColors } from './types.js'
|
|
39
|
+
|
|
40
|
+
// Noise (for advanced customization)
|
|
41
|
+
export { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'
|
|
42
|
+
|
|
43
|
+
// Fonts (for advanced customization)
|
|
44
|
+
export { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js'
|
|
45
|
+
|
|
46
|
+
// Endpoint helpers
|
|
47
|
+
export { createOgEndpoint } from './endpoint.js'
|
|
48
|
+
|
|
49
|
+
// SVG to PNG conversion
|
|
50
|
+
export { svgToPng, svgToPngDataUrl, svgToPngResponse } from './svg.js'
|
|
51
|
+
export type { SvgToPngOptions } from './svg.js'
|
package/src/noise.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ewanc26/og noise
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateNoisePixels } from '@ewanc26/noise'
|
|
6
|
+
import { PNGEncoder } from './png-encoder.js'
|
|
7
|
+
import type { OgNoiseConfig } from './types.js'
|
|
8
|
+
|
|
9
|
+
export interface NoiseOptions {
|
|
10
|
+
seed: string
|
|
11
|
+
width: number
|
|
12
|
+
height: number
|
|
13
|
+
opacity?: number
|
|
14
|
+
colorMode?: OgNoiseConfig['colorMode']
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CircleNoiseOptions {
|
|
18
|
+
seed: string
|
|
19
|
+
size: number
|
|
20
|
+
opacity?: number
|
|
21
|
+
colorMode?: OgNoiseConfig['colorMode']
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate a noise PNG as a data URL.
|
|
26
|
+
*/
|
|
27
|
+
export function generateNoiseDataUrl(options: NoiseOptions): string {
|
|
28
|
+
const { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options
|
|
29
|
+
|
|
30
|
+
const pixels = generateNoisePixels(width, height, seed, {
|
|
31
|
+
gridSize: 4,
|
|
32
|
+
octaves: 3,
|
|
33
|
+
colorMode: colorMode === 'grayscale'
|
|
34
|
+
? { type: 'grayscale', range: [20, 60] }
|
|
35
|
+
: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (opacity < 1) {
|
|
39
|
+
for (let i = 3; i < pixels.length; i += 4) {
|
|
40
|
+
pixels[i] = Math.round(pixels[i] * opacity)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pngBuffer = PNGEncoder.encode(pixels, width, height)
|
|
45
|
+
return `data:image/png;base64,${pngBuffer.toString('base64')}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a circular noise PNG as a data URL.
|
|
50
|
+
* Creates a square image with circular transparency mask.
|
|
51
|
+
*/
|
|
52
|
+
export function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {
|
|
53
|
+
const { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options
|
|
54
|
+
|
|
55
|
+
const pixels = generateNoisePixels(size, size, seed, {
|
|
56
|
+
gridSize: 4,
|
|
57
|
+
octaves: 3,
|
|
58
|
+
colorMode: colorMode === 'grayscale'
|
|
59
|
+
? { type: 'grayscale', range: [30, 70] }
|
|
60
|
+
: { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const center = size / 2
|
|
64
|
+
const radius = size / 2
|
|
65
|
+
|
|
66
|
+
// Apply circular mask
|
|
67
|
+
for (let y = 0; y < size; y++) {
|
|
68
|
+
for (let x = 0; x < size; x++) {
|
|
69
|
+
const idx = (y * size + x) * 4
|
|
70
|
+
const dx = x - center + 0.5
|
|
71
|
+
const dy = y - center + 0.5
|
|
72
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
73
|
+
|
|
74
|
+
if (dist > radius) {
|
|
75
|
+
// Outside circle - fully transparent
|
|
76
|
+
pixels[idx + 3] = 0
|
|
77
|
+
} else if (dist > radius - 2) {
|
|
78
|
+
// Anti-alias edge
|
|
79
|
+
const edgeOpacity = (radius - dist) / 2
|
|
80
|
+
pixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)
|
|
81
|
+
} else {
|
|
82
|
+
// Inside circle - apply opacity
|
|
83
|
+
pixels[idx + 3] = Math.round(255 * opacity)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pngBuffer = PNGEncoder.encode(pixels, size, size)
|
|
89
|
+
return `data:image/png;base64,${pngBuffer.toString('base64')}`
|
|
90
|
+
}
|