Skip to content

Commit 084df97

Browse files
s00dcursoragent
andcommitted
feat(utils): add splitLocaleRoutes for programmatic pages
One registry → pages + globalLocaleRoutes keyed by route name, so shared wrapper SFCs no longer collapse locale paths. Refs #244 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d2996b1 commit 084df97

10 files changed

Lines changed: 263 additions & 2 deletions

File tree

docs/api/packages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ or CI failing.
3030
| [`@i18n-micro/test-utils`](/api/packages/test-utils) | `@i18n-micro/test-utils`, `@i18n-micro/test-utils/publish-smoke` | 36 |
3131
| [`@i18n-micro/types`](/api/packages/types) | `@i18n-micro/types` | 26 |
3232
| [`@i18n-micro/types-generator`](/api/packages/types-generator) | `@i18n-micro/types-generator`, `@i18n-micro/types-generator/nuxt` | 7 |
33-
| [`@i18n-micro/utils`](/api/packages/utils) | `@i18n-micro/utils/accept-language`, `@i18n-micro/utils/active-locales`, `@i18n-micro/utils/app-path`, `@i18n-micro/utils/auto-detect-path`, `@i18n-micro/utils/build`, `@i18n-micro/utils/cache-control`, `@i18n-micro/utils/cookie`, `@i18n-micro/utils/deep-merge`, `@i18n-micro/utils/merge-i18n-head`, `@i18n-micro/utils/merge-source`, `@i18n-micro/utils/normalize`, `@i18n-micro/utils/parse-path`, `@i18n-micro/utils/payload-config`, `@i18n-micro/utils/payload-fetch`, `@i18n-micro/utils/payload-stats`, `@i18n-micro/utils/payload-url`, `@i18n-micro/utils/resolve-hreflang`, `@i18n-micro/utils/resolve-locale`, `@i18n-micro/utils/resolve-og-locale`, `@i18n-micro/utils/route`, `@i18n-micro/utils/route-pattern`, `@i18n-micro/utils/runtime-config`, `@i18n-micro/utils/source-loader` | 98 |
33+
| [`@i18n-micro/utils`](/api/packages/utils) | `@i18n-micro/utils/accept-language`, `@i18n-micro/utils/active-locales`, `@i18n-micro/utils/app-path`, `@i18n-micro/utils/auto-detect-path`, `@i18n-micro/utils/build`, `@i18n-micro/utils/cache-control`, `@i18n-micro/utils/cookie`, `@i18n-micro/utils/deep-merge`, `@i18n-micro/utils/merge-i18n-head`, `@i18n-micro/utils/merge-source`, `@i18n-micro/utils/normalize`, `@i18n-micro/utils/parse-path`, `@i18n-micro/utils/payload-config`, `@i18n-micro/utils/payload-fetch`, `@i18n-micro/utils/payload-stats`, `@i18n-micro/utils/payload-url`, `@i18n-micro/utils/resolve-hreflang`, `@i18n-micro/utils/resolve-locale`, `@i18n-micro/utils/resolve-og-locale`, `@i18n-micro/utils/route`, `@i18n-micro/utils/route-pattern`, `@i18n-micro/utils/runtime-config`, `@i18n-micro/utils/source-loader`, `@i18n-micro/utils/split-locale-routes` | 102 |
3434
| [`@i18n-micro/vue`](/api/packages/vue) | `@i18n-micro/vue` | 27 |
3535

3636
<!-- /generated:packages-index -->

docs/api/packages/utils.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ outline: 'deep'
66

77
# `@i18n-micro/utils`
88

