@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121

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 (119) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +32 -5
  3. package/dist/cjs/runtime/I18nLink.js +17 -28
  4. package/dist/cjs/runtime/Link.js +264 -0
  5. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  6. package/dist/cjs/runtime/context.js +41 -10
  7. package/dist/cjs/runtime/hooks.js +17 -10
  8. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
  10. package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
  11. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  12. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
  15. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  17. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  18. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  21. package/dist/cjs/runtime/i18n/index.js +9 -5
  22. package/dist/cjs/runtime/i18n/instance.js +17 -37
  23. package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
  24. package/dist/cjs/runtime/i18n/utils.js +9 -17
  25. package/dist/cjs/runtime/index.js +50 -15
  26. package/dist/cjs/runtime/localizedPaths.js +102 -0
  27. package/dist/cjs/runtime/routerAdapter.js +167 -0
  28. package/dist/cjs/runtime/utils.js +80 -97
  29. package/dist/cjs/server/index.js +62 -14
  30. package/dist/cjs/shared/deepMerge.js +12 -8
  31. package/dist/cjs/shared/detection.js +9 -5
  32. package/dist/cjs/shared/localisedUrls.js +351 -0
  33. package/dist/cjs/shared/utils.js +15 -11
  34. package/dist/esm/cli/index.mjs +23 -0
  35. package/dist/esm/runtime/I18nLink.mjs +7 -22
  36. package/dist/esm/runtime/Link.mjs +221 -0
  37. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  38. package/dist/esm/runtime/context.mjs +34 -7
  39. package/dist/esm/runtime/hooks.mjs +9 -6
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
  42. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  43. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  44. package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
  45. package/dist/esm/runtime/i18n/utils.mjs +0 -12
  46. package/dist/esm/runtime/index.mjs +23 -13
  47. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  48. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  49. package/dist/esm/runtime/utils.mjs +19 -31
  50. package/dist/esm/server/index.mjs +46 -8
  51. package/dist/esm/shared/localisedUrls.mjs +283 -0
  52. package/dist/esm-node/cli/index.mjs +23 -0
  53. package/dist/esm-node/runtime/I18nLink.mjs +7 -22
  54. package/dist/esm-node/runtime/Link.mjs +222 -0
  55. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  56. package/dist/esm-node/runtime/context.mjs +34 -7
  57. package/dist/esm-node/runtime/hooks.mjs +9 -6
  58. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  59. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
  60. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  61. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  62. package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
  63. package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
  64. package/dist/esm-node/runtime/index.mjs +23 -13
  65. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  66. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  67. package/dist/esm-node/runtime/utils.mjs +19 -31
  68. package/dist/esm-node/server/index.mjs +46 -8
  69. package/dist/esm-node/shared/localisedUrls.mjs +284 -0
  70. package/dist/types/cli/index.d.ts +1 -0
  71. package/dist/types/runtime/I18nLink.d.ts +6 -0
  72. package/dist/types/runtime/Link.d.ts +66 -0
  73. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  74. package/dist/types/runtime/context.d.ts +3 -0
  75. package/dist/types/runtime/hooks.d.ts +4 -2
  76. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
  77. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
  78. package/dist/types/runtime/i18n/instance.d.ts +4 -6
  79. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  80. package/dist/types/runtime/index.d.ts +6 -1
  81. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  82. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  83. package/dist/types/runtime/types.d.ts +1 -1
  84. package/dist/types/runtime/utils.d.ts +13 -9
  85. package/dist/types/server/index.d.ts +6 -0
  86. package/dist/types/shared/localisedUrls.d.ts +36 -0
  87. package/dist/types/shared/type.d.ts +14 -0
  88. package/package.json +24 -24
  89. package/rstest.config.mts +44 -0
  90. package/src/cli/index.ts +44 -1
  91. package/src/runtime/I18nLink.tsx +14 -51
  92. package/src/runtime/Link.tsx +430 -0
  93. package/src/runtime/canonicalRoutes.ts +93 -0
  94. package/src/runtime/context.tsx +45 -7
  95. package/src/runtime/hooks.ts +13 -4
  96. package/src/runtime/i18n/backend/defaults.node.ts +40 -2
  97. package/src/runtime/i18n/backend/defaults.ts +3 -1
  98. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  99. package/src/runtime/i18n/instance.ts +3 -30
  100. package/src/runtime/i18n/react-i18next.ts +25 -0
  101. package/src/runtime/i18n/utils.ts +4 -26
  102. package/src/runtime/index.tsx +47 -12
  103. package/src/runtime/localizedPaths.ts +107 -0
  104. package/src/runtime/routerAdapter.tsx +332 -0
  105. package/src/runtime/types.ts +1 -1
  106. package/src/runtime/utils.ts +33 -38
  107. package/src/server/index.ts +108 -11
  108. package/src/shared/localisedUrls.ts +623 -0
  109. package/src/shared/type.ts +14 -0
  110. package/tests/backendDefaults.test.ts +51 -0
  111. package/tests/i18nUtils.test.ts +59 -0
  112. package/tests/link.test.tsx +525 -0
  113. package/tests/linkTypes.test.ts +28 -0
  114. package/tests/localisedUrls.test.ts +536 -0
  115. package/tests/routerAdapter.test.tsx +456 -0
  116. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  117. package/tests/type-fixture/tsconfig.json +15 -0
  118. package/dist/esm/rslib-runtime.mjs +0 -18
  119. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -0,0 +1,332 @@
