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 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
+ }