@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.2 → 3.2.0-ultramodern.23

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.
Files changed (85) hide show
  1. package/dist/cjs/cli/index.js +22 -0
  2. package/dist/cjs/runtime/I18nLink.js +4 -12
  3. package/dist/cjs/runtime/context.js +32 -5
  4. package/dist/cjs/runtime/hooks.js +8 -5
  5. package/dist/cjs/runtime/i18n/backend/defaults.js +1 -1
  6. package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
  7. package/dist/cjs/runtime/i18n/instance.js +4 -2
  8. package/dist/cjs/runtime/index.js +7 -6
  9. package/dist/cjs/runtime/routerAdapter.js +163 -0
  10. package/dist/cjs/runtime/utils.js +63 -94
  11. package/dist/cjs/server/index.js +64 -8
  12. package/dist/cjs/shared/localisedUrls.js +237 -0
  13. package/dist/esm/cli/index.mjs +22 -0
  14. package/dist/esm/runtime/I18nLink.mjs +4 -12
  15. package/dist/esm/runtime/context.mjs +34 -7
  16. package/dist/esm/runtime/hooks.mjs +9 -6
  17. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  18. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  19. package/dist/esm/runtime/i18n/instance.mjs +4 -2
  20. package/dist/esm/runtime/index.mjs +7 -6
  21. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  22. package/dist/esm/runtime/utils.mjs +11 -30
  23. package/dist/esm/server/index.mjs +57 -7
  24. package/dist/esm/shared/localisedUrls.mjs +191 -0
  25. package/dist/esm-node/cli/index.mjs +22 -0
  26. package/dist/esm-node/runtime/I18nLink.mjs +4 -12
  27. package/dist/esm-node/runtime/context.mjs +34 -7
  28. package/dist/esm-node/runtime/hooks.mjs +9 -6
  29. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  30. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  31. package/dist/esm-node/runtime/i18n/instance.mjs +4 -2
  32. package/dist/esm-node/runtime/index.mjs +7 -6
  33. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  34. package/dist/esm-node/runtime/utils.mjs +11 -30
  35. package/dist/esm-node/server/index.mjs +57 -7
  36. package/dist/esm-node/shared/localisedUrls.mjs +192 -0
  37. package/dist/types/cli/index.d.ts +21 -0
  38. package/dist/types/runtime/I18nLink.d.ts +23 -0
  39. package/dist/types/runtime/context.d.ts +41 -0
  40. package/dist/types/runtime/hooks.d.ts +30 -0
  41. package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
  42. package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
  43. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
  44. package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
  45. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
  46. package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
  47. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
  48. package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
  49. package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
  50. package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
  51. package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
  52. package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
  53. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
  54. package/dist/types/runtime/i18n/index.d.ts +3 -0
  55. package/dist/types/runtime/i18n/instance.d.ts +96 -0
  56. package/dist/types/runtime/i18n/utils.d.ts +29 -0
  57. package/dist/types/runtime/index.d.ts +21 -0
  58. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  59. package/dist/types/runtime/types.d.ts +15 -0
  60. package/dist/types/runtime/utils.d.ts +28 -0
  61. package/dist/types/server/index.d.ts +14 -0
  62. package/dist/types/shared/deepMerge.d.ts +1 -0
  63. package/dist/types/shared/detection.d.ts +11 -0
  64. package/dist/types/shared/localisedUrls.d.ts +13 -0
  65. package/dist/types/shared/type.d.ts +168 -0
  66. package/dist/types/shared/utils.d.ts +5 -0
  67. package/package.json +15 -15
  68. package/rstest.config.mts +39 -0
  69. package/src/cli/index.ts +43 -1
  70. package/src/runtime/I18nLink.tsx +10 -16
  71. package/src/runtime/context.tsx +45 -7
  72. package/src/runtime/hooks.ts +13 -4
  73. package/src/runtime/i18n/backend/defaults.ts +3 -1
  74. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  75. package/src/runtime/i18n/instance.ts +14 -5
  76. package/src/runtime/index.tsx +10 -2
  77. package/src/runtime/routerAdapter.tsx +333 -0
  78. package/src/runtime/utils.ts +22 -34
  79. package/src/server/index.ts +135 -10
  80. package/src/shared/localisedUrls.ts +393 -0
  81. package/src/shared/type.ts +12 -0
  82. package/tests/localisedUrls.test.ts +278 -0
  83. package/tests/routerAdapter.test.tsx +278 -0
  84. package/dist/esm/rslib-runtime.mjs +0 -18
  85. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -3,6 +3,11 @@ import {
3
3
  getGlobalBasename,
4
4
  type TInternalRuntimeContext,
5
5
  } from '@modern-js/runtime/context';
