@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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/i18n-routing.ts
DELETED
|
@@ -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
|
-
}
|