@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
@@ -1,533 +0,0 @@
1
- import { createContext } from '@pyreon/core'
2
- import { signal } from '@pyreon/reactivity'
3
- import type { Plugin } from 'vite'
4
- import type { FileRoute } from './types'
5
-
6
- // ─── Localized routing ─────────────────────────────────────────────────────
7
- //
8
- // Adds locale-prefixed routes to Zero's file-system router (PR H of the SSG
9
- // roadmap). Two complementary halves:
10
- //
11
- // 1. **Build-time route duplication** — `expandRoutesForLocales(routes, config)`
12
- // fans every `FileRoute` into per-locale variants according to the
13
- // configured `strategy`. Called from `vite-plugin.ts`'s virtual-routes
14
- // load AND `ssg-plugin.ts`'s pre-render path expansion. Wired via the
15
- // `i18n?: I18nRoutingConfig` field on `ZeroConfig`.
16
- //
17
- // 2. **Request-time locale detection** — the `i18nRouting()` Vite plugin
18
- // below attaches a middleware that reads `Accept-Language` / cookies,
19
- // sets the `localeSignal` for `useLocale()`, and redirects root
20
- // requests to the detected locale. Independent from (1) — `i18nRouting()`
21
- // only handles middleware; route duplication happens via
22
- // `expandRoutesForLocales` regardless of whether this plugin is mounted.
23
- //
24
- // Examples (with `locales: ["en","de","cs"]`, `defaultLocale: "en"`):
25
- // - `prefix-except-default` (default): `/about` (en, unprefixed) +
26
- // `/de/about`, `/cs/about`. Best for SEO-on-default-locale apps.
27
- // - `prefix`: `/en/about`, `/de/about`, `/cs/about`. Every URL
28
- // self-identifies its locale.
29
- //
30
- // Usage:
31
- // // zero.config.ts
32
- // import { defineConfig, i18nRouting } from "@pyreon/zero"
33
- // export default defineConfig({
34
- // i18n: { locales: ["en","de","cs"], defaultLocale: "en" },
35
- // plugins: [i18nRouting({ locales: ["en","de","cs"], defaultLocale: "en" })],
36
- // })
37
-
38
- export interface I18nRoutingConfig {
39
- /** Supported locales. e.g. ["en", "de", "cs"] */
40
- locales: string[]
41
- /** Default locale — served without prefix (/ instead of /en/). */
42
- defaultLocale: string
43
- /** Redirect root to detected locale. Default: true */
44
- detectLocale?: boolean
45
- /** Cookie name to persist locale preference. Default: "locale" */
46
- cookieName?: string
47
- /** URL strategy. Default: "prefix-except-default" */
48
- strategy?: 'prefix' | 'prefix-except-default'
49
- }
50
-
51
- export interface LocaleContext {
52
- /** Current locale code. e.g. "en", "de" */
53
- locale: string
54
- /** All supported locales. */
55
- locales: string[]
56
- /** Default locale. */
57
- defaultLocale: string
58
- /** Build a localized path. e.g. localePath("/about", "de") → "/de/about" */
59
- localePath: (path: string, locale?: string) => string
60
- /** Get hreflang alternates for the current path. */
61
- alternates: () => Array<{ locale: string; url: string }>
62
- }
63
-
64
- /**
65
- * Detect preferred locale from Accept-Language header.
66
- */
67
- export function detectLocaleFromHeader(
68
- acceptLanguage: string | null | undefined,
69
- locales: string[],
70
- defaultLocale: string,
71
- ): string {
72
- if (!acceptLanguage) return defaultLocale
73
-
74
- // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8
75
- const preferred = acceptLanguage
76
- .split(',')
77
- .map((part) => {
78
- const [lang, q] = part.trim().split(';q=')
79
- return {
80
- lang: lang?.split('-')[0]?.toLowerCase() ?? '',
81
- quality: q ? Number.parseFloat(q) : 1,
82
- }
83
- })
84
- .sort((a, b) => b.quality - a.quality)
85
-
86
- for (const { lang } of preferred) {
87
- if (locales.includes(lang)) return lang
88
- }
89
-
90
- return defaultLocale
91
- }
92
-
93
- /**
94
- * Extract locale from a URL path.
95
- * Returns { locale, pathWithoutLocale }.
96
- */
97
- export function extractLocaleFromPath(
98
- path: string,
99
- locales: string[],
100
- defaultLocale: string,
101
- ): { locale: string; pathWithoutLocale: string } {
102
- const segments = path.split('/').filter(Boolean)
103
- const firstSegment = segments[0]?.toLowerCase()
104
-
105
- if (firstSegment && locales.includes(firstSegment)) {
106
- return {
107
- locale: firstSegment,
108
- pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',
109
- }
110
- }
111
-
112
- return { locale: defaultLocale, pathWithoutLocale: path }
113
- }
114
-
115
- /**
116
- * Build a localized path.
117
- */
118
- export function buildLocalePath(
119
- path: string,
120
- locale: string,
121
- defaultLocale: string,
122
- strategy: 'prefix' | 'prefix-except-default',
123
- ): string {
124
- const clean = path === '/' ? '' : path
125
- if (strategy === 'prefix-except-default' && locale === defaultLocale) {
126
- return path
127
- }
128
- return `/${locale}${clean}`
129
- }
130
-
131
- /**
132
- * Fan a `FileRoute[]` into per-locale duplicates so the file-system router
133
- * knows about every localized URL pattern at build time. PR H — was the
134
- * missing half of the i18n story before this PR (the `i18nRouting()` Vite
135
- * plugin only handled request-time locale detection; routes themselves
136
- * were never duplicated, so static-host SSG outputs and SSR matching had
137
- * no `/de/about` / `/cs/about` records to render against).
138
- *
139
- * Strategy semantics:
140
- *
141
- * - **`prefix-except-default`** (default): the default locale's routes
142
- * keep their original `urlPath` unchanged (`/about` stays `/about`); all
143
- * non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
144
- * SEO-on-default-locale apps — search engines see canonical URLs at
145
- * `/about` while non-default speakers get explicit prefixes.
146
- *
147
- * - **`prefix`**: every locale gets its own prefix, including the default
148
- * (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
149
- * `/de` / `/cs`. Better when no locale is "primary" — every URL
150
- * self-identifies its locale.
151
- *
152
- * Layouts, error boundaries, loading components, and 404 pages duplicate
153
- * along with their pages — same source file (same `filePath`), new
154
- * locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
155
- * from the expanded array therefore has one fully-formed subtree per
156
- * locale, so layout matching, dynamic params (`[id]` → `:id`), and
157
- * catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
158
- * the locale prefix — no special cases.
159
- *
160
- * `getStaticPaths` composition (for SSG): each duplicate route inherits
161
- * the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
162
- * step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
163
- * → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
164
- * (or all six prefixed forms under `'prefix'` strategy). Cardinality
165
- * compounds, which is by design — `ssg.concurrency` (PR D) limits
166
- * in-flight renders independent of route count.
167
- *
168
- * No-op when `config.locales` is empty or contains only the default
169
- * locale (prefix-except-default strategy with no other locales) — returns
170
- * the input array unchanged. Always return a fresh array on duplication
171
- * so callers don't accidentally mutate cached input.
172
- *
173
- * Reference: the helper is called from `vite-plugin.ts`'s virtual route
174
- * module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
175
- * isolation — duplication is a pure transform on FileRoute[] with no
176
- * filesystem or network side effects.
177
- */
178
- export function expandRoutesForLocales(
179
- routes: FileRoute[],
180
- config: I18nRoutingConfig,
181
- ): FileRoute[] {
182
- const strategy = config.strategy ?? 'prefix-except-default'
183
- const { locales, defaultLocale } = config
184
-
185
- // Cheap no-op guards. Empty `locales` would otherwise produce an empty
186
- // route array, killing the app silently.
187
- if (locales.length === 0) return routes
188
-
189
- // PR L2 — Validate every locale string before they reach the filesystem.
190
- // The locales drive both URL pattern emission (`/${locale}/...`) AND
191
- // filesystem writes (`mkdir(dist/${locale})` in ssg-plugin.ts's per-
192
- // locale 404 emit). User-supplied input with `/`, `..`, `\`, NUL, or
193
- // leading dots could write outside dist OR produce broken URLs.
194
- // Validate at the single entry point so every downstream consumer
195
- // (vite-plugin's virtual-routes load AND ssg-plugin's path expansion)
196
- // benefits from one check.
197
- //
198
- // Reject:
199
- // - empty string (kills the app silently with no usable URLs)
200
- // - leading/trailing whitespace (URL-malformed)
201
- // - `/` or `\` (path traversal AND structurally invalid as a URL
202
- // segment — `/de/sub/about` would split into nested directories)
203
- // - `..` or `.` whole-string (path traversal)
204
- // - NUL char (system-call boundary breaks)
205
- // - leading `.` (hidden directory; macOS/Linux dotfile pattern that
206
- // would create `dist/.locale/` invisible to most ls outputs)
207
- //
208
- // Runs AFTER the empty-locales no-op guard so apps temporarily
209
- // toggling to `i18n: { locales: [], ... }` (mid-migration shape)
210
- // don't trip on an unused defaultLocale.
211
- for (const locale of locales) validateLocale(locale)
212
- validateLocale(defaultLocale)
213
- if (
214
- strategy === 'prefix-except-default'
215
- && locales.length === 1
216
- && locales[0] === defaultLocale
217
- ) {
218
- return routes
219
- }
220
-
221
- const expanded: FileRoute[] = []
222
- for (const route of routes) {
223
- for (const locale of locales) {
224
- // For prefix-except-default, the default locale uses the ORIGINAL
225
- // urlPath / dirPath / depth — no prefix applied.
226
- if (strategy === 'prefix-except-default' && locale === defaultLocale) {
227
- expanded.push(route)
228
- continue
229
- }
230
-
231
- // PR H follow-up: skip duplication of ROOT-level layouts under
232
- // `prefix-except-default`. The unprefixed default-locale root
233
- // `_layout.tsx` (urlPath `/`) is the parent of the matched chain
234
- // for EVERY path, including locale-prefixed ones — the route
235
- // tree's hierarchical matching wraps `/de/about` under `/_layout`
236
- // automatically. Producing a duplicate `/de/_layout` would cause
237
- // the matcher to nest BOTH layouts (`/_layout` → `/de/_layout` →
238
- // page), mounting the layout component twice and rendering two
239
- // navbars / two PyreonUI providers.
240
- //
241
- // Non-root layouts (e.g. `/dashboard/_layout` at urlPath
242
- // `/dashboard`) MUST still be duplicated — `/de/dashboard/users`
243
- // is NOT a child of the unprefixed `/dashboard/_layout` (the
244
- // path patterns don't match), so the de-prefixed dashboard needs
245
- // its own `_layout`.
246
- //
247
- // Under `prefix` strategy this skip does NOT apply: there is no
248
- // unprefixed default to inherit from, so every locale needs its
249
- // own root layout (`/en/_layout`, `/de/_layout`, …).
250
- if (
251
- strategy === 'prefix-except-default'
252
- && route.isLayout
253
- && route.urlPath === '/'
254
- ) {
255
- continue
256
- }
257
-
258
- const newUrlPath = prefixUrlPath(route.urlPath, locale)
259
- // dirPath needs the locale segment too so the route-tree builder
260
- // groups localized siblings correctly. Original empty `dirPath`
261
- // (root-level routes) becomes the bare locale.
262
- const newDirPath = route.dirPath === '' ? locale : `${locale}/${route.dirPath}`
263
- // Recompute depth from the new urlPath. Layouts at the root (depth
264
- // 0) become depth 1 under their locale prefix; nested routes shift
265
- // up by 1.
266
- const newDepth = newUrlPath === '/' ? 0 : newUrlPath.split('/').filter(Boolean).length
267
-
268
- expanded.push({
269
- ...route,
270
- urlPath: newUrlPath,
271
- dirPath: newDirPath,
272
- depth: newDepth,
273
- })
274
- }
275
- }
276
- return expanded
277
- }
278
-
279
- /**
280
- * Prepend `/locale` to a URL pattern. Handles three shapes:
281
- * `/` → `/de`
282
- * `/about` → `/de/about`
283
- * `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
284
- *
285
- * Internal helper to `expandRoutesForLocales`; not exported because the
286
- * public surface for path-building is `buildLocalePath` (which strips
287
- * existing locale prefixes — different semantics).
288
- */
289
- function prefixUrlPath(urlPath: string, locale: string): string {
290
- if (urlPath === '/') return `/${locale}`
291
- return `/${locale}${urlPath}`
292
- }
293
-
294
- /**
295
- * Validate a locale string (PR L2).
296
- *
297
- * The locale drives both URL pattern emission AND filesystem writes
298
- * (see `expandRoutesForLocales` for full rationale). Reject input that
299
- * would either:
300
- * - break path-traversal boundaries (`..`, `/`, `\`)
301
- * - produce invalid URL segments (whitespace, NUL)
302
- * - create hidden-file artifacts (`.` leading)
303
- * - silently kill the app (empty string)
304
- *
305
- * Throws with an actionable `[Pyreon]` error message. Called per-locale
306
- * by `expandRoutesForLocales` after the empty-locales no-op guard.
307
- *
308
- * @internal — exported for unit testing.
309
- */
310
- export function validateLocale(locale: string): void {
311
- if (typeof locale !== 'string' || locale === '') {
312
- throw new Error(
313
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`,
314
- )
315
- }
316
- if (locale.trim() !== locale) {
317
- throw new Error(
318
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`,
319
- )
320
- }
321
- if (locale.includes('/') || locale.includes('\\')) {
322
- throw new Error(
323
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`,
324
- )
325
- }
326
- if (locale === '..' || locale === '.') {
327
- throw new Error(
328
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`,
329
- )
330
- }
331
- if (locale.startsWith('.')) {
332
- throw new Error(
333
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`,
334
- )
335
- }
336
- if (locale.includes('\0')) {
337
- throw new Error(
338
- `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`,
339
- )
340
- }
341
- }
342
-
343
- /**
344
- * Create a LocaleContext for use in components and loaders.
345
- */
346
- export function createLocaleContext(
347
- locale: string,
348
- path: string,
349
- config: I18nRoutingConfig,
350
- ): LocaleContext {
351
- const strategy = config.strategy ?? 'prefix-except-default'
352
-
353
- return {
354
- locale,
355
- locales: config.locales,
356
- defaultLocale: config.defaultLocale,
357
-
358
- localePath(targetPath: string, targetLocale?: string) {
359
- return buildLocalePath(
360
- targetPath,
361
- targetLocale ?? locale,
362
- config.defaultLocale,
363
- strategy,
364
- )
365
- },
366
-
367
- alternates() {
368
- const { pathWithoutLocale } = extractLocaleFromPath(
369
- path,
370
- config.locales,
371
- config.defaultLocale,
372
- )
373
- return config.locales.map((loc) => ({
374
- locale: loc,
375
- url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),
376
- }))
377
- },
378
- }
379
- }
380
-
381
- /**
382
- * I18n routing middleware for Zero's server.
383
- *
384
- * - Detects locale from URL prefix or Accept-Language header
385
- * - Redirects root to preferred locale (when detectLocale is true)
386
- * - Sets locale context for loaders and components
387
- *
388
- * @example
389
- * ```ts
390
- * // zero.config.ts
391
- * import { i18nRouting } from "@pyreon/zero"
392
- *
393
- * export default defineConfig({
394
- * plugins: [
395
- * i18nRouting({
396
- * locales: ["en", "de", "cs"],
397
- * defaultLocale: "en",
398
- * }),
399
- * ],
400
- * })
401
- * ```
402
- */
403
- export function i18nRouting(config: I18nRoutingConfig): Plugin {
404
- const strategy = config.strategy ?? 'prefix-except-default'
405
- const detectEnabled = config.detectLocale !== false
406
- const cookieName = config.cookieName ?? 'locale'
407
-
408
- return {
409
- name: 'pyreon-zero-i18n-routing',
410
-
411
- // Route duplication is NOT handled here. It happens in
412
- // `vite-plugin.ts` and `ssg-plugin.ts` via `expandRoutesForLocales`,
413
- // gated by the `i18n` field on `ZeroConfig`. This plugin only
414
- // provides: (1) the dev server middleware for locale detection
415
- // (Accept-Language, cookies, root redirect) and (2) the runtime
416
- // hooks (useLocale, setLocale) for client-side use.
417
- configResolved() {},
418
-
419
- configureServer(server) {
420
- server.middlewares.use((req, res, next) => {
421
- const url = req.url ?? '/'
422
-
423
- // Skip static assets
424
- if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {
425
- return next()
426
- }
427
-
428
- const { locale } = extractLocaleFromPath(
429
- url,
430
- config.locales,
431
- config.defaultLocale,
432
- )
433
-
434
- // Redirect root to detected locale
435
- if (detectEnabled && url === '/') {
436
- const cookies = parseCookies(req.headers.cookie)
437
- const preferredFromCookie = cookies[cookieName]
438
- const preferredFromHeader = detectLocaleFromHeader(
439
- req.headers['accept-language'],
440
- config.locales,
441
- config.defaultLocale,
442
- )
443
- const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)
444
- ? preferredFromCookie
445
- : preferredFromHeader
446
-
447
- if (strategy === 'prefix' || preferred !== config.defaultLocale) {
448
- res.writeHead(302, { Location: `/${preferred}/` })
449
- res.end()
450
- return
451
- }
452
- }
453
-
454
- // Attach locale context to request for loaders
455
- ;(req as any).__locale = locale
456
- ;(req as any).__localeContext = createLocaleContext(locale, url, config)
457
-
458
- // Update the module-level signal so useLocale() returns the correct value
459
- localeSignal.set(locale)
460
-
461
- next()
462
- })
463
- },
464
- }
465
- }
466
-
467
- function parseCookies(header: string | undefined): Record<string, string> {
468
- if (!header) return {}
469
- const result: Record<string, string> = {}
470
- for (const pair of header.split(';')) {
471
- const [key, value] = pair.trim().split('=')
472
- if (key && value) result[key] = decodeURIComponent(value)
473
- }
474
- return result
475
- }
476
-
477
- // ─── Reactive locale hook ───────────────────────────────────────────────────
478
-
479
- /** @internal Context for the current locale. */
480
- export const LocaleCtx = createContext<string>('en')
481
-
482
- /** Current locale signal — set by the server middleware or client-side detection. */
483
- export const localeSignal = signal('en')
484
-
485
- /**
486
- * Read the current locale reactively.
487
- *
488
- * Returns the locale signal value directly — reactive in both SSR and CSR.
489
- * The server middleware sets `localeSignal` per-request, and client-side
490
- * `setLocale()` updates it as well.
491
- *
492
- * @example
493
- * ```tsx
494
- * const locale = useLocale() // "en", "de", etc.
495
- * ```
496
- */
497
- export function useLocale(): string {
498
- return localeSignal()
499
- }
500
-
501
- /**
502
- * Set the locale client-side and update the URL.
503
- *
504
- * @example
505
- * ```tsx
506
- * <button onClick={() => setLocale('de')}>Deutsch</button>
507
- * ```
508
- */
509
- export function setLocale(
510
- locale: string,
511
- config: I18nRoutingConfig,
512
- ): void {
513
- localeSignal.set(locale)
514
-
515
- // Persist to cookie
516
- if (typeof document !== 'undefined') {
517
- document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`
518
- }
519
-
520
- // Navigate to localized URL — use pushState to avoid full page reload
521
- if (typeof window !== 'undefined') {
522
- const strategy = config.strategy ?? 'prefix-except-default'
523
- const { pathWithoutLocale } = extractLocaleFromPath(
524
- window.location.pathname,
525
- config.locales,
526
- config.defaultLocale,
527
- )
528
- const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)
529
- window.history.pushState(null, '', newPath)
530
- // Dispatch popstate so @pyreon/router picks up the URL change
531
- window.dispatchEvent(new PopStateEvent('popstate'))
532
- }
533
- }