1
+ import { isBrowser, RuntimeContext } from '@modern-js/runtime';
2
+ import {
3
+ getRouterRuntimeState,
4
+ InternalRuntimeContext,
5
+ type TInternalRuntimeContext,
6
+ type TRuntimeContext,
7
+ } from '@modern-js/runtime/context';
8
+ import {
9
+ Link as ReactRouterLink,
10
+ useInRouterContext,
11
+ useLocation as useReactRouterLocation,
12
+ useNavigate as useReactRouterNavigate,
13
+ useParams as useReactRouterParams,
14
+ } from '@modern-js/runtime/router';
15
+ import type React from 'react';
16
+ import { useCallback, useContext, useEffect, useState } from 'react';
17
+
18
+ export type I18nRouterFramework = 'react-router' | 'tanstack' | string;
19
+
20
+ export interface I18nRouterLocation {
21
+ pathname: string;
22
+ search: string;
23
+ hash: string;
24
+ }
25
+
26
+ export interface I18nRouterNavigateOptions {
27
+ replace?: boolean;
28
+ state?: unknown;
29
+ }
30
+
31
+ export type I18nRouterNavigate = (
32
+ href: string,
33
+ options?: I18nRouterNavigateOptions,
34
+ ) => void | Promise<void>;
35
+
36
+ export type I18nRouterLink = React.ComponentType<{
37
+ to: string;
38
+ children?: React.ReactNode;
39
+ [key: string]: unknown;
40
+ }>;
41
+
42
+ export interface I18nRouterAdapter {
43
+ framework?: I18nRouterFramework;
44
+ hasRouter: boolean;
45
+ location: I18nRouterLocation | null;
46
+ navigate: I18nRouterNavigate | null;
47
+ Link: I18nRouterLink | null;
48
+ params: Record<string, string>;
49
+ }
50
+
51
+ type RuntimeContextWithRouter = TRuntimeContext & {
52
+ router?: {
53
+ useRouter?: (options?: { warn?: boolean }) => unknown;
54
+ useLocation?: () => unknown;
55
+ useHref?: () => unknown;
56
+ Link?: I18nRouterLink;
57
+ };
58
+ };
59
+
60
+ type InternalRuntimeContextWithRouter = TInternalRuntimeContext & {
61
+ router?: RuntimeContextWithRouter['router'];
62
+ };
63
+
64
+ type RouterInstance = {
65
+ navigate?: (...args: any[]) => unknown;
66
+ state?: {
67
+ location?: unknown;
68
+ matches?: Array<{ params?: Record<string, string> }>;
69
+ };
70
+ stores?: {
71
+ location?: {
72
+ get?: () => unknown;
73
+ subscribe?: (listener: () => void) => () => void;
74
+ };
75
+ matches?: {
76
+ get?: () => Array<{ params?: Record<string, string> }>;
77
+ };
78
+ };
79
+ subscribe?: (eventType: string, listener: () => void) => () => void;
80
+ };
81
+
82
+ const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
83
+ if (typeof value !== 'string' || !value) {
84
+ return '';
85
+ }
86
+ return value.startsWith(prefix) ? value : `${prefix}${value}`;
87
+ };
88
+
89
+ const normalizeLocation = (location: unknown): I18nRouterLocation | null => {
90
+ if (!location || typeof location !== 'object') {
91
+ return null;
92
+ }
93
+
94
+ const locationValue = location as {
95
+ pathname?: unknown;
96
+ search?: unknown;
97
+ searchStr?: unknown;
98
+ hash?: unknown;
99
+ };
100
+
101
+ if (typeof locationValue.pathname !== 'string') {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ pathname: locationValue.pathname,
107
+ search: normalizeUrlPart(
108
+ typeof locationValue.search === 'string'
109
+ ? locationValue.search
110
+ : locationValue.searchStr,
111
+ '?',
112
+ ),
113
+ hash: normalizeUrlPart(locationValue.hash, '#'),
114
+ };
115
+ };
116
+
117
+ const getWindowLocation = (): I18nRouterLocation | null => {
118
+ if (!isBrowser()) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ pathname: window.location.pathname,
124
+ search: window.location.search,
125
+ hash: window.location.hash,
126
+ };
127
+ };
128
+
129
+ const getRouterFramework = (
130
+ runtimeContext: RuntimeContextWithRouter,
131
+ internalContext: InternalRuntimeContextWithRouter,
132
+ inReactRouter: boolean,
133
+ ): I18nRouterFramework | undefined => {
134
+ const framework =
135
+ getRouterRuntimeState(internalContext)?.framework ||
136
+ getRouterRuntimeState(runtimeContext)?.framework;
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 = getRouterRuntimeState(internalContext)?.instance;
171
+ if (!router || typeof router !== 'object') {
172
+ return null;
173
+ }
174
+ return router as RouterInstance;
175
+ };
176
+
177
+ const getRouterStateLocation = (
178
+ internalContext: InternalRuntimeContextWithRouter,
179
+ contextRouter?: RouterInstance | null,
180
+ ): I18nRouterLocation | null => {
181
+ const router = getRouterInstance(internalContext, contextRouter);
182
+ return (
183
+ normalizeLocation(router?.stores?.location?.get?.()) ||
184
+ normalizeLocation(router?.state?.location)
185
+ );
186
+ };
187
+
188
+ const getRouterParams = (
189
+ internalContext: InternalRuntimeContextWithRouter,
190
+ contextRouter?: RouterInstance | null,
191
+ ): Record<string, string> => {
192
+ const router = getRouterInstance(internalContext, contextRouter);
193
+ const matches = router?.stores?.matches?.get?.() || router?.state?.matches;
194
+ if (!Array.isArray(matches)) {
195
+ return {};
196
+ }
197
+
198
+ return matches.reduce<Record<string, string>>((params, match) => {
199
+ if (match?.params) {
200
+ Object.assign(params, match.params);
201
+ }
202
+ return params;
203
+ }, {});
204
+ };
205
+
206
+ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
207
+ const runtimeContext = useContext(RuntimeContext) as RuntimeContextWithRouter;
208
+ const internalContext = useContext(
209
+ InternalRuntimeContext,
210
+ ) as InternalRuntimeContextWithRouter;
211
+ const inReactRouter = useInRouterContext();
212
+ const reactRouterNavigate = inReactRouter ? useReactRouterNavigate() : null;
213
+ const reactRouterLocation = inReactRouter ? useReactRouterLocation() : null;
214
+ const reactRouterParams = inReactRouter ? useReactRouterParams() : {};
215
+ const framework = getRouterFramework(
216
+ runtimeContext,
217
+ internalContext,
218
+ inReactRouter,
219
+ );
220
+ const contextUseRouter =
221
+ !inReactRouter && framework === 'tanstack'
222
+ ? internalContext.router?.useRouter || runtimeContext.router?.useRouter
223
+ : undefined;
224
+ const contextRouter = contextUseRouter
225
+ ? (contextUseRouter({ warn: false }) as RouterInstance | null)
226
+ : null;
227
+ const [, setRouterVersion] = useState(0);
228
+ const hasRouter =
229
+ framework === 'tanstack' ||
230
+ framework === 'react-router' ||
231
+ Boolean(reactRouterNavigate);
232
+
233
+ useEffect(() => {
234
+ if (framework !== 'tanstack') {
235
+ return;
236
+ }
237
+
238
+ const router = getRouterInstance(internalContext, contextRouter);
239
+ if (!router) {
240
+ return;
241
+ }
242
+
243
+ const update = () => setRouterVersion(version => version + 1);
244
+ const unsubscribers: Array<() => void> = [];
245
+
246
+ if (typeof router.stores?.location?.subscribe === 'function') {
247
+ const unsubscribe = router.stores.location.subscribe(update);
248
+ if (typeof unsubscribe === 'function') {
249
+ unsubscribers.push(unsubscribe);
250
+ }
251
+ }
252
+
253
+ if (typeof router.subscribe === 'function') {
254
+ for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
255
+ const unsubscribe = router.subscribe(eventType, update);
256
+ if (typeof unsubscribe === 'function') {
257
+ unsubscribers.push(unsubscribe);
258
+ }
259
+ }
260
+ }
261
+
262
+ return () => {
263
+ for (const unsubscribe of unsubscribers) {
264
+ unsubscribe();
265
+ }
266
+ };
267
+ }, [contextRouter, framework, internalContext]);
268
+
269
+ const navigate = useCallback<I18nRouterNavigate>(
270
+ (href, options) => {
271
+ const router = getRouterInstance(internalContext, contextRouter);
272
+ const activeFramework = getRouterFramework(
273
+ runtimeContext,
274
+ internalContext,
275
+ inReactRouter,
276
+ );
277
+
278
+ if (activeFramework === 'tanstack') {
279
+ if (typeof router?.navigate === 'function') {
280
+ return router.navigate({
281
+ to: href,
282
+ replace: options?.replace,
283
+ ...(options?.state === undefined ? {} : { state: options.state }),
284
+ }) as void | Promise<void>;
285
+ }
286
+ throw new Error('TanStack router instance is not available.');
287
+ }
288
+
289
+ if (reactRouterNavigate) {
290
+ return reactRouterNavigate(href, options);
291
+ }
292
+
293
+ if (activeFramework === 'react-router') {
294
+ if (typeof router?.navigate === 'function') {
295
+ return router.navigate(href, options) as void | Promise<void>;
296
+ }
297
+ throw new Error('React Router instance is not available.');
298
+ }
299
+ },
300
+ [
301
+ contextRouter,
302
+ internalContext,
303
+ inReactRouter,
304
+ reactRouterNavigate,
305
+ runtimeContext,
306
+ ],
307
+ );
308
+
309
+ const location =
310
+ (reactRouterLocation
311
+ ? normalizeLocation(reactRouterLocation)
312
+ : getRouterStateLocation(internalContext, contextRouter)) ||
313
+ getWindowLocation();
314
+ const params = inReactRouter
315
+ ? (reactRouterParams as Record<string, string>)
316
+ : getRouterParams(internalContext, contextRouter);
317
+ const Link =
318
+ framework === 'tanstack'
319
+ ? internalContext.router?.Link || runtimeContext.router?.Link || null
320
+ : framework === 'react-router' || inReactRouter
321
+ ? (ReactRouterLink as I18nRouterLink)
322
+ : null;
323
+
324
+ return {
325
+ framework,
326
+ hasRouter,
327
+ location,
328
+ navigate: hasRouter ? navigate : null,
329
+ Link,
330
+ params,
331
+ };
332
+ };
@@ -10,7 +10,7 @@ declare module '@modern-js/runtime' {
10
10
  };
