Skip to content

Commit 1d37825

Browse files
authored
fix: derive route resources from route localization (#4047)
1 parent 617c824 commit 1d37825

10 files changed

Lines changed: 295 additions & 198 deletions

File tree

internals.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ declare module '#internal/i18n-type-generation-options' {
2929
}
3030

3131
declare module '#build/i18n-route-resources.mjs' {
32+
export const localizedPaths: string[]
3233
export const i18nPathToPath: Record<string, string>
33-
export const pathToI18nConfig: Record<string, Record<string, string | boolean>>
34-
export const disabledI18nPathToPath: Record<string, string>
34+
export const pathToI18nConfig: Record<string, Record<string, string | false>>
35+
export const disabledPaths: string[]
3536
}

src/kit/gen.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const join = (...args: (string | undefined)[]) => args.filter(Boolean).join('')
88
export interface ComputedRouteOptions {
99
locales: readonly string[]
1010
paths: Record<string, string>
11-
srcPaths?: Record<string, string>
1211
}
1312

1413
/**
@@ -39,6 +38,19 @@ export type LocalizeRouteParams = {
3938
parent?: LocalizableRoute
4039
/** localized parent route */
4140
parentLocalized?: LocalizableRoute
41+
/** accumulated full path of the parent route */
42+
parentPath?: string
43+
}
44+
45+
/**
46+
* Compose a child path with its parent's full path, absolute child paths are kept as-is
47+
* (matching how Vue Router resolves nested paths).
48+
*/
49+
export function joinPath(parent: string | undefined, path: string): string {
50+
const full = (!parent || path.startsWith('/'))
51+
? path
52+
: (parent === '/' ? '' : parent) + (path ? '/' + path : '')
53+
return full.replace(/\/+$/, '') || '/'
4254
}
4355

