@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.
Files changed (140) hide show
  1. package/lib/actions.js +97 -0
  2. package/lib/actions.js.map +1 -0
  3. package/lib/ai.js +503 -0
  4. package/lib/ai.js.map +1 -0
  5. package/lib/api-routes.js +137 -0
  6. package/lib/api-routes.js.map +1 -0
  7. package/lib/compression.js +80 -0
  8. package/lib/compression.js.map +1 -0
  9. package/lib/cors.js +57 -0
  10. package/lib/cors.js.map +1 -0
  11. package/lib/csp.js +119 -0
  12. package/lib/csp.js.map +1 -0
  13. package/lib/env.js +217 -0
  14. package/lib/env.js.map +1 -0
  15. package/lib/favicon.js +424 -0
  16. package/lib/favicon.js.map +1 -0
  17. package/lib/i18n-routing.js +167 -0
  18. package/lib/i18n-routing.js.map +1 -0
  19. package/lib/index.js +1631 -179
  20. package/lib/index.js.map +1 -1
  21. package/lib/link.js +5 -0
  22. package/lib/link.js.map +1 -1
  23. package/lib/logger.js +78 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/meta.js +336 -0
  26. package/lib/meta.js.map +1 -0
  27. package/lib/middleware.js +53 -0
  28. package/lib/middleware.js.map +1 -0
  29. package/lib/og-image.js +233 -0
  30. package/lib/og-image.js.map +1 -0
  31. package/lib/rate-limit.js +76 -0
  32. package/lib/rate-limit.js.map +1 -0
  33. package/lib/testing.js +179 -0
  34. package/lib/testing.js.map +1 -0
  35. package/lib/theme.js +11 -2
  36. package/lib/theme.js.map +1 -1
  37. package/lib/types/actions.d.ts +27 -24
  38. package/lib/types/actions.d.ts.map +1 -1
  39. package/lib/types/ai.d.ts +163 -0
  40. package/lib/types/ai.d.ts.map +1 -0
  41. package/lib/types/api-routes.d.ts +37 -33
  42. package/lib/types/api-routes.d.ts.map +1 -1
  43. package/lib/types/cache.d.ts +26 -22
  44. package/lib/types/cache.d.ts.map +1 -1
  45. package/lib/types/client.d.ts +13 -9
  46. package/lib/types/client.d.ts.map +1 -1
  47. package/lib/types/compression.d.ts +14 -10
  48. package/lib/types/compression.d.ts.map +1 -1
  49. package/lib/types/config.d.ts +39 -4
  50. package/lib/types/config.d.ts.map +1 -1
  51. package/lib/types/cors.d.ts +20 -16
  52. package/lib/types/cors.d.ts.map +1 -1
  53. package/lib/types/csp.d.ts +88 -0
  54. package/lib/types/csp.d.ts.map +1 -0
  55. package/lib/types/env.d.ts +118 -0
  56. package/lib/types/env.d.ts.map +1 -0
  57. package/lib/types/favicon.d.ts +70 -24
  58. package/lib/types/favicon.d.ts.map +1 -1
  59. package/lib/types/font.d.ts +68 -65
  60. package/lib/types/font.d.ts.map +1 -1
  61. package/lib/types/i18n-routing.d.ts +43 -37
  62. package/lib/types/i18n-routing.d.ts.map +1 -1
  63. package/lib/types/image-plugin.d.ts +49 -45
  64. package/lib/types/image-plugin.d.ts.map +1 -1
  65. package/lib/types/image.d.ts +47 -36
  66. package/lib/types/image.d.ts.map +1 -1
  67. package/lib/types/index.d.ts +1961 -46
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/link.d.ts +61 -56
  70. package/lib/types/link.d.ts.map +1 -1
  71. package/lib/types/logger.d.ts +57 -0
  72. package/lib/types/logger.d.ts.map +1 -0
  73. package/lib/types/meta.d.ts +180 -69
  74. package/lib/types/meta.d.ts.map +1 -1
  75. package/lib/types/middleware.d.ts +8 -4
  76. package/lib/types/middleware.d.ts.map +1 -1
  77. package/lib/types/og-image.d.ts +111 -0
  78. package/lib/types/og-image.d.ts.map +1 -0
  79. package/lib/types/rate-limit.d.ts +20 -16
  80. package/lib/types/rate-limit.d.ts.map +1 -1
  81. package/lib/types/script.d.ts +23 -19
  82. package/lib/types/script.d.ts.map +1 -1
  83. package/lib/types/seo.d.ts +47 -43
  84. package/lib/types/seo.d.ts.map +1 -1
  85. package/lib/types/testing.d.ts +64 -27
  86. package/lib/types/testing.d.ts.map +1 -1
  87. package/lib/types/theme.d.ts +22 -12
  88. package/lib/types/theme.d.ts.map +1 -1
  89. package/package.json +37 -12
  90. package/src/actions.ts +1 -3
  91. package/src/adapters/bun.ts +2 -0
  92. package/src/adapters/cloudflare.ts +84 -0
  93. package/src/adapters/index.ts +13 -1
  94. package/src/adapters/netlify.ts +86 -0
  95. package/src/adapters/node.ts +2 -0
  96. package/src/adapters/validate.ts +16 -0
  97. package/src/adapters/vercel.ts +86 -0
  98. package/src/ai.ts +623 -0
  99. package/src/compression.ts +19 -3
  100. package/src/csp.ts +207 -0
  101. package/src/entry-server.ts +28 -5
  102. package/src/env.ts +344 -0
  103. package/src/favicon.ts +221 -80
  104. package/src/index.ts +42 -2
  105. package/src/link.tsx +6 -0
  106. package/src/logger.ts +144 -0
  107. package/src/meta.tsx +124 -14
  108. package/src/og-image.ts +378 -0
  109. package/src/rate-limit.ts +11 -9
  110. package/src/theme.tsx +12 -1
  111. package/src/types.ts +1 -1
  112. package/src/vite-plugin.ts +5 -1
  113. package/lib/types/adapters/bun.d.ts +0 -6
  114. package/lib/types/adapters/bun.d.ts.map +0 -1
  115. package/lib/types/adapters/index.d.ts +0 -10
  116. package/lib/types/adapters/index.d.ts.map +0 -1
  117. package/lib/types/adapters/node.d.ts +0 -6
  118. package/lib/types/adapters/node.d.ts.map +0 -1
  119. package/lib/types/adapters/static.d.ts +0 -7
  120. package/lib/types/adapters/static.d.ts.map +0 -1
  121. package/lib/types/app.d.ts +0 -24
  122. package/lib/types/app.d.ts.map +0 -1
  123. package/lib/types/entry-server.d.ts +0 -37
  124. package/lib/types/entry-server.d.ts.map +0 -1
  125. package/lib/types/error-overlay.d.ts +0 -6
  126. package/lib/types/error-overlay.d.ts.map +0 -1
  127. package/lib/types/fs-router.d.ts +0 -47
  128. package/lib/types/fs-router.d.ts.map +0 -1
  129. package/lib/types/isr.d.ts +0 -9
  130. package/lib/types/isr.d.ts.map +0 -1
  131. package/lib/types/not-found.d.ts +0 -7
  132. package/lib/types/not-found.d.ts.map +0 -1
  133. package/lib/types/types.d.ts +0 -111
  134. package/lib/types/types.d.ts.map +0 -1
  135. package/lib/types/utils/use-intersection-observer.d.ts +0 -10
  136. package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
  137. package/lib/types/utils/with-headers.d.ts +0 -6
  138. package/lib/types/utils/with-headers.d.ts.map +0 -1
  139. package/lib/types/vite-plugin.d.ts +0 -17
  140. 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 tags = buildMetaTags({ ...props, title, description } as any)