6
+ import type { LocalisedUrlsMap } from '../shared/localisedUrls';
7
+ import {
8
+ resolveLocalisedPath,
9
+ resolveLocalisedUrlsConfig,
10
+ } from '../shared/localisedUrls';
6
11
 
7
12
  export const getPathname = (context: TInternalRuntimeContext): string => {
8
13
  if (isBrowser()) {
@@ -51,18 +56,25 @@ export const buildLocalizedUrl = (
51
56
  pathname: string,
52
57
  language: string,
53
58
  languages: string[],
59
+ localisedUrls?: boolean | LocalisedUrlsMap,
54
60
  ): string => {
55
61
  const segments = pathname.split('/').filter(Boolean);
56
-
57
- if (segments.length > 0 && languages.includes(segments[0])) {
58
- // Replace existing language prefix
59
- segments[0] = language;
60
- } else {
61
- // Add language prefix
62
- segments.unshift(language);
63
- }
64
-
65
- return `/${segments.join('/')}`;
62
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
63
+ const pathWithoutLanguage =
64
+ segments.length > 0 && languages.includes(segments[0])
65
+ ? `/${segments.slice(1).join('/')}`
66
+ : pathname;
67
+ const resolvedPath = localisedUrlsConfig.enabled
68
+ ? resolveLocalisedPath(
69
+ pathWithoutLanguage,
70
+ language,
71
+ languages,
72
+ localisedUrlsConfig.map,
73
+ )
74
+ : pathWithoutLanguage;
75
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
76
+
77
+ return `/${[language, ...resolvedSegments].join('/')}`;
66
78
  };
67
79
 
68
80
  export const detectLanguageFromPath = (
@@ -137,27 +149,3 @@ export const shouldIgnoreRedirect = (
137
149
  );
138
150
  });
139
151
  };
140
-
141
- // Safe hook wrapper to handle cases where router context is not available
142
- export const useRouterHooks = () => {
143
- try {
144
- const {
145
- useLocation,
146
- useNavigate,
147
- useParams,
148
- } = require('@modern-js/runtime/router');
149
- return {
150
- navigate: useNavigate(),
151
- location: useLocation(),
152
- params: useParams(),
153
- hasRouter: true,
154
- };
155
- } catch (error) {
156
- return {
157
- navigate: null,
158
- location: null,
159
- params: {},
160
- hasRouter: false,
161
- };
162
- }
163
- };
@@ -8,6 +8,10 @@ import {
8
8
  mergeDetectionOptions,
9
9
  } from '../runtime/i18n/detection/config.js';
10
10
  import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
11
+ import {
12
+ resolveLocalisedPath,
13
+ resolveLocalisedUrlsConfig,
14
+ } from '../shared/localisedUrls.js';
11
15
  import type { LocaleDetectionOptions } from '../shared/type';
12
16
  import { getLocaleDetectionOptions } from '../shared/utils.js';
13
17
 
@@ -16,6 +20,74 @@ export interface I18nPluginOptions {
16
20
  staticRoutePrefixes: string[];
17
21
  }
18
22
 