9-
98 exports across 23 entry points.
9+
102 exports across 24 entry points.
1010
Generated from the API snapshot that [`pnpm run api:surface`](/guide/maintenance-commands#api-surface)
1111
checks against the TypeScript sources.
1212

@@ -548,6 +548,49 @@ import { /* … */ } from '@i18n-micro/utils/source-loader'
548548
| --- | --- |
549549
| `getItem` | `(key: string) => Promise<unknown>` |
550550

551+
</details>
552+
## `@i18n-micro/utils/split-locale-routes`
553+
554+
```ts
555+
import { /**/ } from '@i18n-micro/utils/split-locale-routes'
556+
```
557+
558+
| Export | Kind | Signature |
559+
| --- | --- | --- |
560+
| `SplitLocaleRouteEntry` | interface | 4 members |
561+
| `SplitLocaleRoutePage` | interface | 3 members |
562+
| `splitLocaleRoutes` | function | `(entries: readonly SplitLocaleRouteEntry[]) => SplitLocaleRoutesResult` |
563+
| `SplitLocaleRoutesResult` | interface | 2 members |
564+
565+
<details>
566+
<summary><code>SplitLocaleRouteEntry</code> — 4 members</summary>
567+
568+
| Member | Type |
569+
| --- | --- |
570+
| `file` | `string` |
571+
| `name` | `string` |
572+
| `path` | `string` |
573+
| `paths?` | `false \| Record<string, string> \| undefined` |
574+
575+
</details>
576+
<details>
577+
<summary><code>SplitLocaleRoutePage</code> — 3 members</summary>
578+
579+
| Member | Type |
580+
| --- | --- |
581+
| `file` | `string` |
582+
| `name` | `string` |
583+
| `path` | `string` |
584+
585+
</details>
586+
<details>
587+
<summary><code>SplitLocaleRoutesResult</code> — 2 members</summary>
588+
589+
| Member | Type |
590+
| --- | --- |
591+
| `globalLocaleRoutes` | `Record<string, boolean \| Record<string, string>>` |
592+
| `pages` | `SplitLocaleRoutePage[]` |
593+
551594
</details>
552595

553596
Back to [all packages](/api/packages) · [Integration guides](/integrations/)

docs/guide/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,11 @@ globalLocaleRoutes: {
428428
'unlocalized': false // Disable localization entirely
429429
}
430430
```
431+
432+
::: tip Programmatic routes
433+
For routes added in `pages:extend` (especially many sharing one wrapper SFC), build both `pages` and `globalLocaleRoutes` from one list with [`splitLocaleRoutes`](/guide/custom-locale-routes#programmatic-routes-pagesextend--244) — keyed by route **name**, not file path.
434+
:::
435+
431436
#### `routesLocaleLinks`
432437

433438
<!-- generated:option:routesLocaleLinks — do not edit; run `pnpm run docs:generate` -->

docs/guide/custom-locale-routes.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,51 @@ $defineI18nRoute({
373373

374374
- **Catch-all Routes**: For catch-all routes (`[...slug]`), the slug parameter should be an array or a string that will be converted to an array.
375375

376+
## 🧩 Programmatic routes (`pages:extend`) — #244
377+
378+
Routes created in `pages:extend` have no place for `defineI18nRoute()`, and several routes often share one wrapper SFC. File-keyed scanning then collapses them onto a single entry.
379+
380+
Do **not** put localized paths in `route.meta` (they would ship in the client route table). Keep one registry and project it twice:
381+
382+
1. into `pages:extend` (creates the Nuxt routes)
383+
2. into `i18n.globalLocaleRoutes` keyed by route **name** (not file path)
384+
385+
Use `splitLocaleRoutes` from `@i18n-micro/utils/split-locale-routes`:
386+
387+
```typescript
388+
import { splitLocaleRoutes } from '@i18n-micro/utils/split-locale-routes'
389+
390+
const dynamic = splitLocaleRoutes([
391+
{
392+
name: 'products_tag',
393+
path: '/products/:tag',
394+
file: '~/pages/_dynamic.vue',
395+
paths: { fr: '/produits/:tag', de: '/produkte/:tag' },
396+
},
397+
{
398+
name: 'products_category',
399+
path: '/products/category/:category',
400+
file: '~/pages/_dynamic.vue',
401+
paths: { fr: '/produits/categorie/:category' },
402+
},
403+
// Disable localization for a programmatic route:
404+
// { name: 'internal', path: '/internal', file: '~/pages/_dynamic.vue', paths: false },
405+
])
406+
407+
export default defineNuxtConfig({
408+
hooks: {
409+
'pages:extend'(pages) {
410+
pages.push(...dynamic.pages)
411+
},
412+
},
413+
i18n: {
414+
globalLocaleRoutes: dynamic.globalLocaleRoutes,
415+
},
416+
})
417+
```
418+
419+
Both halves stay in sync; shared wrappers no longer overwrite each other. `globalLocaleRoutes` alone is not enough — it only customizes paths for routes that already exist in the Nuxt page table.
420+
376421
## 📝 Best Practices for Using `localeRoutes`
377422

378423
- **🚀 Use for Relevant Locales**: Apply `localeRoutes` primarily where the URL structure significantly impacts the user experience or SEO. Avoid overuse for minor differences.

packages/utils/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,21 @@
180180
},
181181
"default": "./dist/auto-detect-path.mjs"
182182
},
183+
"./split-locale-routes": {
184+
"production": {
185+
"types": "./dist/split-locale-routes.d.ts",
186+
"default": "./dist/split-locale-routes.mjs"
187+
},
188+
"import": {
189+
"types": "./dist/split-locale-routes.d.ts",
190+
"default": "./dist/split-locale-routes.mjs"
191+
},
192+
"require": {
193+
"types": "./dist/split-locale-routes.d.cts",
194+
"default": "./dist/split-locale-routes.cjs"
195+
},
196+
"default": "./dist/split-locale-routes.mjs"
197+
},
183198
"./resolve-og-locale": {
184199
"production": {
185200
"types": "./dist/resolve-og-locale.d.ts",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { GlobalLocaleRoutes, LocaleCode } from '@i18n-micro/types'
2+
3+
/**
4+
* One programmatic route entry for {@link splitLocaleRoutes} (#244).
5+
* Prefer route `name` as the `globalLocaleRoutes` key so shared wrapper SFCs do not collapse.
6+
*/
7+
export interface SplitLocaleRouteEntry {
8+
/** Route name — also the key in `globalLocaleRoutes`. */
9+
name: string
10+
/** Default (unlocalized) path pushed into `pages:extend`. */
11+
path: string
12+
/** Page component / wrapper SFC (Nuxt `file`). */
13+
file: string
14+
/**
15+
* Custom per-locale paths, or `false` to disable localization for this route.
16+
* Omitted → route is still pushed to `pages`, but no `globalLocaleRoutes` entry.
17+
*/
18+
paths?: Record<LocaleCode, string> | false
19+
}
20+
21+
export interface SplitLocaleRoutePage {
22+
name: string
23+
path: string
24+
file: string
25+
}
26+
27+
export interface SplitLocaleRoutesResult {
28+
/** Pass into `pages:extend`: `pages.push(...pages)`. */
29+
pages: SplitLocaleRoutePage[]
30+
/** Merge into `i18n.globalLocaleRoutes` (keyed by route name). */
31+
globalLocaleRoutes: NonNullable<GlobalLocaleRoutes>
32+
}
33+
34+
/**
35+
* Split one programmatic route registry into Nuxt pages + `globalLocaleRoutes` (#244).
36+
*
37+
* Keeps both halves in sync and keys locale paths by route **name** (not file path),
38+
* so N routes sharing one wrapper SFC do not overwrite each other.
39+
*/
40+
export function splitLocaleRoutes(entries: readonly SplitLocaleRouteEntry[]): SplitLocaleRoutesResult {
41+
const pages: SplitLocaleRoutePage[] = []
42+
const globalLocaleRoutes: NonNullable<GlobalLocaleRoutes> = {}
43+
const seen = new Set<string>()
44+
45+
for (const entry of entries) {
46+
const name = entry.name?.trim()
47+
if (!name) {
48+
throw new Error('[splitLocaleRoutes] each entry needs a non-empty `name`')
49+
}
50+
if (!entry.path) {
51+
throw new Error(`[splitLocaleRoutes] entry "${name}" needs a \`path\``)
52+
}
53+
if (!entry.file) {
54+
throw new Error(`[splitLocaleRoutes] entry "${name}" needs a \`file\``)
55+
}
56+
if (seen.has(name)) {
57+
throw new Error(`[splitLocaleRoutes] duplicate route name "${name}"`)
58+
}
59+
seen.add(name)
60+
61+
pages.push({ name, path: entry.path, file: entry.file })
62+
63+
if (entry.paths !== undefined) {
64+
globalLocaleRoutes[name] = entry.paths
65+
}
66+
}
67+
68+
return { pages, globalLocaleRoutes }
69+
}

