@pyreon/zero 0.11.8 → 0.11.10

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 (95) hide show
  1. package/lib/font.js +20 -7
  2. package/lib/font.js.map +1 -1
  3. package/lib/fs-router-BkbIWqek.js.map +1 -1
  4. package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
  5. package/lib/fs-router-Dil4IKZR.js.map +1 -0
  6. package/lib/image-plugin.js.map +1 -1
  7. package/lib/index.js +893 -24
  8. package/lib/index.js.map +1 -1
  9. package/lib/link.js +13 -1
  10. package/lib/link.js.map +1 -1
  11. package/lib/types/actions.d.ts +57 -0
  12. package/lib/types/actions.d.ts.map +1 -0
  13. package/lib/types/adapters/bun.d.ts +6 -0
  14. package/lib/types/adapters/bun.d.ts.map +1 -0
  15. package/lib/types/adapters/index.d.ts +10 -0
  16. package/lib/types/adapters/index.d.ts.map +1 -0
  17. package/lib/types/adapters/node.d.ts +6 -0
  18. package/lib/types/adapters/node.d.ts.map +1 -0
  19. package/lib/types/adapters/static.d.ts +7 -0
  20. package/lib/types/adapters/static.d.ts.map +1 -0
  21. package/lib/types/api-routes.d.ts +66 -0
  22. package/lib/types/api-routes.d.ts.map +1 -0
  23. package/lib/types/app.d.ts +24 -0
  24. package/lib/types/app.d.ts.map +1 -0
  25. package/lib/types/cache.d.ts +54 -0
  26. package/lib/types/cache.d.ts.map +1 -0
  27. package/lib/types/client.d.ts +19 -0
  28. package/lib/types/client.d.ts.map +1 -0
  29. package/lib/types/compression.d.ts +33 -0
  30. package/lib/types/compression.d.ts.map +1 -0
  31. package/lib/types/config.d.ts +18 -0
  32. package/lib/types/config.d.ts.map +1 -0
  33. package/lib/types/cors.d.ts +32 -0
  34. package/lib/types/cors.d.ts.map +1 -0
  35. package/lib/types/entry-server.d.ts +37 -0
  36. package/lib/types/entry-server.d.ts.map +1 -0
  37. package/lib/types/error-overlay.d.ts +6 -0
  38. package/lib/types/error-overlay.d.ts.map +1 -0
  39. package/lib/types/favicon.d.ts +43 -0
  40. package/lib/types/favicon.d.ts.map +1 -0
  41. package/lib/types/font.d.ts +119 -0
  42. package/lib/types/font.d.ts.map +1 -0
  43. package/lib/types/fs-router.d.ts +47 -0
  44. package/lib/types/fs-router.d.ts.map +1 -0
  45. package/lib/types/i18n-routing.d.ts +98 -0
  46. package/lib/types/i18n-routing.d.ts.map +1 -0
  47. package/lib/types/image-plugin.d.ts +79 -0
  48. package/lib/types/image-plugin.d.ts.map +1 -0
  49. package/lib/types/image.d.ts +51 -0
  50. package/lib/types/image.d.ts.map +1 -0
  51. package/lib/types/index.d.ts +46 -0
  52. package/lib/types/index.d.ts.map +1 -0
  53. package/lib/types/isr.d.ts +9 -0
  54. package/lib/types/isr.d.ts.map +1 -0
  55. package/lib/types/link.d.ts +127 -0
  56. package/lib/types/link.d.ts.map +1 -0
  57. package/lib/types/meta.d.ts +91 -0
  58. package/lib/types/meta.d.ts.map +1 -0
  59. package/lib/types/middleware.d.ts +35 -0
  60. package/lib/types/middleware.d.ts.map +1 -0
  61. package/lib/types/not-found.d.ts +7 -0
  62. package/lib/types/not-found.d.ts.map +1 -0
  63. package/lib/types/rate-limit.d.ts +34 -0
  64. package/lib/types/rate-limit.d.ts.map +1 -0
  65. package/lib/types/script.d.ts +35 -0
  66. package/lib/types/script.d.ts.map +1 -0
  67. package/lib/types/seo.d.ts +88 -0
  68. package/lib/types/seo.d.ts.map +1 -0
  69. package/lib/types/testing.d.ts +85 -0
  70. package/lib/types/testing.d.ts.map +1 -0
  71. package/lib/types/theme.d.ts +39 -0
  72. package/lib/types/theme.d.ts.map +1 -0
  73. package/lib/types/types.d.ts +111 -0
  74. package/lib/types/types.d.ts.map +1 -0
  75. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  76. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  77. package/lib/types/utils/with-headers.d.ts +6 -0
  78. package/lib/types/utils/with-headers.d.ts.map +1 -0
  79. package/lib/types/vite-plugin.d.ts +17 -0
  80. package/lib/types/vite-plugin.d.ts.map +1 -0
  81. package/package.json +10 -10
  82. package/src/entry-server.ts +124 -76
  83. package/src/favicon.ts +380 -0
  84. package/src/font.ts +32 -8
  85. package/src/fs-router.ts +54 -13
  86. package/src/i18n-routing.ts +299 -0
  87. package/src/image-plugin.ts +1 -1
  88. package/src/index.ts +125 -76
  89. package/src/link.tsx +19 -0
  90. package/src/meta.tsx +210 -0
  91. package/src/middleware.ts +65 -0
  92. package/src/not-found.ts +44 -0
  93. package/src/types.ts +2 -0
  94. package/src/vite-plugin.ts +258 -127
  95. package/lib/fs-router-n4VA4lxu.js.map +0 -1
