@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.5 → 3.2.0-ultramodern.50

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 (90) 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 +0 -24
  8. package/dist/cjs/runtime/i18n/react-i18next.js +52 -0
  9. package/dist/cjs/runtime/index.js +13 -7
  10. package/dist/cjs/runtime/routerAdapter.js +163 -0
  11. package/dist/cjs/runtime/utils.js +63 -94
  12. package/dist/cjs/server/index.js +60 -8
  13. package/dist/cjs/shared/localisedUrls.js +237 -0
  14. package/dist/esm/cli/index.mjs +22 -0
  15. package/dist/esm/runtime/I18nLink.mjs +4 -12
  16. package/dist/esm/runtime/context.mjs +34 -7
  17. package/dist/esm/runtime/hooks.mjs +9 -6
  18. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  19. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  20. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  21. package/dist/esm/runtime/i18n/react-i18next.mjs +18 -0
  22. package/dist/esm/runtime/index.mjs +14 -8
  23. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  24. package/dist/esm/runtime/utils.mjs +11 -30
  25. package/dist/esm/server/index.mjs +53 -7
  26. package/dist/esm/shared/localisedUrls.mjs +191 -0
  27. package/dist/esm-node/cli/index.mjs +22 -0
  28. package/dist/esm-node/runtime/I18nLink.mjs +4 -12
  29. package/dist/esm-node/runtime/context.mjs +34 -7
  30. package/dist/esm-node/runtime/hooks.mjs +9 -6
  31. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  32. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  33. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  34. package/dist/esm-node/runtime/i18n/react-i18next.mjs +19 -0
  35. package/dist/esm-node/runtime/index.mjs +14 -8
  36. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  37. package/dist/esm-node/runtime/utils.mjs +11 -30
  38. package/dist/esm-node/server/index.mjs +53 -7
  39. package/dist/esm-node/shared/localisedUrls.mjs +192 -0
  40. package/dist/types/cli/index.d.ts +21 -0
  41. package/dist/types/runtime/I18nLink.d.ts +23 -0
  42. package/dist/types/runtime/context.d.ts +41 -0
  43. package/dist/types/runtime/hooks.d.ts +30 -0
  44. package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
  45. package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
  46. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
  47. package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
  48. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
  49. package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
  50. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
  51. package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
  52. package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
  53. package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
  54. package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
  55. package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
  56. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
  57. package/dist/types/runtime/i18n/index.d.ts +3 -0
  58. package/dist/types/runtime/i18n/instance.d.ts +91 -0
  59. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  60. package/dist/types/runtime/i18n/utils.d.ts +29 -0
  61. package/dist/types/runtime/index.d.ts +21 -0
  62. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  63. package/dist/types/runtime/types.d.ts +15 -0
  64. package/dist/types/runtime/utils.d.ts +28 -0
  65. package/dist/types/server/index.d.ts +14 -0
  66. package/dist/types/shared/deepMerge.d.ts +1 -0
  67. package/dist/types/shared/detection.d.ts +11 -0
  68. package/dist/types/shared/localisedUrls.d.ts +13 -0
  69. package/dist/types/shared/type.d.ts +168 -0
  70. package/dist/types/shared/utils.d.ts +5 -0
  71. package/package.json +15 -15
  72. package/rstest.config.mts +39 -0
  73. package/src/cli/index.ts +43 -1
  74. package/src/runtime/I18nLink.tsx +10 -16
  75. package/src/runtime/context.tsx +45 -7
  76. package/src/runtime/hooks.ts +13 -4
  77. package/src/runtime/i18n/backend/defaults.ts +3 -1
  78. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  79. package/src/runtime/i18n/instance.ts +0 -25
  80. package/src/runtime/i18n/react-i18next.ts +31 -0
  81. package/src/runtime/index.tsx +21 -9
  82. package/src/runtime/routerAdapter.tsx +333 -0
  83. package/src/runtime/utils.ts +22 -34
  84. package/src/server/index.ts +117 -10
  85. package/src/shared/localisedUrls.ts +393 -0
  86. package/src/shared/type.ts +12 -0
  87. package/tests/localisedUrls.test.ts +312 -0
  88. package/tests/routerAdapter.test.tsx +278 -0
  89. package/dist/esm/rslib-runtime.mjs +0 -18
  90. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -0,0 +1,333 @@