90
- return { title, meta: tags.meta, link: tags.link, script: tags.script }
91
- }) as any)
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 tags = buildMetaTags({ ...props, title, description } as any)
96
- useHead({ title, meta: tags.meta, link: tags.link, script: tags.script } as any)
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: Array<Record<string, string>>
104
- link: Array<Record<string, string>>
105
- script: Array<{ type: string; children: string }>
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: Array<Record<string, string>> = []
115
- const link: Array<Record<string, string>> = []
116
- const script: Array<{ type: string; children: string }> = []
180
+ const meta: MetaTagEntry[] = []
181
+ const link: LinkTagEntry[] = []
182
+ const script: ScriptTagEntry[] = []
117
183
 
118
184
  const {
119
- title, description, canonical, image, imageAlt,
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
  }
@@ -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, '&amp;')
122
+ .replace(/</g, '&lt;')
123
+ .replace(/>/g, '&gt;')
124
+ .replace(/"/g, '&quot;')
125
+ .replace(/'/g, '&apos;')
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
- // Periodic cleanup of expired entries
56
- const cleanupInterval = setInterval(() => {
57
- const now = Date.now()
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 'dark'
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
@@ -126,7 +126,11 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
126
126
  (handled) => {
127
127
  if (!handled) next();
128
128
  },
129
- () => next(), // On error, fall through to Vite's default handling
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
 
@@ -1,6 +0,0 @@
1
- import type { Adapter } from '../types';
2
- /**
3
- * Bun adapter — generates a standalone Bun.serve() entry.
4
- */
5
- export declare function bunAdapter(): Adapter;
6
- //# sourceMappingURL=bun.d.ts.map