23
+ type ApiPrefixInput = string | string[] | undefined;
24
+
25
+ const normalizeApiPrefix = (prefix: string): string | null => {
26
+ const trimmedPrefix = prefix.trim();
27
+ if (!trimmedPrefix) {
28
+ return null;
29
+ }
30
+
31
+ const prefixedPath = trimmedPrefix.startsWith('/')
32
+ ? trimmedPrefix
33
+ : `/${trimmedPrefix}`;
34
+ const withoutWildcard = prefixedPath.replace(/\/\*$/, '');
35
+ const normalizedPrefix =
36
+ withoutWildcard.length > 1
37
+ ? withoutWildcard.replace(/\/+$/, '')
38
+ : withoutWildcard;
39
+
40
+ return normalizedPrefix === '/' ? null : normalizedPrefix;
41
+ };
42
+
43
+ export const collectApiPrefixes = (
44
+ routes: Array<{ isApi?: boolean; urlPath?: string }>,
45
+ bffPrefix?: ApiPrefixInput,
46
+ ): string[] => {
47
+ const prefixes = new Set<string>();
48
+
49
+ for (const route of routes) {
50
+ if (!route.isApi || !route.urlPath) {
51
+ continue;
52
+ }
53
+
54
+ const normalizedPrefix = normalizeApiPrefix(route.urlPath);
55
+ if (normalizedPrefix) {
56
+ prefixes.add(normalizedPrefix);
57
+ }
58
+ }
59
+
60
+ const bffPrefixes = Array.isArray(bffPrefix)
61
+ ? bffPrefix
62
+ : bffPrefix
63
+ ? [bffPrefix]
64
+ : [];
65
+
66
+ for (const prefix of bffPrefixes) {
67
+ const normalizedPrefix = normalizeApiPrefix(prefix);
68
+ if (normalizedPrefix) {
69
+ prefixes.add(normalizedPrefix);
70
+ }
71
+ }
72
+
73
+ return [...prefixes];
74
+ };
75
+
76
+ export const matchesApiPrefix = (
77
+ pathname: string,
78
+ apiPrefixes: string[],
79
+ ): boolean => {
80
+ const normalizedPathname = pathname.startsWith('/')
81
+ ? pathname
82
+ : `/${pathname}`;
83
+
84
+ return apiPrefixes.some(
85
+ prefix =>
86
+ normalizedPathname === prefix ||
87
+ normalizedPathname.startsWith(`${prefix}/`),
88
+ );
89
+ };
90
+
19
91
  /**
20
92
  * Convert i18next detection options to hono languageDetector options
21
93
  */
