@pyreon/zero 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/og-image.ts DELETED
@@ -1,378 +0,0 @@
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[Pyreon] 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 w = 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
- w += fontSize * 1.0
162
- } else if (code <= 0x7E && 'iljft!|:;.,\''.includes(s[i]!)) {
163
- // Narrow Latin characters
164
- w += fontSize * 0.35
165
- } else {
166
- // Regular Latin characters
167
- w += fontSize * 0.55
168
- }
169
- }
170
- return w
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(`[Pyreon] 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 DELETED
@@ -1,140 +0,0 @@
1
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
-
3
- // ─── Rate limiting middleware ───────────────────────────────────────────────
4
-
5
- export interface RateLimitConfig {
6
- /** Maximum requests per window. Default: `100` */
7
- max?: number
8
- /** Time window in seconds. Default: `60` */
9
- window?: number
10
- /** Function to extract the client identifier. Default: IP from headers. */
11
- keyFn?: (ctx: MiddlewareContext) => string
12
- /** Custom response when rate limited. */
13
- onLimit?: (ctx: MiddlewareContext) => Response
14
- /** URL patterns to rate limit (glob-style). Default: all paths. */
15
- include?: string[]
16
- /** URL patterns to exclude from rate limiting. */
17
- exclude?: string[]
18
- }
19
-
20
- interface RateLimitEntry {
21
- count: number
22
- resetAt: number
23
- }
24
-
25
- /**
26
- * Rate limiting middleware — limits requests per client within a time window.
27
- * Uses an in-memory store (suitable for single-instance deployments).
28
- *
29
- * @example
30
- * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
31
- *
32
- * // 100 requests per minute (default)
33
- * rateLimitMiddleware()
34
- *
35
- * // Strict API rate limiting
36
- * rateLimitMiddleware({
37
- * max: 20,
38
- * window: 60,
39
- * include: ["/api/*"],
40
- * })
41
- */
42
- export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
43
- const {
44
- max = 100,
45
- window: windowSec = 60,
46
- keyFn = defaultKeyFn,
47
- onLimit,
48
- include,
49
- exclude,
50
- } = config
51
-
52
- const windowMs = windowSec * 1000
53
- const store = new Map<string, RateLimitEntry>()
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
62
- for (const [key, entry] of store) {
63
- if (entry.resetAt <= now) store.delete(key)
64
- }
65
- // HARD cap. The expired-sweep above only removes entries whose
66
- // window has elapsed. An attacker flooding unique keys WITHIN one
67
- // window (spoofed `X-Forwarded-For` / proxy header — `defaultKeyFn`
68
- // trusts request headers) produces only fresh entries, so the sweep
69
- // frees nothing and `store.set` grows the Map without bound — an
70
- // unauthenticated memory-exhaustion DoS. `MAX_STORE_SIZE` was a
71
- // declared constant used ONLY as a sweep trigger, never enforced.
72
- // Map preserves insertion order, so evicting from the front drops
73
- // the oldest trackers first (acceptable: an evicted attacker key
74
- // simply gets a fresh window — no bypass of legitimate limits since
75
- // a real client re-inserts and is immediately re-tracked).
76
- while (store.size > MAX_STORE_SIZE) {
77
- const oldest = store.keys().next().value
78
- if (oldest === undefined) break
79
- store.delete(oldest)
80
- }
81
- }
82
-
83
- return (ctx: MiddlewareContext) => {
84
- // Check include/exclude patterns
85
- if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return
86
- if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return
87
-
88
- const key = keyFn(ctx)
89
- const now = Date.now()
90
-
91
- cleanupIfNeeded(now)
92
-
93
- let entry = store.get(key)
94
-
95
- if (!entry || entry.resetAt <= now) {
96
- entry = { count: 0, resetAt: now + windowMs }
97
- store.set(key, entry)
98
- }
99
-
100
- entry.count++
101
- const remaining = Math.max(0, max - entry.count)
102
- const resetSeconds = Math.ceil((entry.resetAt - now) / 1000)
103
-
104
- // Set rate limit headers on all responses
105
- ctx.headers.set('X-RateLimit-Limit', String(max))
106
- ctx.headers.set('X-RateLimit-Remaining', String(remaining))
107
- ctx.headers.set('X-RateLimit-Reset', String(resetSeconds))
108
-
109
- if (entry.count > max) {
110
- if (onLimit) return onLimit(ctx)
111
-
112
- return new Response(JSON.stringify({ error: 'Too many requests' }), {
113
- status: 429,
114
- headers: {
115
- 'Content-Type': 'application/json',
116
- 'Retry-After': String(resetSeconds),
117
- 'X-RateLimit-Limit': String(max),
118
- 'X-RateLimit-Remaining': '0',
119
- 'X-RateLimit-Reset': String(resetSeconds),
120
- },
121
- })
122
- }
123
- }
124
- }
125
-
126
- function defaultKeyFn(ctx: MiddlewareContext): string {
127
- return (
128
- ctx.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
129
- ctx.req.headers.get('x-real-ip') ??
130
- 'unknown'
131
- )
132
- }
133
-
134
- /** Simple glob matching for path patterns. Supports trailing `*`. */
135
- function matchSimpleGlob(pattern: string, path: string): boolean {
136
- if (pattern.endsWith('/*')) {
137
- return path.startsWith(pattern.slice(0, -1))
138
- }
139
- return pattern === path
140
- }