@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.9 → 3.2.0-ultramodern.91

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 (80) 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/defaults.node.js +2 -2
  7. package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
  8. package/dist/cjs/runtime/i18n/instance.js +0 -24
  9. package/dist/cjs/runtime/i18n/react-i18next.js +49 -0
  10. package/dist/cjs/runtime/i18n/utils.js +0 -12
  11. package/dist/cjs/runtime/index.js +18 -10
  12. package/dist/cjs/runtime/routerAdapter.js +163 -0
  13. package/dist/cjs/runtime/utils.js +63 -94
  14. package/dist/cjs/server/index.js +60 -8
  15. package/dist/cjs/shared/localisedUrls.js +237 -0
  16. package/dist/esm/cli/index.mjs +22 -0
  17. package/dist/esm/runtime/I18nLink.mjs +4 -12
  18. package/dist/esm/runtime/context.mjs +34 -7
  19. package/dist/esm/runtime/hooks.mjs +9 -6
  20. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  21. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +2 -2
  22. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  23. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  24. package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
  25. package/dist/esm/runtime/i18n/utils.mjs +0 -12
  26. package/dist/esm/runtime/index.mjs +19 -11
  27. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  28. package/dist/esm/runtime/utils.mjs +11 -30
  29. package/dist/esm/server/index.mjs +53 -7
  30. package/dist/esm/shared/localisedUrls.mjs +191 -0
  31. package/dist/esm-node/cli/index.mjs +22 -0
  32. package/dist/esm-node/runtime/I18nLink.mjs +4 -12
  33. package/dist/esm-node/runtime/context.mjs +34 -7
  34. package/dist/esm-node/runtime/hooks.mjs +9 -6
  35. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  36. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +2 -2
  37. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  38. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  39. package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
  40. package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
  41. package/dist/esm-node/runtime/index.mjs +19 -11
  42. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  43. package/dist/esm-node/runtime/utils.mjs +11 -30
  44. package/dist/esm-node/server/index.mjs +53 -7
  45. package/dist/esm-node/shared/localisedUrls.mjs +192 -0
  46. package/dist/types/runtime/I18nLink.d.ts +15 -0
  47. package/dist/types/runtime/context.d.ts +3 -0
  48. package/dist/types/runtime/hooks.d.ts +4 -2
  49. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
  50. package/dist/types/runtime/i18n/instance.d.ts +0 -5
  51. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  52. package/dist/types/runtime/index.d.ts +1 -0
  53. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  54. package/dist/types/runtime/utils.d.ts +2 -7
  55. package/dist/types/server/index.d.ts +6 -0
  56. package/dist/types/shared/localisedUrls.d.ts +13 -0
  57. package/dist/types/shared/type.d.ts +12 -0
  58. package/package.json +18 -22
  59. package/rstest.config.mts +39 -0
  60. package/src/cli/index.ts +43 -1
  61. package/src/runtime/I18nLink.tsx +10 -16
  62. package/src/runtime/context.tsx +45 -7
  63. package/src/runtime/hooks.ts +13 -4
  64. package/src/runtime/i18n/backend/defaults.node.ts +2 -2
  65. package/src/runtime/i18n/backend/defaults.ts +3 -1
  66. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  67. package/src/runtime/i18n/instance.ts +0 -29
  68. package/src/runtime/i18n/react-i18next.ts +25 -0
  69. package/src/runtime/i18n/utils.ts +4 -26
  70. package/src/runtime/index.tsx +23 -10
  71. package/src/runtime/routerAdapter.tsx +333 -0
  72. package/src/runtime/utils.ts +22 -34
  73. package/src/server/index.ts +117 -10
  74. package/src/shared/localisedUrls.ts +393 -0
  75. package/src/shared/type.ts +12 -0
  76. package/tests/i18nUtils.test.ts +52 -0
  77. package/tests/localisedUrls.test.ts +312 -0
  78. package/tests/routerAdapter.test.tsx +382 -0
  79. package/dist/esm/rslib-runtime.mjs +0 -18
  80. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -29,11 +29,7 @@ import {
29
29
  mergeDetectionOptions,
30
30
  } from './i18n/detection';
31
31
  import { useI18nextLanguageDetector } from './i18n/detection/middleware';
32
- import {
33
- getI18nextInstanceForProvider,
34
- getI18nextProvider,
35
- getInitReactI18next,
36
- } from './i18n/instance';
32
+ import { getI18nextInstanceForProvider } from './i18n/instance';
37
33
  import {
38
34
  changeI18nLanguage,
39
35
  ensureLanguageMatch,
@@ -54,6 +50,7 @@ export interface I18nPluginOptions {
54
50
  changeLanguage?: (lang: string) => void;
55
51
  initOptions?: I18nInitOptions;
56
52
  htmlLangAttr?: boolean;
53
+ reactI18next?: boolean;
57
54
  [key: string]: any;
58
55
  }
59
56
 
@@ -72,6 +69,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
72
69
  localeDetection,
73
70
  backend,
74
71
  htmlLangAttr = false,
72
+ reactI18next = true,
75
73
  } = options;
76
74
  const {
77
75
  localePathRedirect = false,
@@ -80,20 +78,31 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
80
78
  fallbackLanguage = 'en',
81
79
  detection,
82
80
  ignoreRedirectRoutes,
81
+ localisedUrls,
83
82
  } = localeDetection || {};
84
83
  const { enabled: backendEnabled = false } = backend || {};
85
84
  let latestI18nInstance: I18nInstance | undefined;
86
85
  let I18nextProvider: React.FunctionComponent<any> | null;
87
86
 
87
+ const loadReactI18nextIntegration = async () => {
88
+ if (!reactI18next) {
89
+ return null;
90
+ }
91
+ const { getReactI18nextIntegration } = await import(
92
+ './i18n/react-i18next'
93
+ );
94
+ return getReactI18nextIntegration();
95
+ };
96
+
88
97
  api.onBeforeRender(async context => {
89
98
  let i18nInstance = await getI18nInstance(userI18nInstance);
90
99
  const { i18n: otherConfig } = api.getRuntimeConfig();
91
100
  const { initOptions: otherInitOptions } = otherConfig || {};
92
101
  const userInitOptions = merge(otherInitOptions || {}, initOptions || {});
93
- const initReactI18next = await getInitReactI18next();
94
- I18nextProvider = await getI18nextProvider();
95
- if (initReactI18next) {
96
- i18nInstance.use(initReactI18next);
102
+ const reactI18nextIntegration = await loadReactI18nextIntegration();
103
+ I18nextProvider = reactI18nextIntegration?.I18nextProvider ?? null;
104
+ if (reactI18nextIntegration?.initReactI18next) {
105
+ i18nInstance.use(reactI18nextIntegration.initReactI18next);
97
106
  }
98
107
 
99
108
  const pathname = getPathname(context);
@@ -227,6 +236,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
227
236
  languages,
228
237
  fallbackLanguage,
229
238
  ignoreRedirectRoutes,
239
+ localisedUrls,
230
240
  );
231
241
 
232
242
  const contextValue = useMemo(
@@ -238,6 +248,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
238
248
  languages,
239
249
  localePathRedirect,
240
250
  ignoreRedirectRoutes,
251
+ localisedUrls,
241
252
  setLang,
242
253
  ),
243
254
  [
@@ -247,15 +258,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
247
258
  languages,
248
259
  localePathRedirect,
249
260
  ignoreRedirectRoutes,
261
+ localisedUrls,
250
262
  forceUpdate,
251
263
  ],
252
264
  );
253
265
 
266
+ const children = (props as React.PropsWithChildren).children;
254
267
  const appContent = (
255
268
  <>
256
269
  {Boolean(htmlLangAttr) && <Helmet htmlAttributes={{ lang }} />}
257
270
  <ModernI18nProvider value={contextValue}>
258
- <App {...props} />
271
+ {App ? <App {...props}>{children}</App> : children}
259
272
  </ModernI18nProvider>
260
273
  </>
261
274
  );
@@ -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
  });