@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.
- package/lib/font.js +20 -7
- package/lib/font.js.map +1 -1
- package/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
- package/lib/fs-router-Dil4IKZR.js.map +1 -0
- package/lib/image-plugin.js.map +1 -1
- package/lib/index.js +893 -24
- package/lib/index.js.map +1 -1
- package/lib/link.js +13 -1
- package/lib/link.js.map +1 -1
- package/lib/types/actions.d.ts +57 -0
- package/lib/types/actions.d.ts.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +66 -0
- package/lib/types/api-routes.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/compression.d.ts +33 -0
- package/lib/types/compression.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/cors.d.ts +32 -0
- package/lib/types/cors.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +37 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/error-overlay.d.ts +6 -0
- package/lib/types/error-overlay.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +43 -0
- package/lib/types/favicon.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +47 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/i18n-routing.d.ts +98 -0
- package/lib/types/i18n-routing.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +51 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +46 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +127 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/meta.d.ts +91 -0
- package/lib/types/meta.d.ts.map +1 -0
- package/lib/types/middleware.d.ts +35 -0
- package/lib/types/middleware.d.ts.map +1 -0
- package/lib/types/not-found.d.ts +7 -0
- package/lib/types/not-found.d.ts.map +1 -0
- package/lib/types/rate-limit.d.ts +34 -0
- package/lib/types/rate-limit.d.ts.map +1 -0
- package/lib/types/script.d.ts +35 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/testing.d.ts +85 -0
- package/lib/types/testing.d.ts.map +1 -0
- package/lib/types/theme.d.ts +39 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +111 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/font.ts +32 -8
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/image-plugin.ts +1 -1
- package/src/index.ts +125 -76
- package/src/link.tsx +19 -0
- package/src/meta.tsx +210 -0
- package/src/middleware.ts +65 -0
- package/src/not-found.ts +44 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin.ts +258 -127
- 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
|
|
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
|
|
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
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
}
|
|
260
|
-
|
|
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
|
+
}
|
package/src/image-plugin.ts
CHANGED
|
@@ -7,7 +7,7 @@ let sharpWarned = false
|
|
|
7
7
|
function warnSharpMissing() {
|
|
8
8
|
if (sharpWarned) return
|
|
9
9
|
sharpWarned = true
|
|
10
|
-
//
|
|
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
|
)
|