1
+ import { isBrowser, RuntimeContext } from '@modern-js/runtime';
2
+ import {
3
+ InternalRuntimeContext,
4
+ type TInternalRuntimeContext,
5
+ type TRuntimeContext,
6
+ } from '@modern-js/runtime/context';
7
+ import {
8
+ Link as ReactRouterLink,
9
+ useInRouterContext,
10
+ useLocation as useReactRouterLocation,
11
+ useNavigate as useReactRouterNavigate,
12
+ useParams as useReactRouterParams,
13
+ } from '@modern-js/runtime/router';
14
+ import type React from 'react';
15
+ import { useCallback, useContext, useEffect, useState } from 'react';
16
+
17
+ export type I18nRouterFramework = 'react-router' | 'tanstack' | string;
18
+
19
+ export interface I18nRouterLocation {
20
+ pathname: string;
21
+ search: string;
22
+ hash: string;
23
+ }
24
+
25
+ export interface I18nRouterNavigateOptions {
26
+ replace?: boolean;
27
+ state?: unknown;
28
+ }
29
+
30
+ export type I18nRouterNavigate = (
31
+ href: string,
32
+ options?: I18nRouterNavigateOptions,
33
+ ) => void | Promise<void>;
34
+
35
+ export type I18nRouterLink = React.ComponentType<{
36
+ to: string;
37
+ children?: React.ReactNode;
38
+ [key: string]: unknown;
39
+ }>;
40
+
41
+ export interface I18nRouterAdapter {
42
+ framework?: I18nRouterFramework;
43
+ hasRouter: boolean;
44
+ location: I18nRouterLocation | null;
45
+ navigate: I18nRouterNavigate | null;
46
+ Link: I18nRouterLink | null;
47
+ params: Record<string, string>;
48
+ }
49
+
50
+ type RuntimeContextWithRouter = TRuntimeContext & {
51
+ router?: {
52
+ useRouter?: (options?: { warn?: boolean }) => unknown;
53
+ useLocation?: () => unknown;
54
+ useHref?: () => unknown;
55
+ Link?: I18nRouterLink;
56
+ };
57
+ };
58
+
59
+ type InternalRuntimeContextWithRouter = TInternalRuntimeContext & {
60
+ router?: RuntimeContextWithRouter['router'];
61
+ };
62
+
63
+ type RouterInstance = {
64
+ navigate?: (...args: any[]) => unknown;
65
+ state?: {
66
+ location?: unknown;
67
+ matches?: Array<{ params?: Record<string, string> }>;
68
+ };
69
+ stores?: {
70
+ location?: {
71
+ get?: () => unknown;
72
+ subscribe?: (listener: () => void) => () => void;
73
+ };
74
+ matches?: {
75
+ get?: () => Array<{ params?: Record<string, string> }>;
76
+ };
77
+ };
78
+ subscribe?: (eventType: string, listener: () => void) => () => void;
79
+ };
80
+
81
+ const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
82
+ if (typeof value !== 'string' || !value) {
83
+ return '';
84
+ }
85
+ return value.startsWith(prefix) ? value : `${prefix}${value}`;
86
+ };
87
+
88
+ const normalizeLocation = (location: unknown): I18nRouterLocation | null => {
89
+ if (!location || typeof location !== 'object') {
90
+ return null;
91
+ }
92
+
93
+ const locationValue = location as {
94
+ pathname?: unknown;
95
+ search?: unknown;
96
+ searchStr?: unknown;
97
+ hash?: unknown;
98
+ };
99
+
100
+ if (typeof locationValue.pathname !== 'string') {
101
+ return null;
102
+ }
103
+
104
+ return {
105
+ pathname: locationValue.pathname,
106
+ search: normalizeUrlPart(
107
+ typeof locationValue.search === 'string'
108
+ ? locationValue.search
109
+ : locationValue.searchStr,
110
+ '?',
111
+ ),
112
+ hash: normalizeUrlPart(locationValue.hash, '#'),
113
+ };
114
+ };
115
+
116
+ const getWindowLocation = (): I18nRouterLocation | null => {
117
+ if (!isBrowser()) {
118
+ return null;
119
+ }
120
+
121
+ return {
122
+ pathname: window.location.pathname,
123
+ search: window.location.search,
124
+ hash: window.location.hash,
125
+ };
126
+ };
127
+
128
+ const getRouterFramework = (
129
+ runtimeContext: RuntimeContextWithRouter,
130
+ internalContext: InternalRuntimeContextWithRouter,
131
+ inReactRouter: boolean,
132
+ ): I18nRouterFramework | undefined => {
133
+ const framework =
134
+ internalContext.routerFramework ||
135
+ internalContext.routerRuntime?.framework ||
136
+ runtimeContext.routerFramework;
137
+
138
+ if (framework) {
139
+ return framework;
140
+ }
141
+
142
+ if (internalContext.router?.useRouter || runtimeContext.router?.useRouter) {
143
+ return 'tanstack';
144
+ }
145
+
146
+ if (
147
+ internalContext.router?.useLocation ||
148
+ internalContext.router?.useHref ||
149
+ runtimeContext.router?.useLocation ||
150
+ runtimeContext.router?.useHref
151
+ ) {
152
+ return 'react-router';
153
+ }
154
+
155
+ if (inReactRouter) {
156
+ return 'react-router';
157
+ }
158
+
159
+ return undefined;
160
+ };
161
+
162
+ const getRouterInstance = (
163
+ internalContext: InternalRuntimeContextWithRouter,
164
+ contextRouter?: RouterInstance | null,
165
+ ): RouterInstance | null => {
166
+ if (contextRouter) {
167
+ return contextRouter;
168
+ }
169
+
170
+ const router =
171
+ internalContext.routerInstance || internalContext.routerRuntime?.instance;
172
+ if (!router || typeof router !== 'object') {
173
+ return null;
174
+ }
175
+ return router as RouterInstance;
176
+ };
177
+
178
+ const getRouterStateLocation = (
179
+ internalContext: InternalRuntimeContextWithRouter,
180
+ contextRouter?: RouterInstance | null,
181
+ ): I18nRouterLocation | null => {
182
+ const router = getRouterInstance(internalContext, contextRouter);
183
+ return (
184
+ normalizeLocation(router?.stores?.location?.get?.()) ||
185
+ normalizeLocation(router?.state?.location)
186
+ );
187
+ };
188
+
189
+ const getRouterParams = (
190
+ internalContext: InternalRuntimeContextWithRouter,
191
+ contextRouter?: RouterInstance | null,
192
+ ): Record<string, string> => {
193
+ const router = getRouterInstance(internalContext, contextRouter);
194
+ const matches = router?.stores?.matches?.get?.() || router?.state?.matches;
195
+ if (!Array.isArray(matches)) {
196
+ return {};
197
+ }
198
+
199
+ return matches.reduce<Record<string, string>>((params, match) => {
200
+ if (match?.params) {
201
+ Object.assign(params, match.params);
202
+ }
203
+ return params;
204
+ }, {});
205
+ };
206
+
207
+ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
208
+ const runtimeContext = useContext(RuntimeContext) as RuntimeContextWithRouter;
209
+ const internalContext = useContext(
210
+ InternalRuntimeContext,
211
+ ) as InternalRuntimeContextWithRouter;
212
+ const inReactRouter = useInRouterContext();
213
+ const reactRouterNavigate = inReactRouter ? useReactRouterNavigate() : null;
214
+ const reactRouterLocation = inReactRouter ? useReactRouterLocation() : null;
215
+ const reactRouterParams = inReactRouter ? useReactRouterParams() : {};
216
+ const framework = getRouterFramework(
217
+ runtimeContext,
218
+ internalContext,
219
+ inReactRouter,
220
+ );
221
+ const contextUseRouter =
222
+ !inReactRouter && framework === 'tanstack'
223
+ ? internalContext.router?.useRouter || runtimeContext.router?.useRouter
224
+ : undefined;
225
+ const contextRouter = contextUseRouter
226
+ ? (contextUseRouter({ warn: false }) as RouterInstance | null)
227
+ : null;
228
+ const [, setRouterVersion] = useState(0);
229
+ const hasRouter =
230
+ framework === 'tanstack' ||
231
+ framework === 'react-router' ||
232
+ Boolean(reactRouterNavigate);
233
+
234
+ useEffect(() => {
235
+ if (framework !== 'tanstack') {
236
+ return;
237
+ }
238
+
239
+ const router = getRouterInstance(internalContext, contextRouter);
240
+ if (!router) {
241
+ return;
242
+ }
243
+
244
+ const update = () => setRouterVersion(version => version + 1);
245
+ const unsubscribers: Array<() => void> = [];
246
+
247
+ if (typeof router.stores?.location?.subscribe === 'function') {
248
+ const unsubscribe = router.stores.location.subscribe(update);
249
+ if (typeof unsubscribe === 'function') {
250
+ unsubscribers.push(unsubscribe);
251
+ }
252
+ }
253
+
254
+ if (typeof router.subscribe === 'function') {
255
+ for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
256
+ const unsubscribe = router.subscribe(eventType, update);
257
+ if (typeof unsubscribe === 'function') {
258
+ unsubscribers.push(unsubscribe);
259
+ }
260
+ }
261
+ }
262
+
263
+ return () => {
264
+ for (const unsubscribe of unsubscribers) {
265
+ unsubscribe();
266
+ }
267
+ };
268
+ }, [contextRouter, framework, internalContext]);
269
+
270
+ const navigate = useCallback<I18nRouterNavigate>(
271
+ (href, options) => {
272
+ const router = getRouterInstance(internalContext, contextRouter);
273
+ const activeFramework = getRouterFramework(
274
+ runtimeContext,
275
+ internalContext,
276
+ inReactRouter,
277
+ );
278
+
279
+ if (activeFramework === 'tanstack') {
280
+ if (typeof router?.navigate === 'function') {
281
+ return router.navigate({
282
+ to: href,
283
+ replace: options?.replace,
284
+ ...(options?.state === undefined ? {} : { state: options.state }),
285
+ }) as void | Promise<void>;
286
+ }
287
+ throw new Error('TanStack router instance is not available.');
288
+ }
289
+
290
+ if (reactRouterNavigate) {
291
+ return reactRouterNavigate(href, options);
292
+ }
293
+
294
+ if (activeFramework === 'react-router') {
295
+ if (typeof router?.navigate === 'function') {
296
+ return router.navigate(href, options) as void | Promise<void>;
297
+ }
298
+ throw new Error('React Router instance is not available.');
299
+ }
300
+ },
301
+ [
302
+ contextRouter,
303
+ internalContext,
304
+ inReactRouter,
305
+ reactRouterNavigate,
306
+ runtimeContext,
307
+ ],
308
+ );
309
+
310
+ const location =
311
+ (reactRouterLocation
312
+ ? normalizeLocation(reactRouterLocation)
313
+ : getRouterStateLocation(internalContext, contextRouter)) ||
314
+ getWindowLocation();
315
+ const params = inReactRouter
316
+ ? (reactRouterParams as Record<string, string>)
317
+ : getRouterParams(internalContext, contextRouter);
318
+ const Link =
319
+ framework === 'tanstack'
320
+ ? internalContext.router?.Link || runtimeContext.router?.Link || null
321
+ : framework === 'react-router' || inReactRouter
322
+ ? (ReactRouterLink as I18nRouterLink)
323
+ : null;
324
+
325
+ return {
326
+ framework,
327
+ hasRouter,
328
+ location,
329
+ navigate: hasRouter ? navigate : null,
330
+ Link,
331
+ params,
332
+ };
333
+ };
@@ -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,24 @@ 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 expectedUrl = buildLocalizedUrl(
494
+ c.req,
495
+ originUrlPath,
496
+ language,
497
+ languages,
498
+ localisedUrls,
499
+ );
500
+ if (expectedUrl !== `${pathname}${url.search}${url.hash}`) {
501
+ return c.redirect(expectedUrl);
502
+ }
503
+ }
397
504
  await next();
398
505
  },
399
506
  });