4456
function handlePathNesting(localizedPath: string, parentLocalizedPath: string = '') {
@@ -80,7 +92,13 @@ function createLocalizeAliases(ctx: RouteContext): RouteContext['localizeAliases
8092

8193
function createLocalizeChildren(ctx: RouteContext): RouteContext['localizeChildren'] {
8294
return (route: LocalizableRoute, parentLocalized: LocalizableRoute, locale: string, opts: LocalizeRouteParams) => {
83-
const localizeParams = { ...opts, parent: route, locales: [locale], parentLocalized }
95+
const localizeParams = {
96+
...opts,
97+
parent: route,
98+
locales: [locale],
99+
parentLocalized,
100+
parentPath: joinPath(opts.parentPath, route.path),
101+
}
84102
return route.children?.flatMap(child => localizeSingleRoute(child, localizeParams, ctx)) ?? []
85103
}
86104
}
@@ -108,6 +126,7 @@ export function localizeSingleRoute(
108126
): LocalizableRoute[] {
109127
// resolve custom route (config/page) options
110128
const routeOptions = ctx.optionsResolver(route, options.locales)
129+
ctx.onLocalize?.(route, routeOptions, options)
111130
if (!routeOptions) {
112131
return [route]
113132
}
@@ -118,7 +137,11 @@ export function localizeSingleRoute(
118137
&& canCompactRoute(routeOptions, options.locales)
119138
&& canCompactChildren(route.children, options.locales, ctx)) {
120139
const compacted = ctx.compactRoute(route, routeOptions, options)
121-
if (compacted) { return compacted }
140+
if (compacted) {
141+
// compaction keeps children as-is instead of walking them, report them here
142+
reportSkippedChildren(route, options, ctx)
143+
return compacted
144+
}
122145
}
123146

124147
const resultRoutes: LocalizableRoute[] = []
@@ -139,6 +162,19 @@ export function localizeSingleRoute(
139162
return resultRoutes
140163
}
141164

165+
/**
166+
* Fire `onLocalize` for descendants of a compacted route — these are compact-eligible
167+
* (all locales, no custom paths) so their localized paths are identical to their plain paths.
168+
*/
169+
function reportSkippedChildren(route: LocalizableRoute, options: LocalizeRouteParams, ctx: RouteContext): void {
170+
if (ctx.onLocalize == null || !route.children?.length) { return }
171+
const opts = { ...options, parentPath: joinPath(options.parentPath, route.path) }
172+
for (const child of route.children) {
173+
ctx.onLocalize(child, ctx.optionsResolver(child, options.locales), opts)
174+
reportSkippedChildren(child, opts, ctx)
175+
}
176+
}
177+
142178
type LocalizerData = {
143179
route: LocalizableRoute
144180
prefixed: string
@@ -163,6 +199,12 @@ export type RouteContext = {
163199
localizeRouteName: (name: LocalizableRoute, locale: string, isDefault: boolean) => string | undefined
164200
handleTrailingSlash: (localizedPath: string, hasParent: boolean) => string
165201
localizers: { enabled: (data: LocalizerData) => boolean, localizer: LocalizerFn }[]
202+
/** Called for each route visited during localization, before locale variants are produced. */
203+
onLocalize?: (
204+
route: LocalizableRoute,
205+
routeOptions: ComputedRouteOptions | undefined,
206+
options: LocalizeRouteParams,
207+
) => void
166208
/** When set, eligible routes are compacted into a single regex-prefixed route instead of per-locale duplicates. */
167209
compactRoute?: (
168210
route: LocalizableRoute,
@@ -206,9 +248,11 @@ export function createRouteContext(opts: {
206248
optionsResolver?: RouteOptionsResolver
207249
routesNameSeparator?: string
208250
defaultLocaleRouteNameSuffix?: string
251+
onLocalize?: RouteContext['onLocalize']
209252
}) {
210253
const ctx = { localizers: [] as RouteContext['localizers'] } as RouteContext
211254
ctx.trailingSlash = opts.trailingSlash ?? false
255+
ctx.onLocalize = opts.onLocalize
212256
ctx.isDefaultLocale = (locale: string) => opts.defaultLocales.includes(locale)
213257
ctx.localizeRouteName = createLocalizeRouteName(opts)
214258
ctx.optionsResolver = createDefaultOptionsResolver(opts)

src/pages.ts

Lines changed: 14 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { parse as parseSFC } from '@vue/compiler-sfc'
55
import { parseAndWalk } from 'oxc-walker'
66
import { mkdir, readFile, writeFile } from 'node:fs/promises'
77
import { parseSegment, toVueRouterSegment } from 'unrouting'
8-
import { localizeRoutes, shouldLocalizeRoutes } from './routing'
8+
import { createRouteResourcesCollector, localizeRoutes } from './routing'
9+
import type { RouteResources } from './routing'
910
import { logger } from './utils'
1011
import { dirname, parse as parsePath, resolve } from 'pathe'
1112
import { createRoutesContext, resolveOptions } from 'vue-router/unplugin'
@@ -15,24 +16,20 @@ import type { Nuxt, NuxtPage, ResolvedNuxtTemplate } from '@nuxt/schema'
1516
import type { EditableTreeNode, Options as TypedRouterOptions } from 'vue-router/unplugin'
1617
import type { NuxtI18nOptions } from './types'
1718
import type { I18nNuxtContext } from './context'
18-
import type { ComputedRouteOptions, LocalizableRoute, RouteOptionsResolver } from './kit/gen'
19+
import type { ComputedRouteOptions, RouteOptionsResolver } from './kit/gen'
1920
import type { I18nRoute } from './runtime/composables'
2021
import { type CallExpression, type ExpressionStatement, type ObjectExpression, parseSync } from 'oxc-parser'
2122

2223
export class NuxtPageAnalyzeContext {
2324
config: NuxtI18nOptions['pages']
2425
pages: Map<string, { path: string, name?: string }> = new Map()
25-
pathToConfig: Record<string, Record<string, string | boolean> | undefined> = {}
26-
fileToPath: Record<string, string> = {}
2726

2827
constructor(config: NuxtI18nOptions['pages']) {
2928
this.config = config || {}
3029
}
3130

3231
addPage(page: NuxtPage, path: string, name?: string) {
3332
this.pages.set(page.file!, { path, name })
34-
const p = path === 'index' ? '/' : '/' + path.replace(/\/index$/, '')
35-
this.fileToPath[page.file!] = p
3633
}
3734
}
3835

@@ -42,20 +39,22 @@ type NarrowedNuxtPage = Omit<NuxtPage, 'redirect' | 'children'> & {
4239
}
4340

4441
export async function setupPages({ localeCodes, options, normalizedLocales }: I18nNuxtContext, nuxt: Nuxt) {
45-
const routeResources = {
46-
i18nPathToPath: {},
42+
let routeResources: RouteResources = {
43+
localizedPaths: [],
4744
pathToI18nConfig: {},
48-
disabledI18nPathToPath: {},
45+
i18nPathToPath: {},
46+
disabledPaths: [],
4947
}
5048

5149
addTemplate({
5250
filename: 'i18n-route-resources.mjs',
5351
write: true,
5452
getContents: () => {
5553
return `// Generated by @nuxtjs/i18n
54+
export const localizedPaths = ${JSON.stringify(routeResources.localizedPaths, null, 2)};
5655
export const pathToI18nConfig = ${JSON.stringify(routeResources.pathToI18nConfig, null, 2)};
57-
export const i18nPathToPath = ${JSON.stringify(routeResources.i18nPathToPath, null, 2)}
58-
export const disabledI18nPathToPath = ${JSON.stringify(routeResources.disabledI18nPathToPath, null, 2)};`
56+
export const i18nPathToPath = ${JSON.stringify(routeResources.i18nPathToPath, null, 2)};
57+
export const disabledPaths = ${JSON.stringify(routeResources.disabledPaths, null, 2)};`
5958
},
6059
})
6160
if (!localeCodes.length) { return }
@@ -104,21 +103,18 @@ export const disabledI18nPathToPath = ${JSON.stringify(routeResources.disabledI1
104103
normalizeRouteMeta(ctx, pages, localeCodes, options.customRoutes ?? 'page', nuxt.vfs)
105104

106105
const resolver = createPureOptionsResolver(ctx, options.defaultLocale, options.customRoutes)
106+
const resources = createRouteResourcesCollector()
107107

108108
const localizationOptions = {
109109
...options,
110110
locales: normalizedLocales,
111111
optionsResolver: resolver,
112112
compactRoutes: !!options.experimental?.compactRoutes,
113+
onLocalize: resources.collect,
113114
}
114115

115116
const localizedPages = localizeRoutes(pages as NarrowedNuxtPage[], localizationOptions)
116117

117-
// Build path config from original pages (not localized copies)
118-
if (shouldLocalizeRoutes(localizationOptions)) {
119-
buildPathToConfig(ctx, localeCodes, resolver, pages as LocalizableRoute[])
120-
}
121-
122118
// keep root when using prefixed routing without prerendering
123119
const indexPage = pages.find(x => x.path === '/')
124120
if (options.strategy === 'prefix' && indexPage != null) {
@@ -129,28 +125,7 @@ export const disabledI18nPathToPath = ${JSON.stringify(routeResources.disabledI1
129125
// (the boolean form is kept, it marks routes with disabled localization at runtime)
130126
stripRouteMetaI18n(localizedPages)
131127

132-
const invertedMap = {} as Record<string, Record<string, string | false>>
133-
const localizedMapInvert: Record<string, string> = {}
134-
const notLocalizedMapInvert: Record<string, string> = {}
135-
for (const [path, localeConfig] of Object.entries(ctx.pathToConfig)) {
136-
const resPath = resolveRoutePath(path)
137-
invertedMap[resPath] ??= {}
138-
let hasLocalized = false
139-
for (const [locale, localePath] of Object.entries(localeConfig!)) {
140-
const localized = localePath === true ? path : localePath
141-
invertedMap[resPath][locale] = localized && resolveRoutePath(localized)
142-
if (invertedMap[resPath][locale]) {
143-
localizedMapInvert[invertedMap[resPath][locale]] = resPath
144-
hasLocalized = true
145-
}
146-
}
147-
if (!hasLocalized) {
148-
notLocalizedMapInvert[resPath] = resPath
149-
}
150-
}
151-
routeResources.i18nPathToPath = localizedMapInvert
152-
routeResources.pathToI18nConfig = invertedMap
153-
routeResources.disabledI18nPathToPath = notLocalizedMapInvert
128+
routeResources = resources.toResources()
154129

155130
await updateTemplates({
156131
filter: (template: ResolvedNuxtTemplate) => template.filename === 'i18n-route-resources.mjs',
@@ -388,49 +363,6 @@ export function createPureOptionsResolver(
388363
}
389364
}
390365

391-
/**
392-
* Post-processing step: builds ctx.pathToConfig from the original (pre-localized) routes.
393-
* Call this after localizeRoutes() with the same resolver used for localization.
394-
*/
395-
export function buildPathToConfig(
396-
ctx: NuxtPageAnalyzeContext,
397-
localeCodes: string[],
398-
resolver: RouteOptionsResolver,
399-
routes: LocalizableRoute[],
400-
): void {
401-
for (const route of routes) {
402-
if (route.file) {
403-
const res = resolver(route, localeCodes)
404-
const localeCfg = res?.srcPaths
405-
const mappedPath = ctx.fileToPath[route.file]
406-
if (mappedPath) {
407-
ctx.pathToConfig[mappedPath] ??= {} as Record<string, string | boolean>
408-
for (const l of localeCodes) {
409-
ctx.pathToConfig[mappedPath][l] ??= localeCfg?.[l] ?? false
410-
}
411-
for (const l of res?.locales ?? []) {
412-
ctx.pathToConfig[mappedPath][l] ||= true
413-
}
414-
}
415-
}
416-
if (route.children?.length) {
417-
buildPathToConfig(ctx, localeCodes, resolver, route.children)
418-
}
419-
}
420-
}
421-
422-
/**
423-
* Function factory, returns a function based on the `customRoutes` option property.
424-
* @deprecated Use createPureOptionsResolver + buildPathToConfig instead.
425-
*/
426-
export function getRouteOptionsResolver(
427-
ctx: NuxtPageAnalyzeContext,
428-
defaultLocale: string,
429-
customRoutes: NuxtI18nOptions['customRoutes'],
430-
): RouteOptionsResolver {
431-
return createPureOptionsResolver(ctx, defaultLocale, customRoutes)
432-
}
433-
434366
function resolveRoutePath(path: string): string {
435367
const tokens = parseSegment(path.slice(1))
436368
return '/' + toVueRouterSegment(tokens)
@@ -592,7 +524,7 @@ function getRouteOptions(
592524
}
593525
}
594526

595-
return { locales, paths, srcPaths: resolvedOptions.paths }
527+
return { locales, paths }
596528
}
597529

598530
/**

src/routing.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,79 @@ import {
44
type RouteContext,
55
type RouteOptionsResolver,
66
createRouteContext,
7+
joinPath,
78
localizeSingleRoute,
89
} from './kit/gen'
910
import type { LocaleObject, Strategies } from './types'
1011

12+
export type RouteResources = {
13+
/** plain paths mounted as-is for at least one locale */
14+
localizedPaths: string[]
15+
/** per-locale custom paths and disables, locales without an entry use the plain path */
16+
pathToI18nConfig: Record<string, Record<string, string | false>>
17+
/** custom localized path to plain path */
18+
i18nPathToPath: Record<string, string>
19+
/** paths with localization fully disabled */
20+
disabledPaths: string[]
21+
}
22+
23+
/**
24+
* Collects the runtime route resources (`i18n-route-resources.mjs`) during route
25+
* localization, keyed by the full paths routes actually mount at.
26+
*/
27+
export function createRouteResourcesCollector() {
28+
const pathToConfig: Record<string, Record<string, string | false>> = {}
29+
30+
const collect: RouteContext['onLocalize'] = (route, routeOptions, options) => {
31+
const path = joinPath(options.parentPath, route.path)
32+
if (routeOptions == null) {
33+
// only routes with localization explicitly disabled are recorded (not e.g. redirect-only routes)
34+
if ((route.meta as Record<string, unknown> | undefined)?.i18n === false) {
35+
const entry = (pathToConfig[path] ??= {})
36+
for (const locale of options.locales) { entry[locale] ??= false }
37+
}
38+
return
39+
}
40+
41+
const entry = (pathToConfig[path] ??= {})
42+
for (const locale of routeOptions.locales) {
43+
// the walk is top-down, the parent's localized path (unprefixed) is already collected
44+
const parentPath = options.parentPath ? pathToConfig[options.parentPath]?.[locale] || options.parentPath : undefined
45+
entry[locale] = joinPath(parentPath, routeOptions.paths[locale] ?? route.path)
46+
}
47+
for (const locale of options.locales) { entry[locale] ??= false }
48+
}
49+
50+
const toResources = (): RouteResources => {
51+
const resources: RouteResources = { localizedPaths: [], pathToI18nConfig: {}, i18nPathToPath: {}, disabledPaths: [] }
52+
for (const [path, entry] of Object.entries(pathToConfig)) {
53+
// identity localizations (localized path equals the plain path) are kept implicit
54+
const exceptions: Record<string, string | false> = {}
55+
let hasIdentity = false
56+
let hasLocalized = false
57+
for (const [locale, localized] of Object.entries(entry)) {
58+
if (localized === path) {
59+
hasIdentity = hasLocalized = true
60+
continue
61+
}
62+
exceptions[locale] = localized
63+
if (!localized) { continue }
64+
resources.i18nPathToPath[localized] = path
65+
hasLocalized = true
66+
}
67+
if (!hasLocalized) {
68+
resources.disabledPaths.push(path)
69+
continue
70+
}
71+
if (hasIdentity) { resources.localizedPaths.push(path) }
72+
if (Object.keys(exceptions).length) { resources.pathToI18nConfig[path] = exceptions }
73+
}
74+
return resources
75+
}
76+
77+
return { collect, toResources }
78+
}
79+
1180
function createShouldPrefix(opts: SetupLocalizeRoutesOptions, ctx: RouteContext) {
1281
if (opts.strategy === 'no_prefix') { return () => false }
1382
return (path: string, locale: string, options: LocalizeRouteParams) => {
@@ -72,6 +141,7 @@ type SetupLocalizeRoutesOptions = {
72141
defaultLocale?: string
73142
optionsResolver?: RouteOptionsResolver
74143
compactRoutes?: boolean
144+
onLocalize?: RouteContext['onLocalize']
75145
}
76146

77147
/**
@@ -86,6 +156,7 @@ export function localizeRoutes(routes: LocalizableRoute[], config: SetupLocalize
86156
defaultLocales: resolveDefaultLocales(config),
87157
routesNameSeparator: config.routesNameSeparator,
88158
defaultLocaleRouteNameSuffix: config.defaultLocaleRouteNameSuffix,
159+
onLocalize: config.onLocalize,
89160
})
90161

91162
const strategy = config.strategy ?? 'prefix_and_default'

0 commit comments

Comments
 (0)