vike-i18n-routing 0.1.0
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/README.md +450 -0
- package/lib/core/cookies.ts +21 -0
- package/lib/core/domain/detector.ts +18 -0
- package/lib/core/domain/normalize.ts +25 -0
- package/lib/core/locale/detector.ts +80 -0
- package/lib/core/locale/normalize.ts +10 -0
- package/lib/core/resolve.ts +39 -0
- package/lib/core/route-patterns.ts +66 -0
- package/lib/core/router.ts +429 -0
- package/lib/core/types.ts +89 -0
- package/lib/core/utils.ts +132 -0
- package/lib/index.ts +22 -0
- package/lib/middleware.ts +1 -0
- package/lib/vike/+config.ts +22 -0
- package/lib/vike/client.ts +11 -0
- package/lib/vike/global.d.ts +20 -0
- package/lib/vike/onBeforeRenderHtml.ts +15 -0
- package/lib/vike/onBeforeRoute.ts +55 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# vike-i18n-routing
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<strong>I18n routing for Vike with localized URLs, locale prefixes, domain-aware config, and URL helpers.</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="https://www.npmjs.com/package/vike-i18n-routing"><img alt="npm version" src="https://img.shields.io/npm/v/vike-i18n-routing"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/vike-i18n-routing"><img alt="npm downloads" src="https://img.shields.io/npm/dm/vike-i18n-routing"></a>
|
|
12
|
+
<a href="./LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
13
|
+
<a href="https://github.com/vad1ym/vike-i18n-routing/actions"><img alt="build status" src="https://img.shields.io/github/actions/workflow/status/vad1ym/vike-i18n-routing/publish.yml"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
I18n routing for [Vike](https://vike.dev/) with locale prefixes, translated route paths, locale detection, domain-aware locale configuration, and URL helpers.
|
|
19
|
+
|
|
20
|
+
This package lets you keep one canonical route structure inside your app while exposing localized URLs such as:
|
|
21
|
+
|
|
22
|
+
- `/en/about`
|
|
23
|
+
- `/ru/o-nas`
|
|
24
|
+
- `/fr/a-propos`
|
|
25
|
+
|
|
26
|
+
It resolves incoming localized URLs back to one canonical route, redirects invalid locale/path combinations to the correct URL, and exposes helpers to build localized URLs anywhere in your app.
|
|
27
|
+
|
|
28
|
+
## Status
|
|
29
|
+
|
|
30
|
+
> [!WARNING]
|
|
31
|
+
> This package is currently under development and should be treated as a proof of concept.
|
|
32
|
+
> The API, behavior, and configuration shape may still change while the project is being validated.
|
|
33
|
+
|
|
34
|
+
## Table of Contents
|
|
35
|
+
|
|
36
|
+
- [Features](#features)
|
|
37
|
+
- [Installation](#installation)
|
|
38
|
+
- [Quick Start](#quick-start)
|
|
39
|
+
- [How It Works](#how-it-works)
|
|
40
|
+
- [Configuration](#configuration)
|
|
41
|
+
- [Dynamic Routes](#dynamic-routes)
|
|
42
|
+
- [Slug Variants](#slug-variants)
|
|
43
|
+
- [Domain-Based Locale Config](#domain-based-locale-config)
|
|
44
|
+
- [Locale Detection](#locale-detection)
|
|
45
|
+
- [Cookie Behavior](#cookie-behavior)
|
|
46
|
+
- [Runtime Helpers](#runtime-helpers)
|
|
47
|
+
- [Page Context](#page-context)
|
|
48
|
+
- [Example](#example)
|
|
49
|
+
- [Development](#development)
|
|
50
|
+
- [Current Scope](#current-scope)
|
|
51
|
+
- [License](#license)
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- Locale-prefixed routing for Vike
|
|
56
|
+
- Translated static routes like `/about` -> `/o-nas`
|
|
57
|
+
- Dynamic route patterns via `path-to-regexp`
|
|
58
|
+
- Optional segments, for example `/services/:category{/:tab}`
|
|
59
|
+
- Locale detection from URL params, cookies, session, and `Accept-Language`
|
|
60
|
+
- Automatic locale cookie persistence
|
|
61
|
+
- Per-domain locale config
|
|
62
|
+
- URL helpers for canonical URLs, alternates, and localized links
|
|
63
|
+
- Slug variant support for dynamic params
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
Choose your package manager:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm add vike-i18n-routing
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install vike-i18n-routing
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
yarn add vike-i18n-routing
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Peer dependency:
|
|
82
|
+
|
|
83
|
+
- `vike >= 0.4.259`
|
|
84
|
+
|
|
85
|
+
## Quick Start
|
|
86
|
+
|
|
87
|
+
Extend your Vike config with the plugin config and provide an `i18n` definition.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// Result:
|
|
91
|
+
// /en/about
|
|
92
|
+
// /ru/o-nas
|
|
93
|
+
// /fr/a-propos
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// pages/+config.ts
|
|
98
|
+
import vikeVue from 'vike-vue/config'
|
|
99
|
+
import vikeI18n from 'vike-i18n-routing/config'
|
|
100
|
+
import type { Config } from 'vike/types'
|
|
101
|
+
import type { I18nConfig } from 'vike-i18n-routing'
|
|
102
|
+
|
|
103
|
+
export default {
|
|
104
|
+
extends: [vikeVue, vikeI18n],
|
|
105
|
+
|
|
106
|
+
i18n: {
|
|
107
|
+
defaultLocale: 'en',
|
|
108
|
+
locales: ['en', 'ru'],
|
|
109
|
+
prefixDefaultLocale: true,
|
|
110
|
+
routes: {
|
|
111
|
+
'/': { en: '/', ru: '/' },
|
|
112
|
+
'/about': { en: '/about', ru: '/o-nas' },
|
|
113
|
+
},
|
|
114
|
+
} satisfies I18nConfig,
|
|
115
|
+
} satisfies Config
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
With that config:
|
|
119
|
+
|
|
120
|
+
- `/en/about` resolves to canonical route `/about`
|
|
121
|
+
- `/ru/o-nas` resolves to canonical route `/about`
|
|
122
|
+
- `/about` redirects to `/en/about`
|
|
123
|
+
- `/ru/about` redirects to `/ru/o-nas`
|
|
124
|
+
|
|
125
|
+
Your page files still use the canonical route structure:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
pages/
|
|
129
|
+
index/+Page.vue
|
|
130
|
+
about/+Page.vue
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## How It Works
|
|
134
|
+
|
|
135
|
+
The plugin runs in `onBeforeRoute` and:
|
|
136
|
+
|
|
137
|
+
1. Detects the active locale
|
|
138
|
+
2. Resolves the incoming localized path to a canonical route path
|
|
139
|
+
3. Redirects to the correct localized URL when needed
|
|
140
|
+
4. Exposes `pageContext.locale` and `pageContext.canonical`
|
|
141
|
+
|
|
142
|
+
It also writes the resolved locale to a cookie during render unless cookie support is disabled.
|
|
143
|
+
|
|
144
|
+
> [!TIP]
|
|
145
|
+
> Your actual page files stay canonical. Only the public URLs are localized.
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
### `I18nConfig`
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
type I18nConfig = {
|
|
153
|
+
defaultLocale: string
|
|
154
|
+
locales: string[] | Record<string, { urlPrefix: string }>
|
|
155
|
+
routes: Record<string, Record<string, string>>
|
|
156
|
+
prefixDefaultLocale?: boolean
|
|
157
|
+
domains?: Record<string, DomainConfig>
|
|
158
|
+
domainDetector?: (context: DetectorContext) => string | null | undefined
|
|
159
|
+
localeDetector?: (context: DetectorContext) => string | null | undefined
|
|
160
|
+
localeCookie?: string | false
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Locales
|
|
165
|
+
|
|
166
|
+
Simple array form:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
locales: ['en', 'ru', 'fr']
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Object form when you want explicit URL prefixes:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
locales: {
|
|
176
|
+
en: { urlPrefix: 'en' },
|
|
177
|
+
ru: { urlPrefix: 'ru' },
|
|
178
|
+
fr: { urlPrefix: 'fr' },
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Routes
|
|
183
|
+
|
|
184
|
+
Route keys are canonical app routes. Values are locale-specific public URLs.
|
|
185
|
+
|
|
186
|
+
This means your code can keep using one stable route key while users see translated URLs.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
routes: {
|
|
190
|
+
'/': {
|
|
191
|
+
en: '/',
|
|
192
|
+
ru: '/',
|
|
193
|
+
fr: '/',
|
|
194
|
+
},
|
|
195
|
+
'/about': {
|
|
196
|
+
en: '/about',
|
|
197
|
+
ru: '/o-nas',
|
|
198
|
+
fr: '/a-propos',
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `prefixDefaultLocale`
|
|
204
|
+
|
|
205
|
+
When `true`:
|
|
206
|
+
|
|
207
|
+
- `/about` redirects to `/en/about`
|
|
208
|
+
|
|
209
|
+
When `false`:
|
|
210
|
+
|
|
211
|
+
- `/about` stays `/about`
|
|
212
|
+
- `/en/about` redirects back to `/about`
|
|
213
|
+
|
|
214
|
+
## Dynamic Routes
|
|
215
|
+
|
|
216
|
+
Dynamic patterns are supported through `path-to-regexp`.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
routes: {
|
|
220
|
+
'/services/:category{/:tab}': {
|
|
221
|
+
en: '/services/:category{/:tab}',
|
|
222
|
+
ru: '/uslugi/:category{/:tab}',
|
|
223
|
+
fr: '/services-fr/:category{/:tab}',
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
|
|
230
|
+
- `/ru/uslugi/design` -> canonical `/services/design`
|
|
231
|
+
- `/ru/uslugi/design/specs` -> canonical `/services/design/specs`
|
|
232
|
+
|
|
233
|
+
> [!NOTE]
|
|
234
|
+
> Dynamic route matching and generation are powered by `path-to-regexp`.
|
|
235
|
+
|
|
236
|
+
## Slug Variants
|
|
237
|
+
|
|
238
|
+
Use `setRouteSlugVariants()` when a dynamic param should have locale-specific slug values.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { setRouteSlugVariants } from 'vike-i18n-routing'
|
|
242
|
+
|
|
243
|
+
setRouteSlugVariants('category', {
|
|
244
|
+
en: 'web-development',
|
|
245
|
+
ru: 'veb-razrabotka',
|
|
246
|
+
fr: 'developpement-web',
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Then:
|
|
251
|
+
|
|
252
|
+
- canonical `/services/web-development`
|
|
253
|
+
- RU localized `/ru/uslugi/veb-razrabotka`
|
|
254
|
+
- FR localized `/services-fr/developpement-web`
|
|
255
|
+
|
|
256
|
+
## Domain-Based Locale Config
|
|
257
|
+
|
|
258
|
+
You can override locales and default locale per domain.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
i18n: {
|
|
262
|
+
defaultLocale: 'en',
|
|
263
|
+
locales: ['en', 'ru', 'fr'],
|
|
264
|
+
prefixDefaultLocale: true,
|
|
265
|
+
domains: {
|
|
266
|
+
'site.com': {
|
|
267
|
+
defaultLocale: 'en',
|
|
268
|
+
locales: ['en', 'ru'],
|
|
269
|
+
},
|
|
270
|
+
'site.fr': {
|
|
271
|
+
defaultLocale: 'fr',
|
|
272
|
+
locales: ['fr', 'en'],
|
|
273
|
+
prefixDefaultLocale: false,
|
|
274
|
+
meta: {
|
|
275
|
+
supportedAuthCountries: ['fr', 'uk', 'ru'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
routes: {
|
|
280
|
+
'/': { en: '/', ru: '/', fr: '/' },
|
|
281
|
+
'/about': { en: '/about', ru: '/o-nas', fr: '/a-propos' },
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The resolved domain metadata is exposed on `pageContext.i18nDomain`.
|
|
287
|
+
|
|
288
|
+
## Locale Detection
|
|
289
|
+
|
|
290
|
+
For unprefixed requests, locale resolution checks candidates in this order:
|
|
291
|
+
|
|
292
|
+
1. Custom `localeDetector(context)`
|
|
293
|
+
2. `?locale=...`
|
|
294
|
+
3. `?lang=...`
|
|
295
|
+
4. Locale cookie
|
|
296
|
+
5. `session.locale`
|
|
297
|
+
6. `Accept-Language`
|
|
298
|
+
7. `defaultLocale`
|
|
299
|
+
|
|
300
|
+
This keeps unprefixed requests usable while still normalizing users onto the correct localized URL shape.
|
|
301
|
+
|
|
302
|
+
You can also override domain detection:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
domainDetector(context) {
|
|
306
|
+
return context.headers.host as string
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Cookie Behavior
|
|
311
|
+
|
|
312
|
+
By default, the plugin stores the active locale in a cookie named `i18n-locale`.
|
|
313
|
+
|
|
314
|
+
Set a custom name:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
localeCookie: 'locale'
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Disable cookie writes entirely:
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
localeCookie: false
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The default cookie name is `i18n-locale`.
|
|
327
|
+
|
|
328
|
+
## Runtime Helpers
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import {
|
|
332
|
+
createI18nRouter,
|
|
333
|
+
getAlternates,
|
|
334
|
+
resolveCanonical,
|
|
335
|
+
resolveI18nRoute,
|
|
336
|
+
setRouteSlugVariants,
|
|
337
|
+
toCanonicalUrl,
|
|
338
|
+
toLocalizedUrl,
|
|
339
|
+
toRouteUrl,
|
|
340
|
+
} from 'vike-i18n-routing'
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### `toLocalizedUrl()`
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
toLocalizedUrl('/about', 'ru', i18n)
|
|
347
|
+
// /ru/o-nas
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
You can also omit the locale and let it infer from the current URL/context:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
toLocalizedUrl('/about', i18n, { context })
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### `toCanonicalUrl()`
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
toCanonicalUrl('/ru/o-nas', i18n)
|
|
360
|
+
// /en/about when prefixDefaultLocale === true
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### `toRouteUrl()`
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
toRouteUrl('/ru/o-nas', i18n)
|
|
367
|
+
// /about
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### `getAlternates()`
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
getAlternates('/about', i18n, { context })
|
|
374
|
+
// [
|
|
375
|
+
// { locale: 'en', url: '/en/about' },
|
|
376
|
+
// { locale: 'ru', url: '/ru/o-nas' }
|
|
377
|
+
// ]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### `createI18nRouter()`
|
|
381
|
+
|
|
382
|
+
Create a reusable router instance if you want to resolve/build URLs repeatedly:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
const router = createI18nRouter(i18n)
|
|
386
|
+
|
|
387
|
+
router.resolve('/ru/o-nas', { context })
|
|
388
|
+
router.resolveCanonical('/ru/o-nas', context)
|
|
389
|
+
router.resolveLocalizedPath('/about', 'ru', context)
|
|
390
|
+
router.getAlternates('/about', context)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Page Context
|
|
394
|
+
|
|
395
|
+
The plugin adds:
|
|
396
|
+
|
|
397
|
+
- `pageContext.locale`
|
|
398
|
+
- `pageContext.canonical`
|
|
399
|
+
- `pageContext.i18nDomain`
|
|
400
|
+
|
|
401
|
+
Example:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
const locale = pageContext.locale
|
|
405
|
+
const canonicalRoute = pageContext.canonical
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## Example
|
|
409
|
+
|
|
410
|
+
A basic example app is available in [`examples/basic`](./examples/basic).
|
|
411
|
+
|
|
412
|
+
Run it with:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
pnpm example
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## Development
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
pnpm test
|
|
422
|
+
pnpm typecheck
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Current Scope
|
|
426
|
+
|
|
427
|
+
This package currently focuses on:
|
|
428
|
+
|
|
429
|
+
- Vike route resolution
|
|
430
|
+
- Localized path generation
|
|
431
|
+
- Redirect normalization
|
|
432
|
+
- Per-domain locale rules
|
|
433
|
+
- Cookie-backed locale persistence
|
|
434
|
+
|
|
435
|
+
It does not yet include built-in HTML SEO tag generation or full documentation site tooling.
|
|
436
|
+
|
|
437
|
+
## Why This Package
|
|
438
|
+
|
|
439
|
+
If you want Vike routes to:
|
|
440
|
+
|
|
441
|
+
- stay canonical in your app code
|
|
442
|
+
- render translated public URLs per locale
|
|
443
|
+
- redirect users to the correct locale/path combination
|
|
444
|
+
- support domain-specific locale rules
|
|
445
|
+
|
|
446
|
+
this package is the layer that handles that routing logic.
|
|
447
|
+
|
|
448
|
+
## License
|
|
449
|
+
|
|
450
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { I18nConfig, LocaleCookieAction } from './types'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COOKIE_NAME = 'i18n-locale'
|
|
4
|
+
|
|
5
|
+
// Resolves the cookie write action for the current locale.
|
|
6
|
+
export function resolveCookieAction(
|
|
7
|
+
locale: string,
|
|
8
|
+
i18n: I18nConfig,
|
|
9
|
+
): LocaleCookieAction | null {
|
|
10
|
+
if (i18n.localeCookie === false) return null
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: i18n.localeCookie ?? DEFAULT_COOKIE_NAME,
|
|
14
|
+
value: locale,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Serializes the locale cookie action into a Set-Cookie header value.
|
|
19
|
+
export function createSetCookieHeader(action: LocaleCookieAction): string {
|
|
20
|
+
return `${action.name}=${encodeURIComponent(action.value)}; Path=/; SameSite=Lax; HttpOnly`
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DetectorContext, I18nConfig } from '../types'
|
|
2
|
+
|
|
3
|
+
// Resolves the current domain from a custom detector, explicit context, or request host.
|
|
4
|
+
export function detectDomain(context: DetectorContext, i18n: I18nConfig): string | undefined {
|
|
5
|
+
const custom = i18n.domainDetector?.(context)
|
|
6
|
+
if (custom) return custom.toLowerCase()
|
|
7
|
+
if (context.domain) return context.domain.toLowerCase()
|
|
8
|
+
|
|
9
|
+
const hostHeader = context.headers.host
|
|
10
|
+
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader
|
|
11
|
+
if (host) return host.split(':')[0].toLowerCase()
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
return new URL(context.url, 'http://localhost').hostname.toLowerCase() || undefined
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { detectDomain } from './detector'
|
|
2
|
+
import { normalizeLocales } from '../locale/normalize'
|
|
3
|
+
import type { DetectorContext, I18nConfig, ResolvedDomainConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
// Resolves the effective domain config by combining global defaults with domain overrides.
|
|
6
|
+
export function resolveDomainConfig(
|
|
7
|
+
i18n: I18nConfig,
|
|
8
|
+
context: DetectorContext,
|
|
9
|
+
): ResolvedDomainConfig {
|
|
10
|
+
const baseLocales = normalizeLocales(i18n.locales)
|
|
11
|
+
const domain = detectDomain(context, i18n)
|
|
12
|
+
const domainConfig = domain ? i18n.domains?.[domain] : undefined
|
|
13
|
+
const locales = domainConfig?.locales
|
|
14
|
+
? normalizeLocales(domainConfig.locales)
|
|
15
|
+
: baseLocales
|
|
16
|
+
const defaultLocale = domainConfig?.defaultLocale ?? i18n.defaultLocale
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
domain,
|
|
20
|
+
defaultLocale,
|
|
21
|
+
locales,
|
|
22
|
+
prefixDefaultLocale: domainConfig?.prefixDefaultLocale ?? i18n.prefixDefaultLocale !== false,
|
|
23
|
+
meta: domainConfig?.meta,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolveDomainConfig } from '../domain/normalize'
|
|
2
|
+
import { normalizeLocales } from './normalize'
|
|
3
|
+
import type { DetectorContext, I18nConfig, LocaleCode } from '../types'
|
|
4
|
+
|
|
5
|
+
// Parses a Cookie header string into a name/value map.
|
|
6
|
+
export function parseCookies(cookieHeader: string): Record<string, string> {
|
|
7
|
+
return Object.fromEntries(
|
|
8
|
+
cookieHeader
|
|
9
|
+
.split(';')
|
|
10
|
+
.map((part) => part.trim().split('='))
|
|
11
|
+
.filter(([key]) => key)
|
|
12
|
+
.map(([key, ...rest]) => [key.trim(), decodeURIComponent(rest.join('=').trim())]),
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parses Accept-Language into locale candidates ordered by quality value.
|
|
17
|
+
function parseAcceptLanguage(header: string | undefined): string[] {
|
|
18
|
+
if (!header) return []
|
|
19
|
+
|
|
20
|
+
return header
|
|
21
|
+
.split(',')
|
|
22
|
+
.map((part) => {
|
|
23
|
+
const [tag, qValue] = part.trim().split(';q=')
|
|
24
|
+
return {
|
|
25
|
+
locale: tag.toLowerCase(),
|
|
26
|
+
weight: qValue ? Number(qValue) : 1,
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.filter((item) => item.locale)
|
|
30
|
+
.sort((a, b) => b.weight - a.weight)
|
|
31
|
+
.flatMap((item) => {
|
|
32
|
+
const base = item.locale.split('-')[0]
|
|
33
|
+
return base && base !== item.locale ? [item.locale, base] : [item.locale]
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reads the configured locale cookie from the request context.
|
|
38
|
+
function resolveCookieLocale(context: DetectorContext, i18n: I18nConfig): string | undefined {
|
|
39
|
+
if (i18n.localeCookie === false) return undefined
|
|
40
|
+
const cookieName = i18n.localeCookie ?? 'i18n-locale'
|
|
41
|
+
return context.cookies[cookieName]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolves the best locale for an unprefixed request.
|
|
45
|
+
export function runLocaleDetector(
|
|
46
|
+
context: DetectorContext,
|
|
47
|
+
i18n: I18nConfig,
|
|
48
|
+
): LocaleCode {
|
|
49
|
+
const resolvedDomain = resolveDomainConfig(i18n, context)
|
|
50
|
+
const locales = normalizeLocales(resolvedDomain.locales)
|
|
51
|
+
|
|
52
|
+
const validate = (locale: string | null | undefined): LocaleCode | null => {
|
|
53
|
+
if (locale && locales[locale]) return locale
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const candidates: Array<string | null | undefined> = []
|
|
58
|
+
|
|
59
|
+
if (i18n.localeDetector) {
|
|
60
|
+
candidates.push(i18n.localeDetector(context))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
candidates.push(
|
|
64
|
+
context.searchParams.get('locale'),
|
|
65
|
+
context.searchParams.get('lang'),
|
|
66
|
+
resolveCookieLocale(context, i18n),
|
|
67
|
+
context.session?.locale,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const acceptLanguage = context.headers['accept-language']
|
|
71
|
+
const headerValue = Array.isArray(acceptLanguage) ? acceptLanguage[0] : acceptLanguage
|
|
72
|
+
candidates.push(...parseAcceptLanguage(headerValue))
|
|
73
|
+
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
const detected = validate(candidate)
|
|
76
|
+
if (detected) return detected
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return resolvedDomain.defaultLocale
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LocaleCode, LocaleConfig, LocaleConfigs } from '../types'
|
|
2
|
+
|
|
3
|
+
// Normalizes locale config to the internal record form.
|
|
4
|
+
export function normalizeLocales(locales: LocaleConfigs): Record<LocaleCode, LocaleConfig> {
|
|
5
|
+
if (Array.isArray(locales)) {
|
|
6
|
+
return Object.fromEntries(locales.map((code) => [code, { urlPrefix: code }]))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return locales
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createI18nRouter, setRouteSlugVariants } from './router'
|
|
2
|
+
import type {
|
|
3
|
+
DetectorContext,
|
|
4
|
+
I18nConfig,
|
|
5
|
+
LocaleCode,
|
|
6
|
+
LocalizedPathOptions,
|
|
7
|
+
ResolveRouteOptions,
|
|
8
|
+
} from './types'
|
|
9
|
+
|
|
10
|
+
export { setRouteSlugVariants }
|
|
11
|
+
|
|
12
|
+
// Resolves an incoming pathname to locale, canonical route, and redirect metadata.
|
|
13
|
+
export function resolveI18nRoute(
|
|
14
|
+
pathname: string,
|
|
15
|
+
i18n: I18nConfig,
|
|
16
|
+
options: ResolveRouteOptions,
|
|
17
|
+
) {
|
|
18
|
+
return createI18nRouter(i18n).resolve(pathname, options)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Resolves any pathname to its canonical route URL without locale prefixing.
|
|
22
|
+
export function resolveCanonical(
|
|
23
|
+
pathname: string,
|
|
24
|
+
i18n: I18nConfig,
|
|
25
|
+
options: { context: DetectorContext },
|
|
26
|
+
): string {
|
|
27
|
+
return createI18nRouter(i18n).resolveCanonical(pathname, options.context)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Converts a canonical route key into the concrete localized pathname for a locale.
|
|
31
|
+
export function resolveLocalizedPath(
|
|
32
|
+
routeKey: string,
|
|
33
|
+
locale: LocaleCode,
|
|
34
|
+
i18n: I18nConfig,
|
|
35
|
+
context: DetectorContext,
|
|
36
|
+
options?: LocalizedPathOptions,
|
|
37
|
+
): string {
|
|
38
|
+
return createI18nRouter(i18n).resolveLocalizedPath(routeKey, locale, context, options)
|
|
39
|
+
}
|