@pyreon/zero 0.12.1 → 0.12.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/lib/actions.js +97 -0
- package/lib/actions.js.map +1 -0
- package/lib/ai.js +503 -0
- package/lib/ai.js.map +1 -0
- package/lib/api-routes.js +137 -0
- package/lib/api-routes.js.map +1 -0
- package/lib/compression.js +80 -0
- package/lib/compression.js.map +1 -0
- package/lib/cors.js +57 -0
- package/lib/cors.js.map +1 -0
- package/lib/csp.js +119 -0
- package/lib/csp.js.map +1 -0
- package/lib/env.js +217 -0
- package/lib/env.js.map +1 -0
- package/lib/favicon.js +424 -0
- package/lib/favicon.js.map +1 -0
- package/lib/i18n-routing.js +167 -0
- package/lib/i18n-routing.js.map +1 -0
- package/lib/index.js +1631 -179
- package/lib/index.js.map +1 -1
- package/lib/link.js +5 -0
- package/lib/link.js.map +1 -1
- package/lib/logger.js +78 -0
- package/lib/logger.js.map +1 -0
- package/lib/meta.js +336 -0
- package/lib/meta.js.map +1 -0
- package/lib/middleware.js +53 -0
- package/lib/middleware.js.map +1 -0
- package/lib/og-image.js +233 -0
- package/lib/og-image.js.map +1 -0
- package/lib/rate-limit.js +76 -0
- package/lib/rate-limit.js.map +1 -0
- package/lib/testing.js +179 -0
- package/lib/testing.js.map +1 -0
- package/lib/theme.js +11 -2
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +27 -24
- package/lib/types/actions.d.ts.map +1 -1
- package/lib/types/ai.d.ts +163 -0
- package/lib/types/ai.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +37 -33
- package/lib/types/api-routes.d.ts.map +1 -1
- package/lib/types/cache.d.ts +26 -22
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/client.d.ts +13 -9
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/compression.d.ts +14 -10
- package/lib/types/compression.d.ts.map +1 -1
- package/lib/types/config.d.ts +39 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/cors.d.ts +20 -16
- package/lib/types/cors.d.ts.map +1 -1
- package/lib/types/csp.d.ts +88 -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 +70 -24
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/font.d.ts +68 -65
- package/lib/types/font.d.ts.map +1 -1
- package/lib/types/i18n-routing.d.ts +43 -37
- package/lib/types/i18n-routing.d.ts.map +1 -1
- package/lib/types/image-plugin.d.ts +49 -45
- package/lib/types/image-plugin.d.ts.map +1 -1
- package/lib/types/image.d.ts +47 -36
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +1961 -46
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts +61 -56
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/logger.d.ts +57 -0
- package/lib/types/logger.d.ts.map +1 -0
- package/lib/types/meta.d.ts +180 -69
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/middleware.d.ts +8 -4
- package/lib/types/middleware.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +111 -0
- package/lib/types/og-image.d.ts.map +1 -0
- package/lib/types/rate-limit.d.ts +20 -16
- package/lib/types/rate-limit.d.ts.map +1 -1
- package/lib/types/script.d.ts +23 -19
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/seo.d.ts +47 -43
- package/lib/types/seo.d.ts.map +1 -1
- package/lib/types/testing.d.ts +64 -27
- package/lib/types/testing.d.ts.map +1 -1
- package/lib/types/theme.d.ts +22 -12
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +37 -12
- package/src/actions.ts +1 -3
- package/src/adapters/bun.ts +2 -0
- package/src/adapters/cloudflare.ts +84 -0
- package/src/adapters/index.ts +13 -1
- package/src/adapters/netlify.ts +86 -0
- package/src/adapters/node.ts +2 -0
- package/src/adapters/validate.ts +16 -0
- package/src/adapters/vercel.ts +86 -0
- package/src/ai.ts +623 -0
- package/src/compression.ts +19 -3
- package/src/csp.ts +207 -0
- package/src/entry-server.ts +28 -5
- package/src/env.ts +344 -0
- package/src/favicon.ts +221 -80
- package/src/index.ts +42 -2
- package/src/link.tsx +6 -0
- package/src/logger.ts +144 -0
- package/src/meta.tsx +124 -14
- package/src/og-image.ts +378 -0
- package/src/rate-limit.ts +11 -9
- package/src/theme.tsx +12 -1
- package/src/types.ts +1 -1
- package/src/vite-plugin.ts +5 -1
- package/lib/types/adapters/bun.d.ts +0 -6
- package/lib/types/adapters/bun.d.ts.map +0 -1
- package/lib/types/adapters/index.d.ts +0 -10
- package/lib/types/adapters/index.d.ts.map +0 -1
- package/lib/types/adapters/node.d.ts +0 -6
- package/lib/types/adapters/node.d.ts.map +0 -1
- package/lib/types/adapters/static.d.ts +0 -7
- package/lib/types/adapters/static.d.ts.map +0 -1
- package/lib/types/app.d.ts +0 -24
- package/lib/types/app.d.ts.map +0 -1
- package/lib/types/entry-server.d.ts +0 -37
- package/lib/types/entry-server.d.ts.map +0 -1
- package/lib/types/error-overlay.d.ts +0 -6
- package/lib/types/error-overlay.d.ts.map +0 -1
- package/lib/types/fs-router.d.ts +0 -47
- package/lib/types/fs-router.d.ts.map +0 -1
- package/lib/types/isr.d.ts +0 -9
- package/lib/types/isr.d.ts.map +0 -1
- package/lib/types/not-found.d.ts +0 -7
- package/lib/types/not-found.d.ts.map +0 -1
- package/lib/types/types.d.ts +0 -111
- package/lib/types/types.d.ts.map +0 -1
- package/lib/types/utils/use-intersection-observer.d.ts +0 -10
- package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
- package/lib/types/utils/with-headers.d.ts +0 -6
- package/lib/types/utils/with-headers.d.ts.map +0 -1
- package/lib/types/vite-plugin.d.ts +0 -17
- package/lib/types/vite-plugin.d.ts.map +0 -1
package/src/meta.tsx
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
+
import type { UseHeadInput } from '@pyreon/head'
|
|
2
3
|
import { useHead } from '@pyreon/head'
|
|
4
|
+
import type { FaviconPluginConfig } from './favicon'
|
|
5
|
+
import { faviconLinks } from './favicon'
|
|
3
6
|
import type { I18nRoutingConfig } from './i18n-routing'
|
|
4
7
|
import { extractLocaleFromPath } from './i18n-routing'
|
|
8
|
+
import { ogImagePath } from './og-image'
|
|
5
9
|
|
|
6
10
|
// ─── Meta component ────────────────────────────────────────────────────────
|
|
7
11
|
|
|
@@ -16,6 +20,10 @@ export interface MetaProps {
|
|
|
16
20
|
image?: string
|
|
17
21
|
/** Image alt text for accessibility. */
|
|
18
22
|
imageAlt?: string
|
|
23
|
+
/** Image width in pixels (og:image:width). Helps crawlers layout before loading. */
|
|
24
|
+
imageWidth?: number
|
|
25
|
+
/** Image height in pixels (og:image:height). */
|
|
26
|
+
imageHeight?: number
|
|
19
27
|
/** Open Graph type. Default: "website" */
|
|
20
28
|
type?: 'website' | 'article' | 'product' | 'profile'
|
|
21
29
|
/** Site name for og:site_name. */
|
|
@@ -32,6 +40,8 @@ export interface MetaProps {
|
|
|
32
40
|
alternateLocales?: Array<{ locale: string; url: string }>
|
|
33
41
|
/** Robots directives. Default: "index, follow" */
|
|
34
42
|
robots?: string
|
|
43
|
+
/** Convenience: set `true` to emit `noindex, nofollow`. Overrides `robots`. */
|
|
44
|
+
noIndex?: boolean
|
|
35
45
|
/** Published time (ISO 8601) for article type. */
|
|
36
46
|
publishedTime?: string
|
|
37
47
|
/** Modified time (ISO 8601) for article type. */
|
|
@@ -44,6 +54,19 @@ export interface MetaProps {
|
|
|
44
54
|
jsonLd?: Record<string, unknown>
|
|
45
55
|
/** Additional custom meta tags. */
|
|
46
56
|
extra?: Array<{ name?: string; property?: string; content: string }>
|
|
57
|
+
/**
|
|
58
|
+
* Open Graph video URL. Also sets og:video:type if the URL ends with
|
|
59
|
+
* a known extension (.mp4, .webm).
|
|
60
|
+
*/
|
|
61
|
+
video?: string
|
|
62
|
+
/** Video width in pixels. */
|
|
63
|
+
videoWidth?: number
|
|
64
|
+
/** Video height in pixels. */
|
|
65
|
+
videoHeight?: number
|
|
66
|
+
/**
|
|
67
|
+
* Open Graph audio URL.
|
|
68
|
+
*/
|
|
69
|
+
audio?: string
|
|
47
70
|
/**
|
|
48
71
|
* I18n routing config — when provided, auto-generates hreflang alternate
|
|
49
72
|
* links for all locales based on the current path.
|
|
@@ -52,6 +75,22 @@ export interface MetaProps {
|
|
|
52
75
|
i18n?: I18nRoutingConfig
|
|
53
76
|
/** Base URL for building absolute hreflang URLs. e.g. "https://example.com" */
|
|
54
77
|
origin?: string
|
|
78
|
+
/**
|
|
79
|
+
* Favicon plugin config — when provided, injects locale-aware favicon
|
|
80
|
+
* `<link>` tags into `<head>`. Uses the current locale to select
|
|
81
|
+
* the correct favicon set.
|
|
82
|
+
*/
|
|
83
|
+
favicon?: FaviconPluginConfig
|
|
84
|
+
/**
|
|
85
|
+
* OG image template name — auto-resolves to the correct locale-specific
|
|
86
|
+
* OG image path generated by `ogImagePlugin`.
|
|
87
|
+
* Sets both `og:image` and `twitter:image` unless `image` is also provided.
|
|
88
|
+
*/
|
|
89
|
+
ogTemplate?: string
|
|
90
|
+
/** Output directory for OG images. Default: "og" */
|
|
91
|
+
ogImageDir?: string
|
|
92
|
+
/** OG image format. Default: "png" */
|
|
93
|
+
ogImageFormat?: 'png' | 'jpeg'
|
|
55
94
|
children?: VNodeChild
|
|
56
95
|
}
|
|
57
96
|
|
|
@@ -83,26 +122,53 @@ export function Meta(props: MetaProps): VNodeChild {
|
|
|
83
122
|
// If title or description are reactive accessors, pass a getter to useHead
|
|
84
123
|
// so it re-evaluates when the signals change.
|
|
85
124
|
if (hasReactiveTitle || hasReactiveDescription) {
|
|
86
|
-
useHead((
|
|
125
|
+
useHead((): UseHeadInput => {
|
|
87
126
|
const title = resolveStr(props.title)
|
|
88
127
|
const description = resolveStr(props.description)
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
128
|
+
const resolved = { ...props, title, description } as Parameters<typeof buildMetaTags>[0]
|
|
129
|
+
const tags = buildMetaTags(resolved)
|
|
130
|
+
const input: UseHeadInput = { meta: tags.meta, link: tags.link, script: tags.script }
|
|
131
|
+
if (title) input.title = title
|
|
132
|
+
return input
|
|
133
|
+
})
|
|
92
134
|
} else {
|
|
93
135
|
const title = resolveStr(props.title)
|
|
94
136
|
const description = resolveStr(props.description)
|
|
95
|
-
const
|
|
96
|
-
|
|
137
|
+
const resolved = { ...props, title, description } as Parameters<typeof buildMetaTags>[0]
|
|
138
|
+
const tags = buildMetaTags(resolved)
|
|
139
|
+
const input: UseHeadInput = { meta: tags.meta, link: tags.link, script: tags.script }
|
|
140
|
+
if (title) input.title = title
|
|
141
|
+
useHead(input)
|
|
97
142
|
}
|
|
98
143
|
|
|
99
144
|
return props.children ?? null
|
|
100
145
|
}
|
|
101
146
|
|
|
147
|
+
interface MetaTagEntry {
|
|
148
|
+
name?: string
|
|
149
|
+
property?: string
|
|
150
|
+
content: string
|
|
151
|
+
[key: string]: string | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface LinkTagEntry {
|
|
155
|
+
rel: string
|
|
156
|
+
href?: string
|
|
157
|
+
hreflang?: string
|
|
158
|
+
type?: string
|
|
159
|
+
sizes?: string
|
|
160
|
+
[key: string]: string | undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface ScriptTagEntry {
|
|
164
|
+
type: string
|
|
165
|
+
children: string
|
|
166
|
+
}
|
|
167
|
+
|
|
102
168
|
interface MetaTags {
|
|
103
|
-
meta:
|
|
104
|
-
link:
|
|
105
|
-
script:
|
|
169
|
+
meta: MetaTagEntry[]
|
|
170
|
+
link: LinkTagEntry[]
|
|
171
|
+
script: ScriptTagEntry[]
|
|
106
172
|
}
|
|
107
173
|
|
|
108
174
|
export function buildMetaTags(
|
|
@@ -111,19 +177,34 @@ export function buildMetaTags(
|
|
|
111
177
|
description?: string
|
|
112
178
|
},
|
|
113
179
|
): MetaTags {
|
|
114
|
-
const meta:
|
|
115
|
-
const link:
|
|
116
|
-
const script:
|
|
180
|
+
const meta: MetaTagEntry[] = []
|
|
181
|
+
const link: LinkTagEntry[] = []
|
|
182
|
+
const script: ScriptTagEntry[] = []
|
|
117
183
|
|
|
118
184
|
const {
|
|
119
|
-
title, description, canonical,
|
|
185
|
+
title, description, canonical, imageAlt, imageWidth, imageHeight,
|
|
120
186
|
type = 'website', siteName,
|
|
121
187
|
twitterCard = 'summary_large_image', twitterSite, twitterCreator,
|
|
122
188
|
locale = 'en_US', alternateLocales,
|
|
123
|
-
robots = 'index, follow',
|
|
124
189
|
publishedTime, modifiedTime, author, tags, jsonLd, extra,
|
|
190
|
+
video, videoWidth, videoHeight, audio,
|
|
191
|
+
favicon, ogTemplate, ogImageDir, ogImageFormat,
|
|
125
192
|
} = props
|
|
126
193
|
|
|
194
|
+
// noIndex convenience overrides robots
|
|
195
|
+
const robots = props.noIndex ? 'noindex, nofollow' : (props.robots ?? 'index, follow')
|
|
196
|
+
|
|
197
|
+
// Resolve image: explicit `image` prop takes precedence over `ogTemplate`
|
|
198
|
+
const image = props.image ?? (
|
|
199
|
+
ogTemplate
|
|
200
|
+
? ogImagePath(ogTemplate, locale !== 'en_US' ? locale : undefined, ogImageDir, ogImageFormat)
|
|
201
|
+
: undefined
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// Auto-resolve image dimensions for OG template images
|
|
205
|
+
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : undefined)
|
|
206
|
+
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : undefined)
|
|
207
|
+
|
|
127
208
|
if (description) meta.push({ name: 'description', content: description })
|
|
128
209
|
if (robots) meta.push({ name: 'robots', content: robots })
|
|
129
210
|
if (author) meta.push({ name: 'author', content: author })
|
|
@@ -133,10 +214,27 @@ export function buildMetaTags(
|
|
|
133
214
|
if (canonical) meta.push({ property: 'og:url', content: canonical })
|
|
134
215
|
if (image) meta.push({ property: 'og:image', content: image })
|
|
135
216
|
if (imageAlt) meta.push({ property: 'og:image:alt', content: imageAlt })
|
|
217
|
+
if (resolvedImageWidth) meta.push({ property: 'og:image:width', content: String(resolvedImageWidth) })
|
|
218
|
+
if (resolvedImageHeight) meta.push({ property: 'og:image:height', content: String(resolvedImageHeight) })
|
|
136
219
|
meta.push({ property: 'og:type', content: type })
|
|
137
220
|
if (siteName) meta.push({ property: 'og:site_name', content: siteName })
|
|
138
221
|
meta.push({ property: 'og:locale', content: locale })
|
|
139
222
|
|
|
223
|
+
// Video
|
|
224
|
+
if (video) {
|
|
225
|
+
meta.push({ property: 'og:video', content: video })
|
|
226
|
+
if (videoWidth) meta.push({ property: 'og:video:width', content: String(videoWidth) })
|
|
227
|
+
if (videoHeight) meta.push({ property: 'og:video:height', content: String(videoHeight) })
|
|
228
|
+
// Auto-detect video type from extension
|
|
229
|
+
if (video.endsWith('.mp4')) meta.push({ property: 'og:video:type', content: 'video/mp4' })
|
|
230
|
+
else if (video.endsWith('.webm')) meta.push({ property: 'og:video:type', content: 'video/webm' })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Audio
|
|
234
|
+
if (audio) {
|
|
235
|
+
meta.push({ property: 'og:audio', content: audio })
|
|
236
|
+
}
|
|
237
|
+
|
|
140
238
|
if (type === 'article') {
|
|
141
239
|
if (publishedTime) meta.push({ property: 'article:published_time', content: publishedTime })
|
|
142
240
|
if (modifiedTime) meta.push({ property: 'article:modified_time', content: modifiedTime })
|
|
@@ -206,5 +304,17 @@ export function buildMetaTags(
|
|
|
206
304
|
})
|
|
207
305
|
}
|
|
208
306
|
|
|
307
|
+
// Favicon: inject locale-aware favicon links
|
|
308
|
+
if (favicon) {
|
|
309
|
+
const faviconLocale = locale !== 'en_US' ? locale : undefined
|
|
310
|
+
for (const fl of faviconLinks(faviconLocale, favicon)) {
|
|
311
|
+
link.push(fl as LinkTagEntry)
|
|
312
|
+
}
|
|
313
|
+
// Theme color meta from favicon config
|
|
314
|
+
if (favicon.themeColor) {
|
|
315
|
+
meta.push({ name: 'theme-color', content: favicon.themeColor })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
209
319
|
return { meta, link, script }
|
|
210
320
|
}
|
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/rate-limit.ts
CHANGED
|
@@ -51,18 +51,17 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
51
51
|
|
|
52
52
|
const windowMs = windowSec * 1000
|
|
53
53
|
const store = new Map<string, RateLimitEntry>()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const MAX_STORE_SIZE = 10000
|
|
55
|
+
let lastCleanup = Date.now()
|
|
56
|
+
|
|
57
|
+
// Inline cleanup — runs during request processing, no setInterval needed.
|
|
58
|
+
// Evicts expired entries when store exceeds half capacity or on window boundary.
|
|
59
|
+
function cleanupIfNeeded(now: number) {
|
|
60
|
+
if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return
|
|
61
|
+
lastCleanup = now
|
|
58
62
|
for (const [key, entry] of store) {
|
|
59
63
|
if (entry.resetAt <= now) store.delete(key)
|
|
60
64
|
}
|
|
61
|
-
}, windowMs)
|
|
62
|
-
|
|
63
|
-
// Allow GC to clean up the interval
|
|
64
|
-
if (typeof cleanupInterval === 'object' && 'unref' in cleanupInterval) {
|
|
65
|
-
cleanupInterval.unref()
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
return (ctx: MiddlewareContext) => {
|
|
@@ -72,6 +71,9 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
72
71
|
|
|
73
72
|
const key = keyFn(ctx)
|
|
74
73
|
const now = Date.now()
|
|
74
|
+
|
|
75
|
+
cleanupIfNeeded(now)
|
|
76
|
+
|
|
75
77
|
let entry = store.get(key)
|
|
76
78
|
|
|
77
79
|
if (!entry || entry.resetAt <= now) {
|
package/src/theme.tsx
CHANGED
|
@@ -17,11 +17,22 @@ const STORAGE_KEY = 'zero-theme'
|
|
|
17
17
|
/** Reactive theme signal. */
|
|
18
18
|
export const theme = signal<Theme>('system')
|
|
19
19
|
|
|
20
|
+
/** SSR fallback when system preference can't be detected. Default: 'light'. */
|
|
21
|
+
let _ssrDefault: 'light' | 'dark' = 'light'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set the default theme for SSR (when `matchMedia` is unavailable).
|
|
25
|
+
* Call once at server startup before rendering.
|
|
26
|
+
*/
|
|
27
|
+
export function setSSRThemeDefault(value: 'light' | 'dark'): void {
|
|
28
|
+
_ssrDefault = value
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
/** Computed resolved theme (what's actually applied). */
|
|
21
32
|
export function resolvedTheme(): 'light' | 'dark' {
|
|
22
33
|
const t = theme()
|
|
23
34
|
if (t === 'system') {
|
|
24
|
-
if (typeof window === 'undefined') return
|
|
35
|
+
if (typeof window === 'undefined') return _ssrDefault
|
|
25
36
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
26
37
|
}
|
|
27
38
|
return t
|
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
|
package/src/vite-plugin.ts
CHANGED
|
@@ -126,7 +126,11 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
126
126
|
(handled) => {
|
|
127
127
|
if (!handled) next();
|
|
128
128
|
},
|
|
129
|
-
() =>
|
|
129
|
+
(err) => {
|
|
130
|
+
// oxlint-disable-next-line no-console
|
|
131
|
+
console.error('[zero] Error in 404 handler:', err);
|
|
132
|
+
next();
|
|
133
|
+
},
|
|
130
134
|
);
|
|
131
135
|
});
|
|
132
136
|
|