11
11
  }
12
12
 
13
- interface TInternalRuntimeContext {
13
+ interface TRuntimeContext {
14
14
  i18nInstance?: I18nInstance;
15
15
  changeLanguage?: (lang: string) => Promise<void>;
16
16
  }
@@ -3,6 +3,8 @@ import {
3
3
  getGlobalBasename,
4
4
  type TInternalRuntimeContext,
5
5
  } from '@modern-js/runtime/context';
6
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
7
+ import { localiseTargetPathname } from '../shared/localisedUrls';
6
8
 
7
9
  export const getPathname = (context: TInternalRuntimeContext): string => {
8
10
  if (isBrowser()) {
@@ -40,29 +42,46 @@ export const getLanguageFromPath = (
40
42
  return fallbackLanguage;
41
43
  };
42
44
 
45
+ /**
46
+ * Split a link target into its pathname, search and hash parts without
47
+ * relying on `new URL` (SSR-hot path; targets are relative).
48
+ */
49
+ export const splitUrlTarget = (
50
+ target: string,
51
+ ): { pathname: string; search: string; hash: string } => {
52
+ const hashIndex = target.indexOf('#');
53
+ const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
54
+ const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
55
+ const searchIndex = beforeHash.indexOf('?');
56
+ const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
57
+ const pathname =
58
+ searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
59
+
60
+ return { pathname, search, hash };
61
+ };
62
+
43
63
  /**
44
64
  * Helper function to build localized URL
45
- * @param pathname - The current pathname
65
+ * @param target - The language-agnostic target; may include `?search` and `#hash`
46
66
  * @param language - The target language
47
67
  * @param languages - Array of supported languages
48
- * @returns The localized URL path
68
+ * @returns The localized URL path with search and hash re-appended verbatim
49
69
  */
50
70
  export const buildLocalizedUrl = (
51
- pathname: string,
71
+ target: string,
52
72
  language: string,
53
73
  languages: string[],
74
+ localisedUrls?: LocalisedUrlsOption,
54
75
  ): string => {
55
- 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('/')}`;
76
+ const { pathname, search, hash } = splitUrlTarget(target);
77
+ const localizedPathname = localiseTargetPathname(
78
+ pathname,
79
+ language,
80
+ languages,
81
+ localisedUrls,
82
+ );
83
+
84
+ return `${localizedPathname}${search}${hash}`;
66
85
  };
67
86
 
68
87
  export const detectLanguageFromPath = (
@@ -137,27 +156,3 @@ export const shouldIgnoreRedirect = (
137
156
  );
138
157
  });
139
158
  };
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
+ localiseTargetPathname,
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;
@@ -241,17 +314,12 @@ const buildLocalizedUrl = (
241
314
  ? pathname.slice(basePath.length)
242
315
  : pathname;
243
316
 
244
- 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('/')}`;
317
+ const newPathname = localiseTargetPathname(
318
+ remainingPath,
319
+ language,
320
+ languages,
321
+ localisedUrls,
322
+ );
255
323
  // Handle root path case to avoid double slashes like //en
256
324
  const suffix = `${url.search}${url.hash}`;
257
325
  const localizedUrl =
@@ -265,6 +333,11 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
265
333
  setup: api => {
266
334
  api.onPrepare(() => {
267
335
  const { middlewares, routes } = api.getServerContext();
336
+ const serverConfig = api.getServerConfig();
337
+ const bffPrefix = serverConfig?.bff
338
+ ? (serverConfig.bff.prefix ?? '/api')
339
+ : undefined;
340
+ const apiPrefixes = collectApiPrefixes(routes, bffPrefix);
268
341
 
269
342
  // Collect all non-root entry paths for cross-entry path detection
270
343
  const entryPaths = new Set<string>();
@@ -292,6 +365,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
292
365
  fallbackLanguage = 'en',
293
366
  detection,
294
367
  ignoreRedirectRoutes,
368
+ localisedUrls,
295
369
  } = getLocaleDetectionOptions(entryName, options.localeDetection);
296
370
  const staticRoutePrefixes = options.staticRoutePrefixes;
297
371
  const originUrlPath = route.urlPath;
@@ -314,6 +388,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
314
388
  const url = new URL(c.req.url);
315
389
  const pathname = url.pathname;
316
390
 
391
+ if (matchesApiPrefix(pathname, apiPrefixes)) {
392
+ return await next();
393
+ }
394
+
317
395
  // For static resource requests, skip language detection
318
396
  if (
319
397
  isStaticResourceRequest(
@@ -348,6 +426,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
348
426
  const url = new URL(c.req.url);
349
427
  const pathname = url.pathname;
350
428
 
429
+ if (matchesApiPrefix(pathname, apiPrefixes)) {
430
+ return await next();
431
+ }
432
+
351
433
  // For static resource requests, skip i18n processing
352
434
  if (
353
435
  isStaticResourceRequest(
@@ -391,9 +473,24 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
391
473
  originUrlPath,
392
474
  targetLanguage,
393
475
  languages,
476
+ localisedUrls,
394
477
  );
395
478
  return c.redirect(localizedUrl);
396
479
  }
480
+ const localisedUrlsConfig =
481
+ resolveLocalisedUrlsConfig(localisedUrls);
482
+ if (localisedUrlsConfig.enabled) {
483
+ const expectedUrl = buildLocalizedUrl(
484
+ c.req,
485
+ originUrlPath,
486
+ language,
487
+ languages,
488
+ localisedUrls,
489
+ );
490
+ if (expectedUrl !== `${pathname}${url.search}${url.hash}`) {
491
+ return c.redirect(expectedUrl);
492
+ }
493
+ }
397
494
  await next();
398
495
  },
399
496
  });