package/src/fs-router.ts CHANGED
@@ -20,9 +20,12 @@ import type { FileRoute, RenderMode } from './types'
20
20
  // Conventions:
21
21
  // [param] → dynamic segment → :param
22
22
  // [...param] → catch-all → :param*
23
- // _layout → layout wrapper (not a route itself)
23
+ // _layout → layout wrapper must use <RouterView /> to render child routes
24
+ // (props.children is NOT passed — the router handles nesting)
24
25
  // _error → error component
25
26
  // _loading → loading component
27
+ // _404 → not-found component (renders on 404)
28
+ // _not-found → alias for _404
26
29
  // (group) → route group (directory ignored in URL)
27
30
 
28
31
  const ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']
@@ -54,6 +57,7 @@ function parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {
54
57
  const isLayout = fileName === '_layout'
55
58
  const isError = fileName === '_error'
56
59
  const isLoading = fileName === '_loading'
60
+ const isNotFound = fileName === '_404' || fileName === '_not-found'
57
61
  const isCatchAll = route.includes('[...')
58
62
 
59
63
  // Get directory path (strip groups for consistent grouping)
@@ -73,6 +77,7 @@ function parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {
73
77
  isLayout,
74
78
  isError,
75
79
  isLoading,
80
+ isNotFound,
76
81
  isCatchAll,
77
82
  renderMode: defaultMode,
78
83
  }
@@ -99,7 +104,7 @@ export function filePathToUrlPath(filePath: string): string {
99
104
  if (seg.startsWith('(') && seg.endsWith(')')) continue
100
105
 
101
106
  // Skip special files
102
- if (seg === '_layout' || seg === '_error' || seg === '_loading') continue
107
+ if (seg === '_layout' || seg === '_error' || seg === '_loading' || seg === '_404' || seg === '_not-found') continue
103
108
 
104
109
  // "index" maps to the parent path
105
110
  if (seg === 'index') continue
@@ -156,6 +161,8 @@ interface RouteNode {
156
161
  error?: FileRoute
157
162
  /** Loading fallback file (if any). */
158
163
  loading?: FileRoute
164
+ /** Not-found (404) file (if any). */
165
+ notFound?: FileRoute
159
166
  /** Child directories. */
160
167
  children: Map<string, RouteNode>
161
168
  }
@@ -186,6 +193,7 @@ function placeRoute(node: RouteNode, route: FileRoute) {
186
193
  if (route.isLayout) node.layout = route
187
194
  else if (route.isError) node.error = route
188
195
  else if (route.isLoading) node.loading = route
196
+ else if (route.isNotFound) node.notFound = route
189
197
  else node.pages.push(route)
190
198
  }
191
199
 
@@ -202,11 +210,26 @@ function buildRouteTree(routes: FileRoute[]): RouteNode {
202
210
  * Wires up layouts as parent routes with children, loaders, guards,
203
211
  * error/loading components, middleware, and meta from route module exports.
204
212
  */
205
- export function generateRouteModule(files: string[], routesDir: string): string {
213
+ export interface GenerateRouteModuleOptions {
214
+ /**
215
+ * When true, skip lazy() for route components and use static imports.
216
+ * Use for SSG/prerender mode where all routes are rendered at build time
217
+ * and code splitting provides no benefit. Avoids Rolldown warnings about
218
+ * static + dynamic imports of the same module.
219
+ */
220
+ staticImports?: boolean
221
+ }
222
+
223
+ export function generateRouteModule(
224
+ files: string[],
225
+ routesDir: string,
226
+ options?: GenerateRouteModuleOptions,
227
+ ): string {
206
228
  const routes = parseFileRoutes(files)
207
229
  const tree = buildRouteTree(routes)
208
230
  const imports: string[] = []
209
231
  let importCounter = 0
232
+ const useStaticImports = options?.staticImports ?? false
210
233
 
211
234
  function nextImport(filePath: string, exportName = 'default'): string {
212
235
  const name = `_${importCounter++}`
@@ -222,11 +245,18 @@ export function generateRouteModule(files: string[], routesDir: string): string
222
245
  function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {
223
246
  const name = `_${importCounter++}`
224
247
  const fullPath = `${routesDir}/${filePath}`
225
- const opts: string[] = []
226
- if (loadingName) opts.push(`loading: ${loadingName}`)
227
- if (errorName) opts.push(`error: ${errorName}`)
228
- const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
229
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
248
+
249
+ if (useStaticImports) {
250
+ // SSG mode: static import avoids Rolldown warnings about
251
+ // static + dynamic imports of the same module
252
+ imports.push(`import ${name} from "${fullPath}"`)
253
+ } else {
254
+ const opts: string[] = []
255
+ if (loadingName) opts.push(`loading: ${loadingName}`)
256
+ if (errorName) opts.push(`error: ${errorName}`)
257
+ const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
258
+ imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
259
+ }
230
260
  return name
231
261
  }
232
262
 
@@ -242,6 +272,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
242
272
  indent: string,
243
273
  loadingName: string | undefined,
244
274
  errorName: string | undefined,
275
+ notFoundName: string | undefined,
245
276
  ): string {
246
277
  const mod = nextModuleImport(page.filePath)
247
278
  const comp = nextLazy(page.filePath, loadingName, errorName)
@@ -254,10 +285,15 @@ export function generateRouteModule(files: string[], routesDir: string): string
254
285
  `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,
255
286
  ]
256
287
 
288
+ // Only emit errorComponent when there's an actual _error file in scope
289
+ // or the route module exports an error component. Avoids referencing
290
+ // undefined .error exports that produce noisy bundler warnings.
257
291
  if (errorName) {
258
292
  props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)
259
- } else {
260
- props.push(`${indent} errorComponent: ${mod}.error`)
293
+ }
294
+
295
+ if (notFoundName) {
296
+ props.push(`${indent} notFoundComponent: ${notFoundName}`)
261
297
  }
262
298
 
263
299
  return `${indent}{\n${props.join(',\n')}\n${indent}}`
@@ -268,6 +304,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
268
304
  children: string[],
269
305
  indent: string,
270
306
  errorName: string | undefined,
307
+ notFoundName: string | undefined,
271
308
  ): string {
272
309
  const layout = node.layout as FileRoute
273
310
  const layoutMod = nextModuleImport(layout.filePath)
@@ -283,6 +320,9 @@ export function generateRouteModule(files: string[], routesDir: string): string
283
320
  if (errorName) {
284
321
  props.push(`${indent}errorComponent: ${errorName}`)
285
322
  }
323
+ if (notFoundName) {
324
+ props.push(`${indent}notFoundComponent: ${notFoundName}`)
325
+ }
286
326
  if (children.length > 0) {
287
327
  props.push(`${indent}children: [\n${children.join(',\n')}\n${indent}]`)
288
328
  }
@@ -298,6 +338,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
298
338
 
299
339
  const errorName = node.error ? nextImport(node.error.filePath) : undefined
300
340
  const loadingName = node.loading ? nextImport(node.loading.filePath) : undefined
341
+ const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : undefined
301
342
 
302
343
  const childRouteDefs: string[] = []
303
344
  for (const [, childNode] of node.children) {
@@ -305,13 +346,13 @@ export function generateRouteModule(files: string[], routesDir: string): string
305
346
  }
306
347
 
307
348
  const pageRouteDefs = node.pages.map((page) =>
308
- generatePageRoute(page, indent, loadingName, errorName),
349
+ generatePageRoute(page, indent, loadingName, errorName, notFoundName),
309
350
  )
310
351
 
311
352
  const allChildren = [...pageRouteDefs, ...childRouteDefs]
312
353
 
313
354
  if (node.layout) {
314
- return [wrapWithLayout(node, allChildren, indent, errorName)]
355
+ return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)]
315
356
  }
316
357
  return allChildren
317
358
  }
@@ -350,7 +391,7 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
350
391
  let counter = 0
351
392
 
352
393
  for (const route of routes) {
353
- if (route.isLayout || route.isError || route.isLoading) continue
394
+ if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
354
395
  const name = `_mw${counter++}`
355
396
  const fullPath = `${routesDir}/${route.filePath}`
356
397
  imports.push(`import { middleware as ${name} } from "${fullPath}"`)
@@ -0,0 +1,299 @@
1
+ import { createContext } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import type { Plugin } from 'vite'
4
+
5
+ // ─── Localized routing ─────────────────────────────────────────────────────
6
+ //
7
+ // Adds locale-prefixed routes to Zero's file-system router:
8
+ // - /about → /en/about, /de/about, /cs/about
9
+ // - / → /en, /de, /cs (or default locale without prefix)
10
+ // - Automatic locale detection from Accept-Language header
11
+ // - Redirect to preferred locale
12
+ // - hreflang link generation
13
+ //
14
+ // Usage:
15
+ // import { i18nRouting } from "@pyreon/zero"
16
+ // export default { plugins: [zero(), i18nRouting({ locales: ["en", "de"], defaultLocale: "en" })] }
17
+
18
+ export interface I18nRoutingConfig {
19
+ /** Supported locales. e.g. ["en", "de", "cs"] */
20
+ locales: string[]
21
+ /** Default locale — served without prefix (/ instead of /en/). */
22
+ defaultLocale: string
23
+ /** Redirect root to detected locale. Default: true */
24
+ detectLocale?: boolean
25
+ /** Cookie name to persist locale preference. Default: "locale" */
26
+ cookieName?: string
27
+ /** URL strategy. Default: "prefix-except-default" */
28
+ strategy?: 'prefix' | 'prefix-except-default'
29
+ }
30
+
31
+ export interface LocaleContext {
32
+ /** Current locale code. e.g. "en", "de" */
33
+ locale: string
34
+ /** All supported locales. */
35
+ locales: string[]
36
+ /** Default locale. */
37
+ defaultLocale: string
38
+ /** Build a localized path. e.g. localePath("/about", "de") → "/de/about" */
39
+ localePath: (path: string, locale?: string) => string
40
+ /** Get hreflang alternates for the current path. */
41
+ alternates: () => Array<{ locale: string; url: string }>
42
+ }
43
+
44
+ /**
45
+ * Detect preferred locale from Accept-Language header.
46
+ */
47
+ export function detectLocaleFromHeader(
48
+ acceptLanguage: string | null | undefined,
49
+ locales: string[],
50
+ defaultLocale: string,
51
+ ): string {
52
+ if (!acceptLanguage) return defaultLocale
53
+
54
+ // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8
55
+ const preferred = acceptLanguage
56
+ .split(',')
57
+ .map((part) => {
58
+ const [lang, q] = part.trim().split(';q=')
59
+ return {
60
+ lang: lang?.split('-')[0]?.toLowerCase() ?? '',
61
+ quality: q ? Number.parseFloat(q) : 1,
62
+ }
63
+ })
64
+ .sort((a, b) => b.quality - a.quality)
65
+
66
+ for (const { lang } of preferred) {
67
+ if (locales.includes(lang)) return lang
68
+ }
69
+
70
+ return defaultLocale
71
+ }
72
+
73
+ /**
74
+ * Extract locale from a URL path.
75
+ * Returns { locale, pathWithoutLocale }.
76
+ */
77
+ export function extractLocaleFromPath(
78
+ path: string,
79
+ locales: string[],
80
+ defaultLocale: string,
81
+ ): { locale: string; pathWithoutLocale: string } {
82
+ const segments = path.split('/').filter(Boolean)
83
+ const firstSegment = segments[0]?.toLowerCase()
84
+
85
+ if (firstSegment && locales.includes(firstSegment)) {
86
+ return {
87
+ locale: firstSegment,
88
+ pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',
89
+ }
90
+ }
91
+
92
+ return { locale: defaultLocale, pathWithoutLocale: path }
93
+ }
94
+
95
+ /**
96
+ * Build a localized path.
97
+ */
98
+ export function buildLocalePath(
99
+ path: string,
100
+ locale: string,
101
+ defaultLocale: string,
102
+ strategy: 'prefix' | 'prefix-except-default',
103
+ ): string {
104
+ const clean = path === '/' ? '' : path
105
+ if (strategy === 'prefix-except-default' && locale === defaultLocale) {
106
+ return path
107
+ }
108
+ return `/${locale}${clean}`
109
+ }
110
+
111
+ /**
112
+ * Create a LocaleContext for use in components and loaders.
113
+ */
114
+ export function createLocaleContext(
115
+ locale: string,
116
+ path: string,
117
+ config: I18nRoutingConfig,
118
+ ): LocaleContext {
119
+ const strategy = config.strategy ?? 'prefix-except-default'
120
+
121
+ return {
122
+ locale,
123
+ locales: config.locales,
124
+ defaultLocale: config.defaultLocale,
125
+
126
+ localePath(targetPath: string, targetLocale?: string) {
127
+ return buildLocalePath(
128
+ targetPath,
129
+ targetLocale ?? locale,
130
+ config.defaultLocale,
131
+ strategy,
132
+ )
133
+ },
134
+
135
+ alternates() {
136
+ const { pathWithoutLocale } = extractLocaleFromPath(
137
+ path,
138
+ config.locales,
139
+ config.defaultLocale,
140
+ )
141
+ return config.locales.map((loc) => ({
142
+ locale: loc,
143
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),
144
+ }))
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * I18n routing middleware for Zero's server.
151
+ *
152
+ * - Detects locale from URL prefix or Accept-Language header
153
+ * - Redirects root to preferred locale (when detectLocale is true)
154
+ * - Sets locale context for loaders and components
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * // zero.config.ts
159
+ * import { i18nRouting } from "@pyreon/zero"
160
+ *
161
+ * export default defineConfig({
162
+ * plugins: [
163
+ * i18nRouting({
164
+ * locales: ["en", "de", "cs"],
165
+ * defaultLocale: "en",
166
+ * }),
167
+ * ],
168
+ * })
169
+ * ```
170
+ */
171
+ export function i18nRouting(config: I18nRoutingConfig): Plugin {
172
+ const strategy = config.strategy ?? 'prefix-except-default'
173
+ const detectEnabled = config.detectLocale !== false
174
+ const cookieName = config.cookieName ?? 'locale'
175
+
176
+ return {
177
+ name: 'pyreon-zero-i18n-routing',
178
+
179
+ // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`
180
+ // consumes the i18n config to duplicate routes per locale at build time.
181
+ // This plugin only provides: (1) the server middleware for locale detection
182
+ // and (2) the runtime hooks (useLocale, setLocale) for client-side use.
183
+ configResolved() {},
184
+
185
+ configureServer(server) {
186
+ server.middlewares.use((req, res, next) => {
187
+ const url = req.url ?? '/'
188
+
189
+ // Skip static assets
190
+ if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {
191
+ return next()
192
+ }
193
+
194
+ const { locale } = extractLocaleFromPath(
195
+ url,
196
+ config.locales,
197
+ config.defaultLocale,
198
+ )
199
+
200
+ // Redirect root to detected locale
201
+ if (detectEnabled && url === '/') {
202
+ const cookies = parseCookies(req.headers.cookie)
203
+ const preferredFromCookie = cookies[cookieName]
204
+ const preferredFromHeader = detectLocaleFromHeader(
205
+ req.headers['accept-language'],
206
+ config.locales,
207
+ config.defaultLocale,
208
+ )
209
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)
210
+ ? preferredFromCookie
211
+ : preferredFromHeader
212
+
213
+ if (strategy === 'prefix' || preferred !== config.defaultLocale) {
214
+ res.writeHead(302, { Location: `/${preferred}/` })
215
+ res.end()
216
+ return
217
+ }
218
+ }
219
+
220
+ // Attach locale context to request for loaders
221
+ ;(req as any).__locale = locale
222
+ ;(req as any).__localeContext = createLocaleContext(locale, url, config)
223
+
224
+ // Update the module-level signal so useLocale() returns the correct value
225
+ localeSignal.set(locale)
226
+
227
+ next()
228
+ })
229
+ },
230
+ }
231
+ }
232
+
233
+ function parseCookies(header: string | undefined): Record<string, string> {
234
+ if (!header) return {}
235
+ const result: Record<string, string> = {}
236
+ for (const pair of header.split(';')) {
237
+ const [key, value] = pair.trim().split('=')
238
+ if (key && value) result[key] = decodeURIComponent(value)
239
+ }
240
+ return result
241
+ }
242
+
243
+ // ─── Reactive locale hook ───────────────────────────────────────────────────
244
+
245
+ /** @internal Context for the current locale. */
246
+ export const LocaleCtx = createContext<string>('en')
247
+
248
+ /** Current locale signal — set by the server middleware or client-side detection. */
249
+ export const localeSignal = signal('en')
250
+
251
+ /**
252
+ * Read the current locale reactively.
253
+ *
254
+ * Returns the locale signal value directly — reactive in both SSR and CSR.
255
+ * The server middleware sets `localeSignal` per-request, and client-side
256
+ * `setLocale()` updates it as well.
257
+ *
258
+ * @example
259
+ * ```tsx
260
+ * const locale = useLocale() // "en", "de", etc.
261
+ * ```
262
+ */
263
+ export function useLocale(): string {
264
+ return localeSignal()
265
+ }
266
+
267
+ /**
268
+ * Set the locale client-side and update the URL.
269
+ *
270
+ * @example
271
+ * ```tsx
272
+ * <button onClick={() => setLocale('de')}>Deutsch</button>
273
+ * ```
274
+ */
275
+ export function setLocale(
276
+ locale: string,
277
+ config: I18nRoutingConfig,
278
+ ): void {
279
+ localeSignal.set(locale)
280
+
281
+ // Persist to cookie
282
+ if (typeof document !== 'undefined') {
283
+ document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`
284
+ }
285
+
286
+ // Navigate to localized URL — use pushState to avoid full page reload
287
+ if (typeof window !== 'undefined') {
288
+ const strategy = config.strategy ?? 'prefix-except-default'
289
+ const { pathWithoutLocale } = extractLocaleFromPath(
290
+ window.location.pathname,
291
+ config.locales,
292
+ config.defaultLocale,
293
+ )
294
+ const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)
295
+ window.history.pushState(null, '', newPath)
296
+ // Dispatch popstate so @pyreon/router picks up the URL change
297
+ window.dispatchEvent(new PopStateEvent('popstate'))
298
+ }
299
+ }
@@ -7,7 +7,7 @@ let sharpWarned = false
7
7
  function warnSharpMissing() {
8
8
  if (sharpWarned) return
9
9
  sharpWarned = true
10
- // biome-ignore lint/suspicious/noConsole: intentional build-time warning
10
+ // oxlint-disable-next-line no-console
11
11
  console.warn(
12
12
  '\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n',
13
13
  )