@pyreon/zero 0.12.1 → 0.12.2
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/lib/index.js +1476 -82
- package/lib/index.js.map +1 -1
- package/lib/types/adapters/cloudflare.d.ts +26 -0
- package/lib/types/adapters/cloudflare.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +3 -0
- package/lib/types/adapters/index.d.ts.map +1 -1
- package/lib/types/adapters/netlify.d.ts +21 -0
- package/lib/types/adapters/netlify.d.ts.map +1 -0
- package/lib/types/adapters/vercel.d.ts +21 -0
- package/lib/types/adapters/vercel.d.ts.map +1 -0
- package/lib/types/ai.d.ts +182 -0
- package/lib/types/ai.d.ts.map +1 -0
- package/lib/types/csp.d.ts +107 -0
- package/lib/types/csp.d.ts.map +1 -0
- package/lib/types/env.d.ts +118 -0
- package/lib/types/env.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +42 -0
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/index.d.ts +13 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/logger.d.ts +68 -0
- package/lib/types/logger.d.ts.map +1 -0
- package/lib/types/meta.d.ts +36 -0
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +107 -0
- package/lib/types/og-image.d.ts.map +1 -0
- package/lib/types/types.d.ts +1 -1
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +35 -10
- package/src/adapters/cloudflare.ts +82 -0
- package/src/adapters/index.ts +13 -1
- package/src/adapters/netlify.ts +84 -0
- package/src/adapters/vercel.ts +84 -0
- package/src/ai.ts +623 -0
- package/src/csp.ts +207 -0
- package/src/env.ts +344 -0
- package/src/favicon.ts +221 -80
- package/src/index.ts +41 -2
- package/src/logger.ts +144 -0
- package/src/meta.tsx +84 -2
- package/src/og-image.ts +378 -0
- package/src/types.ts +1 -1
package/src/meta.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { VNodeChild } from '@pyreon/core'
|
|
2
2
|
import { useHead } from '@pyreon/head'
|
|
3
|
+
import type { FaviconPluginConfig } from './favicon'
|
|
4
|
+
import { faviconLinks } from './favicon'
|
|
3
5
|
import type { I18nRoutingConfig } from './i18n-routing'
|
|
4
6
|
import { extractLocaleFromPath } from './i18n-routing'
|
|
7
|
+
import { ogImagePath } from './og-image'
|
|
5
8
|
|
|
6
9
|
// ─── Meta component ────────────────────────────────────────────────────────
|
|
7
10
|
|
|
@@ -16,6 +19,10 @@ export interface MetaProps {
|
|
|
16
19
|
image?: string
|
|
17
20
|
/** Image alt text for accessibility. */
|
|
18
21
|
imageAlt?: string
|
|
22
|
+
/** Image width in pixels (og:image:width). Helps crawlers layout before loading. */
|
|
23
|
+
imageWidth?: number
|
|
24
|
+
/** Image height in pixels (og:image:height). */
|
|
25
|
+
imageHeight?: number
|
|
19
26
|
/** Open Graph type. Default: "website" */
|
|
20
27
|
type?: 'website' | 'article' | 'product' | 'profile'
|
|
21
28
|
/** Site name for og:site_name. */
|
|
@@ -32,6 +39,8 @@ export interface MetaProps {
|
|
|
32
39
|
alternateLocales?: Array<{ locale: string; url: string }>
|
|
33
40
|
/** Robots directives. Default: "index, follow" */
|
|
34
41
|
robots?: string
|
|
42
|
+
/** Convenience: set `true` to emit `noindex, nofollow`. Overrides `robots`. */
|
|
43
|
+
noIndex?: boolean
|
|
35
44
|
/** Published time (ISO 8601) for article type. */
|
|
36
45
|
publishedTime?: string
|
|
37
46
|
/** Modified time (ISO 8601) for article type. */
|
|
@@ -44,6 +53,19 @@ export interface MetaProps {
|
|
|
44
53
|
jsonLd?: Record<string, unknown>
|
|
45
54
|
/** Additional custom meta tags. */
|
|
46
55
|
extra?: Array<{ name?: string; property?: string; content: string }>
|
|
56
|
+
/**
|
|
57
|
+
* Open Graph video URL. Also sets og:video:type if the URL ends with
|
|
58
|
+
* a known extension (.mp4, .webm).
|
|
59
|
+
*/
|
|
60
|
+
video?: string
|
|
61
|
+
/** Video width in pixels. */
|
|
62
|
+
videoWidth?: number
|
|
63
|
+
/** Video height in pixels. */
|
|
64
|
+
videoHeight?: number
|
|
65
|
+
/**
|
|
66
|
+
* Open Graph audio URL.
|
|
67
|
+
*/
|
|
68
|
+
audio?: string
|
|
47
69
|
/**
|
|
48
70
|
* I18n routing config — when provided, auto-generates hreflang alternate
|
|
49
71
|
* links for all locales based on the current path.
|
|
@@ -52,6 +74,22 @@ export interface MetaProps {
|
|
|
52
74
|
i18n?: I18nRoutingConfig
|
|
53
75
|
/** Base URL for building absolute hreflang URLs. e.g. "https://example.com" */
|
|
54
76
|
origin?: string
|
|
77
|
+
/**
|
|
78
|
+
* Favicon plugin config — when provided, injects locale-aware favicon
|
|
79
|
+
* `<link>` tags into `<head>`. Uses the current locale to select
|
|
80
|
+
* the correct favicon set.
|
|
81
|
+
*/
|
|
82
|
+
favicon?: FaviconPluginConfig
|
|
83
|
+
/**
|
|
84
|
+
* OG image template name — auto-resolves to the correct locale-specific
|
|
85
|
+
* OG image path generated by `ogImagePlugin`.
|
|
86
|
+
* Sets both `og:image` and `twitter:image` unless `image` is also provided.
|
|
87
|
+
*/
|
|
88
|
+
ogTemplate?: string
|
|
89
|
+
/** Output directory for OG images. Default: "og" */
|
|
90
|
+
ogImageDir?: string
|
|
91
|
+
/** OG image format. Default: "png" */
|
|
92
|
+
ogImageFormat?: 'png' | 'jpeg'
|
|
55
93
|
children?: VNodeChild
|
|
56
94
|
}
|
|
57
95
|
|
|
@@ -116,14 +154,29 @@ export function buildMetaTags(
|
|
|
116
154
|
const script: Array<{ type: string; children: string }> = []
|
|
117
155
|
|
|
118
156
|
const {
|
|
119
|
-
title, description, canonical,
|
|
157
|
+
title, description, canonical, imageAlt, imageWidth, imageHeight,
|
|
120
158
|
type = 'website', siteName,
|
|
121
159
|
twitterCard = 'summary_large_image', twitterSite, twitterCreator,
|
|
122
160
|
locale = 'en_US', alternateLocales,
|
|
123
|
-
robots = 'index, follow',
|
|
124
161
|
publishedTime, modifiedTime, author, tags, jsonLd, extra,
|
|
162
|
+
video, videoWidth, videoHeight, audio,
|
|
163
|
+
favicon, ogTemplate, ogImageDir, ogImageFormat,
|
|
125
164
|
} = props
|
|
126
165
|
|
|
166
|
+
// noIndex convenience overrides robots
|
|
167
|
+
const robots = props.noIndex ? 'noindex, nofollow' : (props.robots ?? 'index, follow')
|
|
168
|
+
|
|
169
|
+
// Resolve image: explicit `image` prop takes precedence over `ogTemplate`
|
|
170
|
+
const image = props.image ?? (
|
|
171
|
+
ogTemplate
|
|
172
|
+
? ogImagePath(ogTemplate, locale !== 'en_US' ? locale : undefined, ogImageDir, ogImageFormat)
|
|
173
|
+
: undefined
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Auto-resolve image dimensions for OG template images
|
|
177
|
+
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : undefined)
|
|
178
|
+
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : undefined)
|
|
179
|
+
|
|
127
180
|
if (description) meta.push({ name: 'description', content: description })
|
|
128
181
|
if (robots) meta.push({ name: 'robots', content: robots })
|
|
129
182
|
if (author) meta.push({ name: 'author', content: author })
|
|
@@ -133,10 +186,27 @@ export function buildMetaTags(
|
|
|
133
186
|
if (canonical) meta.push({ property: 'og:url', content: canonical })
|
|
134
187
|
if (image) meta.push({ property: 'og:image', content: image })
|
|
135
188
|
if (imageAlt) meta.push({ property: 'og:image:alt', content: imageAlt })
|
|
189
|
+
if (resolvedImageWidth) meta.push({ property: 'og:image:width', content: String(resolvedImageWidth) })
|
|
190
|
+
if (resolvedImageHeight) meta.push({ property: 'og:image:height', content: String(resolvedImageHeight) })
|
|
136
191
|
meta.push({ property: 'og:type', content: type })
|
|
137
192
|
if (siteName) meta.push({ property: 'og:site_name', content: siteName })
|
|
138
193
|
meta.push({ property: 'og:locale', content: locale })
|
|
139
194
|
|
|
195
|
+
// Video
|
|
196
|
+
if (video) {
|
|
197
|
+
meta.push({ property: 'og:video', content: video })
|
|
198
|
+
if (videoWidth) meta.push({ property: 'og:video:width', content: String(videoWidth) })
|
|
199
|
+
if (videoHeight) meta.push({ property: 'og:video:height', content: String(videoHeight) })
|
|
200
|
+
// Auto-detect video type from extension
|
|
201
|
+
if (video.endsWith('.mp4')) meta.push({ property: 'og:video:type', content: 'video/mp4' })
|
|
202
|
+
else if (video.endsWith('.webm')) meta.push({ property: 'og:video:type', content: 'video/webm' })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Audio
|
|
206
|
+
if (audio) {
|
|
207
|
+
meta.push({ property: 'og:audio', content: audio })
|
|
208
|
+
}
|
|
209
|
+
|
|
140
210
|
if (type === 'article') {
|
|
141
211
|
if (publishedTime) meta.push({ property: 'article:published_time', content: publishedTime })
|
|
142
212
|
if (modifiedTime) meta.push({ property: 'article:modified_time', content: modifiedTime })
|
|
@@ -206,5 +276,17 @@ export function buildMetaTags(
|
|
|
206
276
|
})
|
|
207
277
|
}
|
|
208
278
|
|
|
279
|
+
// Favicon: inject locale-aware favicon links
|
|
280
|
+
if (favicon) {
|
|
281
|
+
const faviconLocale = locale !== 'en_US' ? locale : undefined
|
|
282
|
+
for (const fl of faviconLinks(faviconLocale, favicon)) {
|
|
283
|
+
link.push(fl as Record<string, string>)
|
|
284
|
+
}
|
|
285
|
+
// Theme color meta from favicon config
|
|
286
|
+
if (favicon.themeColor) {
|
|
287
|
+
meta.push({ name: 'theme-color', content: favicon.themeColor })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
209
291
|
return { meta, link, script }
|
|
210
292
|
}
|
package/src/og-image.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OG Image generation plugin.
|
|
3
|
+
*
|
|
4
|
+
* Generates Open Graph images at build time from templates with
|
|
5
|
+
* text overlays. Supports locale-specific text for i18n apps.
|
|
6
|
+
* Uses sharp for image processing (same optional dep as favicon/image plugins).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // vite.config.ts
|
|
11
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
12
|
+
*
|
|
13
|
+
* export default {
|
|
14
|
+
* plugins: [
|
|
15
|
+
* ogImagePlugin({
|
|
16
|
+
* locales: ["en", "de", "cs"],
|
|
17
|
+
* templates: [{
|
|
18
|
+
* name: "default",
|
|
19
|
+
* background: "./src/assets/og-bg.jpg",
|
|
20
|
+
* layers: [{
|
|
21
|
+
* text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
|
|
22
|
+
* y: "40%",
|
|
23
|
+
* fontSize: 72,
|
|
24
|
+
* }],
|
|
25
|
+
* }],
|
|
26
|
+
* }),
|
|
27
|
+
* ],
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import type { Plugin } from 'vite'
|
|
34
|
+
|
|
35
|
+
let sharpWarned = false
|
|
36
|
+
function warnSharpMissing() {
|
|
37
|
+
if (sharpWarned) return
|
|
38
|
+
sharpWarned = true
|
|
39
|
+
// oxlint-disable-next-line no-console
|
|
40
|
+
console.warn(
|
|
41
|
+
'\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface OgImageLayer {
|
|
48
|
+
/**
|
|
49
|
+
* Text content. Can be:
|
|
50
|
+
* - A string (same for all locales)
|
|
51
|
+
* - A record mapping locale → text
|
|
52
|
+
* - A function receiving locale and returning text
|
|
53
|
+
*/
|
|
54
|
+
text: string | Record<string, string> | ((locale: string) => string)
|
|
55
|
+
/** X position — number (px) or string with % (e.g. "50%"). Default: "50%" */
|
|
56
|
+
x?: number | string
|
|
57
|
+
/** Y position — number (px) or string with % (e.g. "40%"). Default: "50%" */
|
|
58
|
+
y?: number | string
|
|
59
|
+
/** Font size in px. Default: 64 */
|
|
60
|
+
fontSize?: number
|
|
61
|
+
/** Font family. Default: "sans-serif" */
|
|
62
|
+
fontFamily?: string
|
|
63
|
+
/** Font weight. Default: "bold" */
|
|
64
|
+
fontWeight?: string
|
|
65
|
+
/** Text color. Default: "#ffffff" */
|
|
66
|
+
color?: string
|
|
67
|
+
/** Text anchor (alignment). Default: "middle" */
|
|
68
|
+
textAnchor?: 'start' | 'middle' | 'end'
|
|
69
|
+
/** Max width in px before wrapping. Default: 80% of image width. */
|
|
70
|
+
maxWidth?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OgImageTemplate {
|
|
74
|
+
/** Template name — used for output file naming. */
|
|
75
|
+
name: string
|
|
76
|
+
/**
|
|
77
|
+
* Background: path to an image file, or a solid color config.
|
|
78
|
+
*
|
|
79
|
+
* @example "./src/assets/og-bg.jpg"
|
|
80
|
+
* @example { color: "#0066ff", width: 1200, height: 630 }
|
|
81
|
+
*/
|
|
82
|
+
background: string | { color: string; width?: number; height?: number }
|
|
83
|
+
/** Output width. Default: 1200 */
|
|
84
|
+
width?: number
|
|
85
|
+
/** Output height. Default: 630 */
|
|
86
|
+
height?: number
|
|
87
|
+
/** Output format. Default: "png" */
|
|
88
|
+
format?: 'png' | 'jpeg'
|
|
89
|
+
/** JPEG quality (1-100). Default: 90 */
|
|
90
|
+
quality?: number
|
|
91
|
+
/** Text layers to overlay on the background. */
|
|
92
|
+
layers?: OgImageLayer[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface OgImagePluginConfig {
|
|
96
|
+
/** Templates to generate. */
|
|
97
|
+
templates: OgImageTemplate[]
|
|
98
|
+
/** Locales to generate for. When omitted, generates a single image per template. */
|
|
99
|
+
locales?: string[]
|
|
100
|
+
/** Output directory prefix. Default: "og" */
|
|
101
|
+
outDir?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function resolvePosition(value: number | string | undefined, dimension: number, fallback = '50%'): number {
|
|
107
|
+
if (value === undefined) value = fallback
|
|
108
|
+
if (typeof value === 'number') return value
|
|
109
|
+
if (value.endsWith('%')) return Math.round((Number.parseFloat(value) / 100) * dimension)
|
|
110
|
+
return Number.parseInt(value, 10) || 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveLayerText(layer: OgImageLayer, locale: string): string {
|
|
114
|
+
if (typeof layer.text === 'string') return layer.text
|
|
115
|
+
if (typeof layer.text === 'function') return layer.text(locale)
|
|
116
|
+
return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ''] ?? ''
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function escapeXml(str: string): string {
|
|
120
|
+
return str
|
|
121
|
+
.replace(/&/g, '&')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, ''')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build an SVG overlay with text layers.
|
|
130
|
+
* @internal Exported for testing.
|
|
131
|
+
*/
|
|
132
|
+
export function buildTextOverlaySvg(
|
|
133
|
+
layers: OgImageLayer[],
|
|
134
|
+
width: number,
|
|
135
|
+
height: number,
|
|
136
|
+
locale: string,
|
|
137
|
+
): string {
|
|
138
|
+
const textElements = layers.map((layer) => {
|
|
139
|
+
const text = resolveLayerText(layer, locale)
|
|
140
|
+
const x = resolvePosition(layer.x, width, '50%')
|
|
141
|
+
const y = resolvePosition(layer.y, height, '50%')
|
|
142
|
+
const fontSize = layer.fontSize ?? 64
|
|
143
|
+
const fontFamily = layer.fontFamily ?? 'sans-serif'
|
|
144
|
+
const fontWeight = layer.fontWeight ?? 'bold'
|
|
145
|
+
const color = layer.color ?? '#ffffff'
|
|
146
|
+
const anchor = layer.textAnchor ?? 'middle'
|
|
147
|
+
const maxWidth = layer.maxWidth ?? Math.round(width * 0.8)
|
|
148
|
+
|
|
149
|
+
// Word wrapping via tspan elements.
|
|
150
|
+
// Width estimation: Latin chars ~0.55em, CJK chars ~1.0em, narrow chars ~0.35em.
|
|
151
|
+
const words = text.split(' ')
|
|
152
|
+
const lines: string[] = []
|
|
153
|
+
let currentLine = ''
|
|
154
|
+
|
|
155
|
+
const estimateWidth = (s: string): number => {
|
|
156
|
+
let width = 0
|
|
157
|
+
for (let i = 0; i < s.length; i++) {
|
|
158
|
+
const code = s.charCodeAt(i)
|
|
159
|
+
if (code >= 0x3000 && code <= 0x9FFF) {
|
|
160
|
+
// CJK characters — full width
|
|
161
|
+
width += fontSize * 1.0
|
|
162
|
+
} else if (code <= 0x7E && 'iljft!|:;.,\''.includes(s[i]!)) {
|
|
163
|
+
// Narrow Latin characters
|
|
164
|
+
width += fontSize * 0.35
|
|
165
|
+
} else {
|
|
166
|
+
// Regular Latin characters
|
|
167
|
+
width += fontSize * 0.55
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return width
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const word of words) {
|
|
174
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word
|
|
175
|
+
if (estimateWidth(testLine) > maxWidth && currentLine) {
|
|
176
|
+
lines.push(currentLine)
|
|
177
|
+
currentLine = word
|
|
178
|
+
} else {
|
|
179
|
+
currentLine = testLine
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (currentLine) lines.push(currentLine)
|
|
183
|
+
|
|
184
|
+
const tspans = lines
|
|
185
|
+
.map((line, i) => {
|
|
186
|
+
const dy = i === 0 ? '0' : `${fontSize * 1.2}`
|
|
187
|
+
return `<tspan x="${x}" dy="${dy}">${escapeXml(line)}</tspan>`
|
|
188
|
+
})
|
|
189
|
+
.join('')
|
|
190
|
+
|
|
191
|
+
return `<text x="${x}" y="${y}" font-size="${fontSize}" font-family="${escapeXml(fontFamily)}" font-weight="${fontWeight}" fill="${color}" text-anchor="${anchor}" dominant-baseline="middle">${tspans}</text>`
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${textElements.join('')}</svg>`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render an OG image from a template for a specific locale.
|
|
199
|
+
* @internal Exported for testing.
|
|
200
|
+
*/
|
|
201
|
+
export async function renderOgImage(
|
|
202
|
+
template: OgImageTemplate,
|
|
203
|
+
locale: string,
|
|
204
|
+
rootDir: string,
|
|
205
|
+
): Promise<Uint8Array | null> {
|
|
206
|
+
try {
|
|
207
|
+
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
208
|
+
const width = template.width ?? 1200
|
|
209
|
+
const height = template.height ?? 630
|
|
210
|
+
|
|
211
|
+
let pipeline: any
|
|
212
|
+
if (typeof template.background === 'string') {
|
|
213
|
+
const bgPath = join(rootDir, template.background)
|
|
214
|
+
pipeline = sharp(bgPath).resize(width, height, { fit: 'cover' })
|
|
215
|
+
} else {
|
|
216
|
+
pipeline = (sharp as any)({
|
|
217
|
+
create: {
|
|
218
|
+
width,
|
|
219
|
+
height,
|
|
220
|
+
channels: 4,
|
|
221
|
+
background: template.background.color,
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Overlay text layers if any
|
|
227
|
+
if (template.layers && template.layers.length > 0) {
|
|
228
|
+
const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale)
|
|
229
|
+
pipeline = pipeline.composite([{
|
|
230
|
+
input: Buffer.from(svgOverlay),
|
|
231
|
+
top: 0,
|
|
232
|
+
left: 0,
|
|
233
|
+
}])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (template.format === 'jpeg') {
|
|
237
|
+
return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer()
|
|
238
|
+
}
|
|
239
|
+
return await pipeline.png().toBuffer()
|
|
240
|
+
} catch {
|
|
241
|
+
warnSharpMissing()
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Path utility ───────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Compute the OG image path for a template and locale.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```ts
|
|
253
|
+
* ogImagePath("default", "de") // → "/og/default-de.png"
|
|
254
|
+
* ogImagePath("default") // → "/og/default.png"
|
|
255
|
+
* ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export function ogImagePath(
|
|
259
|
+
templateName: string,
|
|
260
|
+
locale?: string,
|
|
261
|
+
outDir = 'og',
|
|
262
|
+
format: 'png' | 'jpeg' = 'png',
|
|
263
|
+
): string {
|
|
264
|
+
const ext = format === 'jpeg' ? 'jpg' : 'png'
|
|
265
|
+
const suffix = locale ? `-${locale}` : ''
|
|
266
|
+
return `/${outDir}/${templateName}${suffix}.${ext}`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Vite plugin ────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* OG image generation Vite plugin.
|
|
273
|
+
*
|
|
274
|
+
* Generates Open Graph images at build time. In dev, generates on-demand.
|
|
275
|
+
* Requires `sharp` as an optional dependency.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* // vite.config.ts
|
|
280
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
281
|
+
*
|
|
282
|
+
* export default {
|
|
283
|
+
* plugins: [
|
|
284
|
+
* ogImagePlugin({
|
|
285
|
+
* locales: ["en", "de"],
|
|
286
|
+
* templates: [{
|
|
287
|
+
* name: "default",
|
|
288
|
+
* background: { color: "#0066ff" },
|
|
289
|
+
* layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
|
|
290
|
+
* }],
|
|
291
|
+
* }),
|
|
292
|
+
* ],
|
|
293
|
+
* }
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
export function ogImagePlugin(config: OgImagePluginConfig): Plugin {
|
|
297
|
+
const outDir = config.outDir ?? 'og'
|
|
298
|
+
let root = ''
|
|
299
|
+
let isBuild = false
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
name: 'pyreon-zero-og-image',
|
|
303
|
+
enforce: 'pre',
|
|
304
|
+
|
|
305
|
+
configResolved(resolvedConfig) {
|
|
306
|
+
root = resolvedConfig.root
|
|
307
|
+
isBuild = resolvedConfig.command === 'build'
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// Dev: generate on-demand
|
|
311
|
+
configureServer(server) {
|
|
312
|
+
const devCache = new Map<string, Uint8Array>()
|
|
313
|
+
|
|
314
|
+
server.middlewares.use(async (req, res, next) => {
|
|
315
|
+
const url = req.url ?? ''
|
|
316
|
+
if (!url.startsWith(`/${outDir}/`)) return next()
|
|
317
|
+
|
|
318
|
+
// Parse: /og/default-en.png → template=default, locale=en
|
|
319
|
+
const fileName = url.slice(outDir.length + 2) // strip /{outDir}/
|
|
320
|
+
const match = fileName.match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/)
|
|
321
|
+
if (!match) return next()
|
|
322
|
+
|
|
323
|
+
const [, templateName, locale, ext] = match
|
|
324
|
+
const template = config.templates.find((t) => t.name === templateName)
|
|
325
|
+
if (!template) return next()
|
|
326
|
+
|
|
327
|
+
const resolvedLocale = locale ?? config.locales?.[0] ?? 'en'
|
|
328
|
+
const cacheKey = `${templateName}:${resolvedLocale}`
|
|
329
|
+
|
|
330
|
+
let buffer = devCache.get(cacheKey)
|
|
331
|
+
if (!buffer) {
|
|
332
|
+
const result = await renderOgImage(template, resolvedLocale, root)
|
|
333
|
+
if (!result) return next()
|
|
334
|
+
buffer = result
|
|
335
|
+
devCache.set(cacheKey, result)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const contentType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png'
|
|
339
|
+
res.setHeader('Content-Type', contentType)
|
|
340
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
341
|
+
res.end(Buffer.from(buffer))
|
|
342
|
+
})
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// Build: generate all variants
|
|
346
|
+
async generateBundle() {
|
|
347
|
+
if (!isBuild) return
|
|
348
|
+
|
|
349
|
+
for (const template of config.templates) {
|
|
350
|
+
const locales = config.locales ?? [undefined]
|
|
351
|
+
const format = template.format ?? 'png'
|
|
352
|
+
const ext = format === 'jpeg' ? 'jpg' : 'png'
|
|
353
|
+
|
|
354
|
+
for (const locale of locales) {
|
|
355
|
+
// Validate background exists if it's a file path
|
|
356
|
+
if (typeof template.background === 'string') {
|
|
357
|
+
const bgPath = join(root, template.background)
|
|
358
|
+
if (!existsSync(bgPath)) {
|
|
359
|
+
// oxlint-disable-next-line no-console
|
|
360
|
+
console.warn(`[zero:og-image] Background not found: ${bgPath}`)
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const buffer = await renderOgImage(template, locale ?? 'en', root)
|
|
366
|
+
if (!buffer) continue
|
|
367
|
+
|
|
368
|
+
const suffix = locale ? `-${locale}` : ''
|
|
369
|
+
this.emitFile({
|
|
370
|
+
type: 'asset',
|
|
371
|
+
fileName: `${outDir}/${template.name}${suffix}.${ext}`,
|
|
372
|
+
source: buffer,
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface ZeroConfig {
|
|
|
75
75
|
isr?: ISRConfig
|
|
76
76
|
|
|
77
77
|
/** Deploy adapter. Default: "node" */
|
|
78
|
-
adapter?: 'node' | 'bun' | 'static'
|
|
78
|
+
adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify'
|
|
79
79
|
|
|
80
80
|
/** Base URL path. Default: "/" */
|
|
81
81
|
base?: string
|