packages/utils/tests/publish/subpaths.publish.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const subpaths = [
1616
{ subpath: './parse-path', exportName: 'parseTranslationRelativePath' },
1717
{ subpath: './accept-language', exportName: 'detectLocaleFromAcceptLanguage' },
1818
{ subpath: './auto-detect-path', exportName: 'shouldAttemptLocaleRedirect' },
19+
{ subpath: './split-locale-routes', exportName: 'splitLocaleRoutes' },
1920
{ subpath: './runtime-config', exportName: 'resolveI18nConfigWithRuntimeOverrides' },
2021
] as const
2122

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { splitLocaleRoutes } from '../src/split-locale-routes'
3+
4+
describe('splitLocaleRoutes (#244)', () => {
5+
test('splits one registry into pages + globalLocaleRoutes keyed by name', () => {
6+
const result = splitLocaleRoutes([
7+
{
8+
name: 'products_tag',
9+
path: '/products/:tag',
10+
file: '~/pages/_dynamic.vue',
11+
paths: { fr: '/produits/:tag', de: '/produkte/:tag' },
12+
},
13+
{
14+
name: 'products_category',
15+
path: '/products/category/:category',
16+
file: '~/pages/_dynamic.vue',
17+
paths: { fr: '/produits/categorie/:category' },
18+
},
19+
])
20+
21+
expect(result.pages).toEqual([
22+
{ name: 'products_tag', path: '/products/:tag', file: '~/pages/_dynamic.vue' },
23+
{ name: 'products_category', path: '/products/category/:category', file: '~/pages/_dynamic.vue' },
24+
])
25+
expect(result.globalLocaleRoutes).toEqual({
26+
products_tag: { fr: '/produits/:tag', de: '/produkte/:tag' },
27+
products_category: { fr: '/produits/categorie/:category' },
28+
})
29+
})
30+
31+
test('shared wrapper file does not collapse locale paths', () => {
32+
const shared = '~/pages/_dynamic.vue'
33+
const { pages, globalLocaleRoutes } = splitLocaleRoutes([
34+
{ name: 'a', path: '/a', file: shared, paths: { fr: '/aa' } },
35+
{ name: 'b', path: '/b', file: shared, paths: { fr: '/bb' } },
36+
])
37+
38+
expect(pages).toHaveLength(2)
39+
expect(pages[0]!.file).toBe(pages[1]!.file)
40+
expect(globalLocaleRoutes.a).toEqual({ fr: '/aa' })
41+
expect(globalLocaleRoutes.b).toEqual({ fr: '/bb' })
42+
})
43+
44+
test('supports false to disable localization; omits paths when unset', () => {
45+
const { pages, globalLocaleRoutes } = splitLocaleRoutes([
46+
{ name: 'plain', path: '/plain', file: '~/pages/plain.vue' },
47+
{ name: 'fixed', path: '/fixed', file: '~/pages/fixed.vue', paths: false },
48+
])
49+
50+
expect(pages.map((p) => p.name)).toEqual(['plain', 'fixed'])
51+
expect(globalLocaleRoutes).toEqual({ fixed: false })
52+
expect(globalLocaleRoutes).not.toHaveProperty('plain')
53+
})
54+
55+
test('throws on duplicate or incomplete entries', () => {
56+
expect(() =>
57+
splitLocaleRoutes([
58+
{ name: 'x', path: '/x', file: '~/x.vue' },
59+
{ name: 'x', path: '/y', file: '~/y.vue' },
60+
]),
61+
).toThrow(/duplicate route name "x"/)
62+
63+
expect(() => splitLocaleRoutes([{ name: '', path: '/x', file: '~/x.vue' }])).toThrow(/non-empty `name`/)
64+
expect(() => splitLocaleRoutes([{ name: 'x', path: '', file: '~/x.vue' }])).toThrow(/needs a `path`/)
65+
expect(() => splitLocaleRoutes([{ name: 'x', path: '/x', file: '' }])).toThrow(/needs a `file`/)
66+
})
67+
})