@@ -231,6 +303,7 @@ const buildLocalizedUrl = (
231
303
  urlPath: string,
232
304
  language: string,
233
305
  languages: string[],
306
+ localisedUrls?: LocaleDetectionOptions['localisedUrls'],
234
307
  ): string => {
235
308
  const url = new URL(req.url);
236
309
  const pathname = url.pathname;
@@ -242,16 +315,21 @@ const buildLocalizedUrl = (
242
315
  : pathname;
243
316
 
244
317
  const segments = remainingPath.split('/').filter(Boolean);
245
-
246
- if (segments.length > 0 && languages.includes(segments[0])) {
247
- // Replace existing language prefix
248
- segments[0] = language;
249
- } else {
250
- // If path doesn't start with language, add language prefix
251
- segments.unshift(language);
252
- }
253
-
254
- const newPathname = `/${segments.join('/')}`;
318
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
319
+ const pathWithoutLanguage =
320
+ segments.length > 0 && languages.includes(segments[0])
321
+ ? `/${segments.slice(1).join('/')}`
322
+ : remainingPath;
323
+ const resolvedPath = localisedUrlsConfig.enabled
324
+ ? resolveLocalisedPath(
325
+ pathWithoutLanguage,
326
+ language,
327
+ languages,
328
+ localisedUrlsConfig.map,
329
+ )
330
+ : pathWithoutLanguage;
331
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
332
+ const newPathname = `/${[language, ...resolvedSegments].join('/')}`;
255
333
  // Handle root path case to avoid double slashes like //en
256
334
  const suffix = `${url.search}${url.hash}`;
257
335
  const localizedUrl =
@@ -265,6 +343,11 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
265
343
  setup: api => {
266
344
  api.onPrepare(() => {
267
345
  const { middlewares, routes } = api.getServerContext();
346
+ const serverConfig = api.getServerConfig();
347
+ const bffPrefix = serverConfig?.bff
348
+ ? (serverConfig.bff.prefix ?? '/api')
349
+ : undefined;
350
+ const apiPrefixes = collectApiPrefixes(routes, bffPrefix);
268
351
 
269
352
  // Collect all non-root entry paths for cross-entry path detection
270
353
  const entryPaths = new Set<string>();
@@ -292,6 +375,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
292
375
  fallbackLanguage = 'en',
293
376
  detection,
294
377
  ignoreRedirectRoutes,
378
+ localisedUrls,
295
379
  } = getLocaleDetectionOptions(entryName, options.localeDetection);
296
380
  const staticRoutePrefixes = options.staticRoutePrefixes;
297
381
  const originUrlPath = route.urlPath;
@@ -314,6 +398,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
314
398
  const url = new URL(c.req.url);
315
399
  const pathname = url.pathname;
316
400
 
401
+ if (matchesApiPrefix(pathname, apiPrefixes)) {
402
+ return await next();
403
+ }
404
+
317
405
  // For static resource requests, skip language detection
318
406
  if (
319
407
  isStaticResourceRequest(
@@ -348,6 +436,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
348
436
  const url = new URL(c.req.url);
349
437
  const pathname = url.pathname;
350
438
 
439
+ if (matchesApiPrefix(pathname, apiPrefixes)) {
440
+ return await next();
441
+ }
442
+
351
443
  // For static resource requests, skip i18n processing
352
444
  if (
353
445
  isStaticResourceRequest(
@@ -391,9 +483,42 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
391
483
  originUrlPath,
392
484
  targetLanguage,
393
485
  languages,
486
+ localisedUrls,
394
487
  );
395
488
  return c.redirect(localizedUrl);
396
489
  }
490
+ const localisedUrlsConfig =
491
+ resolveLocalisedUrlsConfig(localisedUrls);
492
+ if (localisedUrlsConfig.enabled) {
493
+ const basePath = originUrlPath.replace('/*', '');
494
+ const remainingPath = pathname.startsWith(basePath)
495
+ ? pathname.slice(basePath.length)
496
+ : pathname;
497
+ const pathWithoutLanguage = remainingPath
498
+ .split('/')
499
+ .filter(Boolean)
500
+ .slice(1)
501
+ .join('/');
502
+ const canonicalLocalizedPath = resolveLocalisedPath(
503
+ `/${pathWithoutLanguage}`,
504
+ language,
505
+ languages,
506
+ localisedUrlsConfig.map,
507
+ );
508
+ const expectedPathname =
509
+ basePath === '/'
510
+ ? `/${language}${canonicalLocalizedPath === '/' ? '' : canonicalLocalizedPath}`
511
+ : `${basePath}/${language}${
512
+ canonicalLocalizedPath === '/'
513
+ ? ''
514
+ : canonicalLocalizedPath
515
+ }`;
516
+ if (expectedPathname !== pathname) {
517
+ return c.redirect(
518
+ `${expectedPathname}${url.search}${url.hash}`,
519
+ );
520
+ }
521
+ }
397
522
  await next();
398
523
  },
399
524
  });
@@ -0,0 +1,393 @@
1
+ import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
2
+
3
+ export type LocalisedUrlPathMap = Record<string, string>;
4
+ export type LocalisedUrlsMap = Record<string, LocalisedUrlPathMap>;
5
+ export type LocalisedUrlsOption = boolean | LocalisedUrlsMap;
6
+
7
+ export interface ResolvedLocalisedUrlsConfig {
8
+ enabled: boolean;
9
+ map: LocalisedUrlsMap;
10
+ }
11
+
12
+ const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
13
+
14
+ export const normalisePathPattern = (path: string): string => {
15
+ const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
16
+ const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
17
+ ? withoutDuplicateSlashes
18
+ : `/${withoutDuplicateSlashes}`;
19
+ const withoutTrailingSlash =
20
+ withLeadingSlash.length > 1
21
+ ? withLeadingSlash.replace(/\/+$/, '')
22
+ : withLeadingSlash;
23
+
24
+ return withoutTrailingSlash.replace(/\[(.+?)\]/g, ':$1');
25
+ };
26
+
27
+ const normaliseRoutePath = (path: string): string => {
28
+ const normalized = normalisePathPattern(path);
29
+ return normalized === '/' ? '' : normalized.slice(1);
30
+ };
31
+
32
+ const getLocaleParamSegment = (segment: string): string | null => {
33
+ if (!segment.startsWith(':')) {
34
+ return null;
35
+ }
36
+
37
+ const paramName = segment.slice(1).replace(/\?$/, '');
38
+ return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
39
+ };
40
+
41
+ const splitPathSegments = (path?: string): string[] => {
42
+ if (!path) {
43
+ return [];
44
+ }
45
+
46
+ return normalisePathPattern(path).split('/').filter(Boolean);
47
+ };
48
+
49
+ const stripLeadingLocaleParam = (path?: string): string | undefined => {
50
+ const segments = splitPathSegments(path);
51
+ const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
52
+
53
+ if (!leadingLocaleParam) {
54
+ return path;
55
+ }
56
+
57
+ const remainingPath = segments.slice(1).join('/');
58
+ return remainingPath ? `/${remainingPath}` : undefined;
59
+ };
60
+
61
+ const getLeadingLocaleParam = (path?: string): string | null => {
62
+ const segments = splitPathSegments(path);
63
+ return getLocaleParamSegment(segments[0] || '');
64
+ };
65
+
66
+ export const resolveLocalisedUrlsConfig = (
67
+ option: LocalisedUrlsOption | undefined,
68
+ ): ResolvedLocalisedUrlsConfig => {
69
+ if (option === false) {
70
+ return { enabled: false, map: {} };
71
+ }
72
+
73
+ if (option && typeof option === 'object') {
74
+ return { enabled: true, map: option };
75
+ }
76
+
77
+ return { enabled: true, map: {} };
78
+ };
79
+
80
+ const isLocaleParamPath = (path?: string): boolean => {
81
+ const segments = splitPathSegments(path);
82
+ return segments.length === 1 && Boolean(getLocaleParamSegment(segments[0]));
83
+ };
84
+
85
+ const isLocalisableRoutePath = (path?: string): path is string => {
86
+ const pathWithoutLocale = stripLeadingLocaleParam(path);
87
+
88
+ if (
89
+ !pathWithoutLocale ||
90
+ pathWithoutLocale === '/' ||
91
+ pathWithoutLocale === '*'
92
+ ) {
93
+ return false;
94
+ }
95
+
96
+ return true;
97
+ };
98
+
99
+ const joinPath = (parentPath: string, routePath?: string): string => {
100
+ if (!isLocalisableRoutePath(routePath)) {
101
+ return parentPath;
102
+ }
103
+
104
+ const segment = normaliseRoutePath(stripLeadingLocaleParam(routePath) || '');
105
+ return normalisePathPattern(`${parentPath}/${segment}`);
106
+ };
107
+
108
+ const ensureLocalisedUrlsForPath = (
109
+ canonicalPath: string,
110
+ languages: string[],
111
+ localisedUrls: LocalisedUrlsMap,
112
+ ): LocalisedUrlPathMap => {
113
+ const entry = localisedUrls[canonicalPath];
114
+ if (!entry) {
115
+ throw new Error(
116
+ `localisedUrls is enabled, but route "${canonicalPath}" does not define localised URLs for languages: ${languages.join(
117
+ ', ',
118
+ )}. Add localisedUrls["${canonicalPath}"] or set localeDetection.localisedUrls to false.`,
119
+ );
120
+ }
121
+
122
+ const missingLanguages = languages.filter(language => !entry[language]);
123
+ if (missingLanguages.length > 0) {
124
+ throw new Error(
125
+ `localisedUrls["${canonicalPath}"] is missing languages: ${missingLanguages.join(
126
+ ', ',
127
+ )}. Every configured language must have a localised URL.`,
128
+ );
129
+ }
130
+
131
+ return entry;
132
+ };
133
+
134
+ export const validateLocalisedUrls = (
135
+ routes: (NestedRouteForCli | PageRoute)[],
136
+ languages: string[],
137
+ localisedUrls: LocalisedUrlsMap,
138
+ ) => {
139
+ const visit = (route: NestedRouteForCli | PageRoute, parentPath: string) => {
140
+ const canonicalPath = joinPath(parentPath, route.path);
141
+ if (isLocalisableRoutePath(route.path)) {
142
+ ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls);
143
+ }
144
+
145
+ if ('children' in route && route.children) {
146
+ route.children.forEach(child => visit(child, canonicalPath));
147
+ }
148
+ };
149
+
150
+ routes.forEach(route => visit(route, ''));
151
+ };
152
+
153
+ const getLocalisedRoutePaths = (
154
+ canonicalPath: string,
155
+ parentLocalisedPaths: Record<string, string>,
156
+ languages: string[],
157
+ entry: LocalisedUrlPathMap,
158
+ ): string[] => {
159
+ const paths = languages.map(language => {
160
+ const fullPath = normalisePathPattern(entry[language]);
161
+ const parentPath = normalisePathPattern(
162
+ parentLocalisedPaths[language] || '/',
163
+ );
164
+ if (parentPath === '/') {
165
+ return normaliseRoutePath(fullPath) || undefined;
166
+ }
167
+
168
+ const parentPrefix = `${parentPath}/`;
169
+ if (!fullPath.startsWith(parentPrefix)) {
170
+ throw new Error(
171
+ `localisedUrls["${canonicalPath}"].${language} must be nested under "${parentPath}" because its parent route is localised there.`,
172
+ );
173
+ }
174
+
175
+ return normaliseRoutePath(fullPath.slice(parentPath.length));
176
+ });
177
+
178
+ return Array.from(new Set(paths.filter(Boolean) as string[]));
179
+ };
180
+
181
+ const transformLocalisedRoute = (
182
+ route: NestedRouteForCli | PageRoute,
183
+ parentCanonicalPath: string,
184
+ parentLocalisedPaths: Record<string, string>,
185
+ languages: string[],
186
+ localisedUrls: LocalisedUrlsMap,
187
+ ): (NestedRouteForCli | PageRoute)[] => {
188
+ const canonicalPath = joinPath(parentCanonicalPath, route.path);
189
+ const localisedUrlEntry = isLocalisableRoutePath(route.path)
190
+ ? ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls)
191
+ : undefined;
192
+ const routeLocalisedPaths = localisedUrlEntry
193
+ ? languages.reduce<Record<string, string>>((acc, language) => {
194
+ acc[language] = normalisePathPattern(localisedUrlEntry[language]);
195
+ return acc;
196
+ }, {})
197
+ : parentLocalisedPaths;
198
+
199
+ const children =
200
+ 'children' in route && route.children
201
+ ? route.children.flatMap(child =>
202
+ transformLocalisedRoute(
203
+ child,
204
+ canonicalPath,
205
+ routeLocalisedPaths,
206
+ languages,
207
+ localisedUrls,
208
+ ),
209
+ )
210
+ : undefined;
211
+
212
+ const baseRoute = {
213
+ ...route,
214
+ ...(children ? { children } : {}),
215
+ } as NestedRouteForCli | PageRoute;
216
+
217
+ if (!localisedUrlEntry) {
218
+ return [baseRoute];
219
+ }
220
+
221
+ return getLocalisedRoutePaths(
222
+ canonicalPath,
223
+ parentLocalisedPaths,
224
+ languages,
225
+ localisedUrlEntry,
226
+ ).map((localisedPath, index) =>
227
+ cloneRouteWithLocalisedPath(baseRoute, localisedPath, index),
228
+ );
229
+ };
230
+
231
+ const legalRouteIdPart = (value: string): string =>
232
+ value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
233
+
234
+ const suffixRouteIds = <T extends NestedRouteForCli | PageRoute>(
235
+ route: T,
236
+ suffix: string,
237
+ ): T => {
238
+ const children =
239
+ 'children' in route && route.children
240
+ ? route.children.map(child => suffixRouteIds(child, suffix))
241
+ : undefined;
242
+
243
+ return {
244
+ ...route,
245
+ ...(route.id ? { id: `${route.id}__localised_${suffix}` } : {}),
246
+ ...(children ? { children } : {}),
247
+ };
248
+ };
249
+
250
+ const cloneRouteWithLocalisedPath = (
251
+ route: NestedRouteForCli | PageRoute,
252
+ path: string,
253
+ index: number,
254
+ ): NestedRouteForCli | PageRoute => {
255
+ const leadingLocaleParam = getLeadingLocaleParam(route.path);
256
+ const localisedPath = leadingLocaleParam
257
+ ? normaliseRoutePath(`${leadingLocaleParam}/${path}`)
258
+ : path;
259
+ const routeWithPath = {
260
+ ...route,
261
+ path: localisedPath,
262
+ } as NestedRouteForCli | PageRoute;
263
+
264
+ return index === 0
265
+ ? routeWithPath
266
+ : suffixRouteIds(routeWithPath, legalRouteIdPart(localisedPath));
267
+ };
268
+
269
+ export const applyLocalisedUrlsToRoutes = (
270
+ routes: (NestedRouteForCli | PageRoute)[],
271
+ languages: string[],
272
+ localisedUrls: LocalisedUrlsMap,
273
+ ): (NestedRouteForCli | PageRoute)[] => {
274
+ const rootLocalisedPaths = languages.reduce<Record<string, string>>(
275
+ (acc, language) => {
276
+ acc[language] = '/';
277
+ return acc;
278
+ },
279
+ {},
280
+ );
281
+
282
+ validateLocalisedUrls(routes, languages, localisedUrls);
283
+
284
+ return routes.flatMap(route =>
285
+ transformLocalisedRoute(
286
+ route,
287
+ '',
288
+ rootLocalisedPaths,
289
+ languages,
290
+ localisedUrls,
291
+ ),
292
+ );
293
+ };
294
+
295
+ const escapeRegExp = (value: string): string =>
296
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
297
+
298
+ const getParamName = (segment: string): string =>
299
+ segment.slice(1).replace(/\?$/, '');
300
+
301
+ const compilePathPattern = (pattern: string) => {
302
+ const names: string[] = [];
303
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
304
+ const source = segments
305
+ .map(segment => {
306
+ if (segment.startsWith(':')) {
307
+ names.push(getParamName(segment));
308
+ const paramPattern = '([^/]+)';
309
+ return segment.endsWith('?')
310
+ ? `(?:/${paramPattern})?`
311
+ : `/${paramPattern}`;
312
+ }
313
+ if (segment === '*') {
314
+ names.push('*');
315
+ return '/(.*)';
316
+ }
317
+ return `/${escapeRegExp(segment)}`;
318
+ })
319
+ .join('');
320
+
321
+ return {
322
+ names,
323
+ regexp: new RegExp(`^${source || '/'}$`),
324
+ };
325
+ };
326
+
327
+ const matchPathPattern = (
328
+ pathname: string,
329
+ pattern: string,
330
+ ): Record<string, string> | null => {
331
+ const { names, regexp } = compilePathPattern(pattern);
332
+ const match = regexp.exec(normalisePathPattern(pathname));
333
+ if (!match) {
334
+ return null;
335
+ }
336
+
337
+ return names.reduce<Record<string, string>>((params, name, index) => {
338
+ params[name] = decodeURIComponent(match[index + 1] || '');
339
+ return params;
340
+ }, {});
341
+ };
342
+
343
+ const buildPathFromPattern = (
344
+ pattern: string,
345
+ params: Record<string, string>,
346
+ ): string => {
347
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
348
+ const path = segments
349
+ .map(segment => {
350
+ if (segment.startsWith(':')) {
351
+ const param = params[getParamName(segment)];
352
+ return param ? encodeURIComponent(param) : '';
353
+ }
354
+ if (segment === '*') {
355
+ return params['*'] || '';
356
+ }
357
+ return segment;
358
+ })
359
+ .filter(Boolean)
360
+ .join('/');
361
+
362
+ return `/${path}`;
363
+ };
364
+
365
+ export const resolveLocalisedPath = (
366
+ pathname: string,
367
+ targetLanguage: string,
368
+ languages: string[],
369
+ localisedUrls: LocalisedUrlsMap,
370
+ ): string => {
371
+ const normalizedPathname = normalisePathPattern(pathname);
372
+
373
+ for (const localisedUrlEntry of Object.values(localisedUrls)) {
374
+ const targetPattern = localisedUrlEntry[targetLanguage];
375
+ if (!targetPattern) {
376
+ continue;
377
+ }
378
+
379
+ for (const language of languages) {
380
+ const sourcePattern = localisedUrlEntry[language];
381
+ if (!sourcePattern) {
382
+ continue;
383
+ }
384
+
385
+ const params = matchPathPattern(normalizedPathname, sourcePattern);
386
+ if (params) {
387
+ return buildPathFromPattern(targetPattern, params);
388
+ }
389
+ }
390
+ }
391
+
392
+ return normalizedPathname;
393
+ };
@@ -2,6 +2,7 @@ import type {
2
2
  LanguageDetectorOptions,
3
3
  Resources,
4
4
  } from '../runtime/i18n/instance';
5
+ import type { LocalisedUrlsOption } from './localisedUrls';
5
6
 
6
7
  export interface BaseLocaleDetectionOptions {
7
8
  localePathRedirect?: boolean;
@@ -10,6 +11,17 @@ export interface BaseLocaleDetectionOptions {
10
11
  fallbackLanguage?: string;
11
12
  detection?: LanguageDetectorOptions;
12
13
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
14
+ /**
15
+ * Enables localised pathnames in addition to the locale prefix.
16
+ *
17
+ * - `false`: keep only locale-prefix behavior (`/en/about`).
18
+ * - object: map canonical route paths to every configured language.
19
+ *
20
+ * Defaults to `true` when `localePathRedirect` is enabled, so route
21
+ * generation validates that every localisable route path has entries for all
22
+ * configured languages.
23
+ */
24
+ localisedUrls?: LocalisedUrlsOption;
13
25
  }
14
26
 
15
27
  export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {