@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.4 → 3.9.0-ultramodern.6

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 (79) hide show
  1. package/dist/cjs/cli/index.js +0 -22
  2. package/dist/cjs/runtime/Link.js +19 -32
  3. package/dist/cjs/runtime/context.js +26 -61
  4. package/dist/cjs/runtime/contextHelpers.js +5 -5
  5. package/dist/cjs/runtime/core.js +17 -14
  6. package/dist/cjs/runtime/hooks.js +7 -8
  7. package/dist/cjs/runtime/localizedPaths.js +11 -9
  8. package/dist/cjs/runtime/providerComposition.js +37 -13
  9. package/dist/cjs/runtime/routerAdapter.js +35 -140
  10. package/dist/cjs/runtime/utils.js +17 -19
  11. package/dist/cjs/server/index.js +10 -7
  12. package/dist/cjs/server/redirectPolicy.js +36 -31
  13. package/dist/cjs/shared/urlStrategy.js +18 -0
  14. package/dist/esm/cli/index.mjs +0 -22
  15. package/dist/esm/runtime/Link.mjs +19 -32
  16. package/dist/esm/runtime/context.mjs +18 -62
  17. package/dist/esm/runtime/contextHelpers.mjs +5 -5
  18. package/dist/esm/runtime/core.mjs +8 -5
  19. package/dist/esm/runtime/hooks.mjs +7 -8
  20. package/dist/esm/runtime/localizedPaths.mjs +11 -9
  21. package/dist/esm/runtime/providerComposition.mjs +38 -14
  22. package/dist/esm/runtime/routerAdapter.mjs +30 -141
  23. package/dist/esm/runtime/utils.mjs +15 -17
  24. package/dist/esm/server/index.mjs +10 -7
  25. package/dist/esm/server/redirectPolicy.mjs +29 -24
  26. package/dist/esm/shared/urlStrategy.mjs +0 -0
  27. package/dist/esm-node/cli/index.mjs +0 -22
  28. package/dist/esm-node/runtime/Link.mjs +19 -32
  29. package/dist/esm-node/runtime/context.mjs +18 -62
  30. package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
  31. package/dist/esm-node/runtime/core.mjs +8 -5
  32. package/dist/esm-node/runtime/hooks.mjs +7 -8
  33. package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
  34. package/dist/esm-node/runtime/providerComposition.mjs +38 -14
  35. package/dist/esm-node/runtime/routerAdapter.mjs +30 -141
  36. package/dist/esm-node/runtime/utils.mjs +15 -17
  37. package/dist/esm-node/server/index.mjs +10 -7
  38. package/dist/esm-node/server/redirectPolicy.mjs +29 -24
  39. package/dist/esm-node/shared/urlStrategy.mjs +1 -0
  40. package/dist/types/runtime/context.d.ts +8 -11
  41. package/dist/types/runtime/contextHelpers.d.ts +2 -2
  42. package/dist/types/runtime/core.d.ts +11 -2
  43. package/dist/types/runtime/hooks.d.ts +4 -5
  44. package/dist/types/runtime/localizedPaths.d.ts +4 -4
  45. package/dist/types/runtime/providerComposition.d.ts +13 -1
  46. package/dist/types/runtime/routerAdapter.d.ts +23 -6
  47. package/dist/types/runtime/utils.d.ts +4 -12
  48. package/dist/types/server/index.d.ts +2 -0
  49. package/dist/types/server/redirectPolicy.d.ts +5 -13
  50. package/dist/types/shared/type.d.ts +0 -14
  51. package/dist/types/shared/urlStrategy.d.ts +7 -0
  52. package/package.json +21 -11
  53. package/rstest.config.mts +2 -8
  54. package/src/cli/index.ts +1 -43
  55. package/src/runtime/Link.tsx +20 -40
  56. package/src/runtime/context.tsx +34 -133
  57. package/src/runtime/contextHelpers.ts +7 -5
  58. package/src/runtime/core.tsx +26 -6
  59. package/src/runtime/hooks.ts +14 -9
  60. package/src/runtime/localizedPaths.ts +24 -19
  61. package/src/runtime/providerComposition.tsx +71 -23
  62. package/src/runtime/routerAdapter.tsx +73 -338
  63. package/src/runtime/utils.ts +40 -31
  64. package/src/server/index.ts +44 -9
  65. package/src/server/redirectPolicy.ts +70 -70
  66. package/src/shared/type.ts +0 -14
  67. package/src/shared/urlStrategy.ts +11 -0
  68. package/tests/federatedI18nBoundaryResolver.test.tsx +71 -0
  69. package/tests/i18nUtils.test.ts +31 -65
  70. package/tests/link.test.tsx +13 -511
  71. package/tests/linkTypes.test.ts +12 -3
  72. package/tests/localisedUrlRewriteMatrix.fork.test.ts +4 -96
  73. package/tests/localisedUrls.test.ts +7 -216
  74. package/tests/reactI18nextRuntimeBoundary.test.ts +1 -82
  75. package/tests/redirectPolicy.test.ts +39 -30
  76. package/tests/routerAdapter.test.tsx +95 -1055
  77. package/tests/federatedI18nBoundary.client.test.tsx +0 -129
  78. package/tests/federatedI18nBoundary.test.tsx +0 -132
  79. package/tests/peerCohort.test.ts +0 -25