packages/utils/vite.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const libEntries = {
1717
'accept-language': resolve(rootDir, 'src/accept-language.ts'),
1818
'resolve-locale': resolve(rootDir, 'src/resolve-locale.ts'),
1919
'auto-detect-path': resolve(rootDir, 'src/auto-detect-path.ts'),
20+
'split-locale-routes': resolve(rootDir, 'src/split-locale-routes.ts'),
2021
'resolve-og-locale': resolve(rootDir, 'src/resolve-og-locale.ts'),
2122
'resolve-hreflang': resolve(rootDir, 'src/resolve-hreflang.ts'),
2223
'merge-i18n-head': resolve(rootDir, 'src/merge-i18n-head.ts'),

scripts/api-surface/i18n-micro__utils.api.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,18 @@ member RuntimeI18nOverrides.strategy?: string | undefined
229229
function loadSourceTranslationsFromStorage: (storage: TranslationStorageReader, input: Omit<MergeSourceTranslationsInput, "readLocaleFile">) => Promise<Translations>
230230
interface TranslationStorageReader
231231
member TranslationStorageReader.getItem: (key: string) => Promise<unknown>
232+
233+
# ./split-locale-routes (src/split-locale-routes.ts)
234+
interface SplitLocaleRouteEntry
235+
member SplitLocaleRouteEntry.file: string
236+
member SplitLocaleRouteEntry.name: string
237+
member SplitLocaleRouteEntry.path: string
238+
member SplitLocaleRouteEntry.paths?: false | Record<string, string> | undefined
239+
interface SplitLocaleRoutePage
240+
member SplitLocaleRoutePage.file: string
241+
member SplitLocaleRoutePage.name: string
242+
member SplitLocaleRoutePage.path: string
243+
function splitLocaleRoutes: (entries: readonly SplitLocaleRouteEntry[]) => SplitLocaleRoutesResult
244+
interface SplitLocaleRoutesResult
245+
member SplitLocaleRoutesResult.globalLocaleRoutes: Record<string, boolean | Record<string, string>>
246+
member SplitLocaleRoutesResult.pages: SplitLocaleRoutePage[]

0 commit comments

Comments
 (0)