@@ -1,14 +1,7 @@
1
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';
2
+ import { InternalRuntimeContext } from '@modern-js/runtime/context';
8
3
  import type React from 'react';
9
- import { useCallback, useContext, useSyncExternalStore } from 'react';
10
-
11
- type I18nRouterFramework = 'react-router' | 'tanstack' | string;
4
+ import { createContext, useContext } from 'react';
12
5
 
13
6
  interface I18nRouterLocation {
14
7
  pathname: string;
@@ -16,14 +9,9 @@ interface I18nRouterLocation {
16
9
  hash: string;
17
10
  }
18
11
 
19
- interface I18nRouterNavigateOptions {
20
- replace?: boolean;
21
- state?: unknown;
22
- }
23
-
24
12
  type I18nRouterNavigate = (
25
13
  href: string,
26
- options?: I18nRouterNavigateOptions,
14
+ options?: { replace?: boolean; state?: unknown },
27
15
  ) => void | Promise<void>;
28
16
 
29
17
  type I18nRouterLink = React.ComponentType<{
@@ -32,345 +20,92 @@ type I18nRouterLink = React.ComponentType<{
32
20
  [key: string]: unknown;
33
21
  }>;
34
22
 
35
- interface I18nRouterAdapter {
36
- framework?: I18nRouterFramework;
23
+ export interface I18nRouterLinkTarget {
24
+ pathname: string;
25
+ href: string;
26
+ search?: Record<string, unknown>;
27
+ hash?: string;
28
+ hashScrollIntoView?: boolean | ScrollIntoViewOptions;
29
+ prefetch?: 'intent' | 'render' | 'viewport' | 'none';
30
+ preload?: unknown;
31
+ }
32
+
33
+ export interface I18nRouterAdapter {
34
+ framework?: string;
37
35
  hasRouter: boolean;
38
36
  location: I18nRouterLocation | null;
39
37
  navigate: I18nRouterNavigate | null;
40
38
  Link: I18nRouterLink | null;
41
39
  params: Record<string, string>;
42
- }
43
-
44
- type RuntimeContextWithRouter = TRuntimeContext & {
45
- router?: {
46
- useInRouterContext?: () => boolean;
47
- useRouter?: (options?: { warn?: boolean }) => unknown;
48
- useLocation?: () => unknown;
49
- useNavigate?: () => I18nRouterNavigate;
50
- useParams?: () => Record<string, string>;
51
- useHref?: () => unknown;
52
- Link?: I18nRouterLink;
53
- };
54
- };
55
-
56
- type InternalRuntimeContextWithRouter = TInternalRuntimeContext & {
57
- router?: RuntimeContextWithRouter['router'];
58
- };
59
-
60
- type RouterInstance = {
61
- navigate?: (...args: any[]) => unknown;
62
- state?: {
63
- location?: unknown;
64
- matches?: Array<{ params?: Record<string, string> }>;
65
- };
66
- stores?: {
67
- location?: {
68
- get?: () => unknown;
69
- subscribe?: (listener: () => void) => () => void;
70
- };
71
- matches?: {
72
- get?: () => Array<{ params?: Record<string, string> }>;
73
- };
74
- };
75
- subscribe?:
76
- | ((listener: () => void) => () => void)
77
- | ((eventType: string, listener: () => void) => () => void);
78
- };
79
-
80
- const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
81
- if (typeof value !== 'string' || !value) {
82
- return '';
83
- }
84
- return value.startsWith(prefix) ? value : `${prefix}${value}`;
85
- };
86
-
87
- const normalizeLocation = (location: unknown): I18nRouterLocation | null => {
88
- if (!location || typeof location !== 'object') {
89
- return null;
90
- }
91
-
92
- const locationValue = location as {
93
- pathname?: unknown;
94
- search?: unknown;
95
- searchStr?: unknown;
96
- hash?: unknown;
97
- };
98
-
99
- if (typeof locationValue.pathname !== 'string') {
100
- return null;
101
- }
102
-
103
- return {
104
- pathname: locationValue.pathname,
105
- search: normalizeUrlPart(
106
- typeof locationValue.search === 'string'
107
- ? locationValue.search
108
- : locationValue.searchStr,
109
- '?',
110
- ),
111
- hash: normalizeUrlPart(locationValue.hash, '#'),
112
- };
113
- };
114
-
115
- const getWindowLocation = (): I18nRouterLocation | null => {
116
- if (!isBrowser()) {
117
- return null;
118
- }
119
-
120
- return {
121
- pathname: window.location.pathname,
122
- search: window.location.search,
123
- hash: window.location.hash,
40
+ /** Convert an already resolved target to the selected router's own props. */
41
+ createLinkProps?: (target: I18nRouterLinkTarget) => {
42
+ to: string;
43
+ [key: string]: unknown;
124
44
  };
125
- };
126
-
127
- const getRouterFramework = (
128
- runtimeContext: RuntimeContextWithRouter,
129
- internalContext: InternalRuntimeContextWithRouter,
130
- inReactRouter: boolean,
131
- ): I18nRouterFramework | undefined => {
132
- const framework =
133
- getRouterRuntimeState(internalContext)?.framework ||
134
- getRouterRuntimeState(runtimeContext)?.framework;
135
-
136
- if (framework) {
137
- return framework;
138
- }
139
-
140
- if (internalContext.router?.useRouter || runtimeContext.router?.useRouter) {
141
- return 'tanstack';
142
- }
143
-
144
- if (
145
- internalContext.router?.useLocation ||
146
- internalContext.router?.useHref ||
147
- runtimeContext.router?.useLocation ||
148
- runtimeContext.router?.useHref
149
- ) {
150
- return 'react-router';
151
- }
152
-
153
- if (inReactRouter) {
154
- return 'react-router';
155
- }
156
-
157
- return undefined;
158
- };
159
-
160
- const getRouterInstance = (
161
- internalContext: InternalRuntimeContextWithRouter,
162
- contextRouter?: RouterInstance | null,
163
- ): RouterInstance | null => {
164
- if (contextRouter) {
165
- return contextRouter;
166
- }
167
-
168
- const router = getRouterRuntimeState(internalContext)?.instance;
169
- if (!router || typeof router !== 'object') {
170
- return null;
171
- }
172
- return router as RouterInstance;
173
- };
174
-
175
- const getRouterStateLocation = (
176
- internalContext: InternalRuntimeContextWithRouter,
177
- contextRouter?: RouterInstance | null,
178
- ): I18nRouterLocation | null => {
179
- const router = getRouterInstance(internalContext, contextRouter);
180
- return (
181
- normalizeLocation(router?.stores?.location?.get?.()) ||
182
- normalizeLocation(router?.state?.location)
183
- );
184
- };
185
-
186
- const getRouterParams = (
187
- internalContext: InternalRuntimeContextWithRouter,
188
- contextRouter?: RouterInstance | null,
189
- ): Record<string, string> => {
190
- const router = getRouterInstance(internalContext, contextRouter);
191
- const matches = router?.stores?.matches?.get?.() || router?.state?.matches;
192
- if (!Array.isArray(matches)) {
193
- return {};
194
- }
195
-
196
- return matches.reduce<Record<string, string>>((params, match) => {
197
- if (match?.params) {
198
- Object.assign(params, match.params);
199
- }
200
- return params;
201
- }, {});
202
- };
45
+ }
203
46
 
204
- const getRouterSnapshot = (
205
- internalContext: InternalRuntimeContextWithRouter,
206
- contextRouter?: RouterInstance | null,
207
- ) => {
208
- const location = getRouterStateLocation(internalContext, contextRouter);
209
- const params = getRouterParams(internalContext, contextRouter);
210
- return JSON.stringify([
211
- location?.pathname ?? '',
212
- location?.search ?? '',
213
- location?.hash ?? '',
214
- Object.entries(params).sort(([left], [right]) => left.localeCompare(right)),
215
- ]);
47
+ const navigationContextKey = Symbol.for(
48
+ '@modern-js/plugin-i18n/runtime/I18nNavigationContext',
49
+ );
50
+ // Only the React context identity is shared. Its values remain provider-owned.
51
+ const contextStore = globalThis as typeof globalThis & {
52
+ [navigationContextKey]?: React.Context<I18nRouterAdapter | null>;
216
53
  };
54
+ const I18nNavigationContext = (contextStore[navigationContextKey] ??=
55
+ createContext<I18nRouterAdapter | null>(null));
56
+
57
+ export const I18nNavigationProvider = ({
58
+ children,
59
+ value,
60
+ }: React.PropsWithChildren<{ value: I18nRouterAdapter }>) => (
61
+ <I18nNavigationContext.Provider value={value}>
62
+ {children}
63
+ </I18nNavigationContext.Provider>
64
+ );
65
+
66
+ interface NativeRouterApi {
67
+ useInRouterContext?: () => boolean;
68
+ useNavigate?: () => I18nRouterNavigate;
69
+ useLocation?: () => I18nRouterLocation;
70
+ useParams?: () => Record<string, string>;
71
+ Link?: I18nRouterLink;
72
+ }
217
73
 
218
- export const useI18nRouterAdapter = (): I18nRouterAdapter => {
219
- const runtimeContext = useContext(RuntimeContext) as RuntimeContextWithRouter;
220
- const internalContext = useContext(
221
- InternalRuntimeContext,
222
- ) as InternalRuntimeContextWithRouter;
223
- const routerApi = internalContext.router || runtimeContext.router;
224
- const inReactRouter = routerApi?.useInRouterContext?.() ?? false;
225
- const reactRouterNavigate = inReactRouter
226
- ? (routerApi?.useNavigate?.() ?? null)
227
- : null;
228
- const reactRouterLocation = inReactRouter
74
+ /** Read native router hooks only when their own provider is present. */
75
+ export const useNativeI18nRouterAdapter = (): I18nRouterAdapter => {
76
+ const runtimeContext = useContext(RuntimeContext);
77
+ const internalContext = useContext(InternalRuntimeContext);
78
+ const routerApi = (internalContext.router || runtimeContext.router) as
79
+ | NativeRouterApi
80
+ | undefined;
81
+ const hasRouter = routerApi?.useInRouterContext?.() ?? false;
82
+ const navigate = hasRouter ? (routerApi?.useNavigate?.() ?? null) : null;
83
+ const routerLocation = hasRouter
229
84
  ? (routerApi?.useLocation?.() ?? null)
230
85
  : null;
231
- const reactRouterParams = inReactRouter
232
- ? (routerApi?.useParams?.() ?? {})
233
- : {};
234
- const framework = getRouterFramework(
235
- runtimeContext,
236
- internalContext,
237
- inReactRouter,
238
- );
239
- const contextUseRouter =
240
- !inReactRouter && framework === 'tanstack'
241
- ? internalContext.router?.useRouter || runtimeContext.router?.useRouter
242
- : undefined;
243
- const contextRouter = contextUseRouter
244
- ? (contextUseRouter({ warn: false }) as RouterInstance | null)
245
- : null;
246
- const hasRouter =
247
- framework === 'tanstack' ||
248
- framework === 'react-router' ||
249
- Boolean(reactRouterNavigate);
250
-
251
- const subscribeToRouter = useCallback(
252
- (update: () => void) => {
253
- const router = getRouterInstance(internalContext, contextRouter);
254
- if (!router) {
255
- return () => undefined;
256
- }
257
-
258
- const unsubscribers: Array<() => void> = [];
259
-
260
- if (
261
- framework === 'react-router' &&
262
- !inReactRouter &&
263
- typeof router.subscribe === 'function'
264
- ) {
265
- const subscribe = router.subscribe as (
266
- this: RouterInstance,
267
- listener: () => void,
268
- ) => () => void;
269
- const unsubscribe = subscribe.call(router, update);
270
- if (typeof unsubscribe === 'function') {
271
- unsubscribers.push(unsubscribe);
272
- }
273
- }
274
-
275
- if (
276
- framework === 'tanstack' &&
277
- typeof router.stores?.location?.subscribe === 'function'
278
- ) {
279
- const unsubscribe = router.stores.location.subscribe(update);
280
- if (typeof unsubscribe === 'function') {
281
- unsubscribers.push(unsubscribe);
282
- }
283
- }
284
-
285
- if (framework === 'tanstack' && typeof router.subscribe === 'function') {
286
- const subscribe = router.subscribe as (
287
- this: RouterInstance,
288
- eventType: string,
289
- listener: () => void,
290
- ) => () => void;
291
- for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
292
- const unsubscribe = subscribe.call(router, eventType, update);
293
- if (typeof unsubscribe === 'function') {
294
- unsubscribers.push(unsubscribe);
295
- }
296
- }
297
- }
298
-
299
- return () => {
300
- for (const unsubscribe of unsubscribers) {
301
- unsubscribe();
302
- }
303
- };
304
- },
305
- [contextRouter, framework, inReactRouter, internalContext],
306
- );
307
- const getSnapshot = useCallback(
308
- () => getRouterSnapshot(internalContext, contextRouter),
309
- [contextRouter, internalContext],
310
- );
311
- useSyncExternalStore(subscribeToRouter, getSnapshot, getSnapshot);
312
-
313
- const navigate = useCallback<I18nRouterNavigate>(
314
- (href, options) => {
315
- const router = getRouterInstance(internalContext, contextRouter);
316
- const activeFramework = getRouterFramework(
317
- runtimeContext,
318
- internalContext,
319
- inReactRouter,
320
- );
321
-
322
- if (activeFramework === 'tanstack') {
323
- if (typeof router?.navigate === 'function') {
324
- return router.navigate({
325
- to: href,
326
- replace: options?.replace,
327
- ...(options?.state === undefined ? {} : { state: options.state }),
328
- }) as void | Promise<void>;
329
- }
330
- throw new Error('TanStack router instance is not available.');
331
- }
332
-
333
- if (reactRouterNavigate) {
334
- return reactRouterNavigate(href, options);
335
- }
336
-
337
- if (activeFramework === 'react-router') {
338
- if (typeof router?.navigate === 'function') {
339
- return router.navigate(href, options) as void | Promise<void>;
340
- }
341
- throw new Error('React Router instance is not available.');
342
- }
343
- },
344
- [
345
- contextRouter,
346
- internalContext,
347
- inReactRouter,
348
- reactRouterNavigate,
349
- runtimeContext,
350
- ],
351
- );
352
-
86
+ const params = hasRouter ? (routerApi?.useParams?.() ?? {}) : {};
353
87
  const location =
354
- (reactRouterLocation
355
- ? normalizeLocation(reactRouterLocation)
356
- : getRouterStateLocation(internalContext, contextRouter)) ||
357
- getWindowLocation();
358
- const params = inReactRouter
359
- ? (reactRouterParams as Record<string, string>)
360
- : getRouterParams(internalContext, contextRouter);
361
- const Link =
362
- framework === 'tanstack'
363
- ? internalContext.router?.Link || runtimeContext.router?.Link || null
364
- : framework === 'react-router' || inReactRouter
365
- ? (routerApi?.Link ?? null)
366
- : null;
88
+ routerLocation ??
89
+ (isBrowser()
90
+ ? {
91
+ pathname: window.location.pathname,
92
+ search: window.location.search,
93
+ hash: window.location.hash,
94
+ }
95
+ : null);
367
96
 
368
97
  return {
369
- framework,
98
+ framework: hasRouter ? 'react-router' : undefined,
370
99
  hasRouter,
100
+ navigate,
371
101
  location,
372
- navigate: hasRouter ? navigate : null,
373
- Link,
374
102
  params,
103
+ Link: hasRouter ? (routerApi?.Link ?? null) : null,
375
104
  };
376
105
  };
106
+
107
+ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
108
+ const provided = useContext(I18nNavigationContext);
109
+ const native = useNativeI18nRouterAdapter();
110
+ return provided ?? native;
111
+ };
@@ -1,10 +1,9 @@
1
- import {
2
- type LocalisedUrlsOption,
3
- localiseTargetPathname,
4
- shouldSkipLocaleRedirect,
5
- } from '@modern-js/i18n-runtime-extensions';
6
1
  import { isBrowser } from '@modern-js/runtime';
7
2
  import { getGlobalBasename } from '@modern-js/runtime/context';
3
+ import { splitUrlTarget } from '@modern-js/runtime-utils/url';
4
+ import type { I18nUrlStrategy } from '../shared/urlStrategy';
5
+
6
+ export { splitUrlTarget } from '@modern-js/runtime-utils/url';
8
7
 
9
8
  // Structural parameter: hooks.ts passes a public-TRuntimeContext-based
10
9
  // context while core.tsx passes the internal one; both carry the request
@@ -47,24 +46,6 @@ export const getLanguageFromPath = (
47
46
  return fallbackLanguage;
48
47
  };
49
48
 
50
- /**
51
- * Split a link target into its pathname, search and hash parts without
52
- * relying on `new URL` (SSR-hot path; targets are relative).
53
- */
54
- export const splitUrlTarget = (
55
- target: string,
56
- ): { pathname: string; search: string; hash: string } => {
57
- const hashIndex = target.indexOf('#');
58
- const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
59
- const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
60
- const searchIndex = beforeHash.indexOf('?');
61
- const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
62
- const pathname =
63
- searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
64
-
65
- return { pathname, search, hash };
66
- };
67
-
68
49
  /**
69
50
  * Helper function to build localized URL
70
51
  * @param target - The language-agnostic target; may include `?search` and `#hash`
@@ -76,15 +57,22 @@ export const buildLocalizedUrl = (
76
57
  target: string,
77
58
  language: string,
78
59
  languages: string[],
79
- localisedUrls?: LocalisedUrlsOption,
60
+ urlStrategy?: I18nUrlStrategy,
80
61
  ): string => {
81
62
  const { pathname, search, hash } = splitUrlTarget(target);
82
- const localizedPathname = localiseTargetPathname(
83
- pathname,
84
- language,
85
- languages,
86
- localisedUrls,
87
- );
63
+ if (urlStrategy) {
64
+ return `${urlStrategy.localizePathname(pathname, language, languages)}${search}${hash}`;
65
+ }
66
+ const segments = pathname.split('/').filter(Boolean);
67
+ if (
68
+ segments[0] &&
69
+ languages.some(item => item.toLowerCase() === segments[0].toLowerCase())
70
+ ) {
71
+ segments[0] = language;
72
+ } else {
73
+ segments.unshift(language);
74
+ }
75
+ const localizedPathname = `/${segments.join('/')}`;
88
76
 
89
77
  return `${localizedPathname}${search}${hash}`;
90
78
  };
@@ -131,6 +119,27 @@ export const shouldIgnoreRedirect = (
131
119
  pathname: string,
132
120
  languages: string[],
133
121
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
122
+ urlStrategy?: I18nUrlStrategy,
134
123
  ): boolean => {
135
- return shouldSkipLocaleRedirect(pathname, languages, ignoreRedirectRoutes);
124
+ if (urlStrategy?.shouldSkipRedirect?.(pathname, languages)) {
125
+ return true;
126
+ }
127
+ if (!ignoreRedirectRoutes) {
128
+ return false;
129
+ }
130
+ const segments = pathname.split('/').filter(Boolean);
131
+ if (
132
+ segments[0] &&
133
+ languages.some(item => item.toLowerCase() === segments[0].toLowerCase())
134
+ ) {
135
+ segments.shift();
136
+ }
137
+ const normalizedPath = `/${segments.join('/')}`;
138
+ return typeof ignoreRedirectRoutes === 'function'
139
+ ? ignoreRedirectRoutes(normalizedPath)
140
+ : ignoreRedirectRoutes.some(
141
+ pattern =>
142
+ normalizedPath === pattern ||
143
+ normalizedPath.startsWith(`${pattern}/`),
144
+ );
136
145
  };
@@ -1,10 +1,10 @@
1
- import { resolveLocalisedUrlsConfig } from '@modern-js/i18n-runtime-extensions';
2
1
  import * as honoPkg from '@modern-js/server-core/hono';
3
2
 
4
3
  const { languageDetector } = honoPkg;
5
4
 
6
5
  import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
7
6
  import type { LocaleDetectionOptions } from '../shared/type';
7
+ import type { I18nUrlStrategy } from '../shared/urlStrategy';
8
8
  import { getLocaleDetectionOptions } from '../shared/utils.js';
9
9
  import { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
10
10
  import { convertToHonoLanguageDetectorOptions } from './detectorOptions.js';
@@ -21,6 +21,7 @@ export { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
21
21
  export interface I18nPluginOptions {
22
22
  localeDetection: LocaleDetectionOptions;
23
23
  staticRoutePrefixes: string[];
24
+ resolveUrlStrategy?: (entryName: string) => I18nUrlStrategy | undefined;
24
25
  }
25
26
 
26
27
  export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
@@ -53,6 +54,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
53
54
  if (!options.localeDetection) {
54
55
  return;
55
56
  }
57
+ const entryLocaleDetection = getLocaleDetectionOptions(
58
+ entryName,
59
+ options.localeDetection,
60
+ );
56
61
  const {
57
62
  localePathRedirect,
58
63
  i18nextDetector = true,
@@ -60,8 +65,22 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
60
65
  fallbackLanguage = 'en',
61
66
  detection,
62
67
  ignoreRedirectRoutes,
63
- localisedUrls,
64
- } = getLocaleDetectionOptions(entryName, options.localeDetection);
68
+ } = entryLocaleDetection;
69
+ const legacyLocalisedUrls = Reflect.get(
70
+ entryLocaleDetection,
71
+ 'localisedUrls',
72
+ );
73
+ const urlStrategy = options.resolveUrlStrategy?.(entryName);
74
+ if (
75
+ legacyLocalisedUrls &&
76
+ typeof legacyLocalisedUrls === 'object' &&
77
+ Object.keys(legacyLocalisedUrls).length > 0 &&
78
+ !urlStrategy
79
+ ) {
80
+ throw new Error(
81
+ 'Mapped locale URLs require a URL strategy. Use the i18n integration server plugin or supply resolveUrlStrategy.',
82
+ );
83
+ }
65
84
  const staticRoutePrefixes = options.staticRoutePrefixes;
66
85
  const originUrlPath = route.urlPath;
67
86
  const urlPath = originUrlPath.endsWith('/')
@@ -87,6 +106,18 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
87
106
  return await next();
88
107
  }
89
108
 
109
+ if (
110
+ shouldIgnoreRedirect(
111
+ pathname,
112
+ urlPath,
113
+ ignoreRedirectRoutes,
114
+ urlStrategy,
115
+ languages,
116
+ )
117
+ ) {
118
+ return await next();
119
+ }
120
+
90
121
  // For static resource requests, skip language detection
91
122
  if (
92
123
  isStaticResourceRequest(
@@ -138,7 +169,13 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
138
169
 
139
170
  // Check if this route should ignore automatic redirect
140
171
  if (
141
- shouldIgnoreRedirect(pathname, urlPath, ignoreRedirectRoutes)
172
+ shouldIgnoreRedirect(
173
+ pathname,
174
+ urlPath,
175
+ ignoreRedirectRoutes,
176
+ urlStrategy,
177
+ languages,
178
+ )
142
179
  ) {
143
180
  return await next();
144
181
  }
@@ -168,19 +205,17 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
168
205
  originUrlPath,
169
206
  targetLanguage,
170
207
  languages,
171
- localisedUrls,
208
+ urlStrategy,
172
209
  );
173
210
  return createLocaleRedirectResponse(localizedUrl);
174
211
  }
175
- const localisedUrlsConfig =
176
- resolveLocalisedUrlsConfig(localisedUrls);
177
- if (localisedUrlsConfig.enabled) {
212
+ if (urlStrategy) {
178
213
  const expectedUrl = buildLocalizedUrl(
179
214
  c.req,
180
215
  originUrlPath,
181
216
  language,
182
217
  languages,
183
- localisedUrls,
218
+ urlStrategy,
184
219
  );
185
220
  if (expectedUrl !== `${pathname}${url.search}${url.hash}`) {
186
221
  return createLocaleRedirectResponse(expectedUrl);