@bleedingdev/modern-js-plugin-i18n 3.5.0-ultramodern.6 → 3.5.0-ultramodern.75

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 (221) hide show
  1. package/dist/cjs/cli/index.js +2 -1
  2. package/dist/cjs/cli/locales.js +15 -15
  3. package/dist/cjs/runtime/Link.js +8 -89
  4. package/dist/cjs/runtime/context.js +78 -91
  5. package/dist/cjs/runtime/contextHelpers.js +143 -0
  6. package/dist/cjs/runtime/core.js +32 -132
  7. package/dist/cjs/runtime/hooks.js +54 -21
  8. package/dist/cjs/runtime/i18n/backend/defaults.node.js +7 -7
  9. package/dist/cjs/runtime/i18n/detection/cache.js +150 -0
  10. package/dist/cjs/runtime/i18n/detection/detector.js +100 -0
  11. package/dist/cjs/runtime/i18n/detection/index.js +8 -263
  12. package/dist/cjs/runtime/i18n/detection/initOptions.js +69 -0
  13. package/dist/cjs/runtime/i18n/detection/language.js +62 -0
  14. package/dist/cjs/runtime/i18n/detection/path.js +49 -0
  15. package/dist/cjs/runtime/i18n/detection/priority.js +66 -0
  16. package/dist/cjs/runtime/i18n/detection/ssr.js +70 -0
  17. package/dist/cjs/runtime/i18n/detection/types.js +18 -0
  18. package/dist/cjs/runtime/i18n/instance.js +34 -10
  19. package/dist/cjs/runtime/linkHelpers.js +148 -0
  20. package/dist/cjs/runtime/pluginSetup.js +102 -0
  21. package/dist/cjs/runtime/providerComposition.js +115 -0
  22. package/dist/cjs/runtime/reactI18next.js +43 -0
  23. package/dist/cjs/runtime/routerAdapter.js +16 -8
  24. package/dist/cjs/runtime/utils.js +1 -9
  25. package/dist/cjs/server/apiPrefix.js +72 -0
  26. package/dist/cjs/server/detectorOptions.js +97 -0
  27. package/dist/cjs/server/index.js +17 -144
  28. package/dist/cjs/server/redirectPolicy.js +96 -0
  29. package/dist/cjs/shared/detection.js +2 -104
  30. package/dist/cjs/shared/localisedUrls/config.js +49 -0
  31. package/dist/cjs/shared/localisedUrls/index.js +91 -0
  32. package/dist/cjs/shared/localisedUrls/normalise.js +87 -0
  33. package/dist/cjs/shared/localisedUrls/pathname.js +68 -0
  34. package/dist/cjs/shared/localisedUrls/patterns.js +130 -0
  35. package/dist/cjs/shared/localisedUrls/redirect.js +103 -0
  36. package/dist/cjs/shared/localisedUrls/resolve.js +88 -0
  37. package/dist/cjs/shared/localisedUrls/routes.js +130 -0
  38. package/dist/cjs/shared/localisedUrls/types.js +18 -0
  39. package/dist/cjs/shared/localisedUrls.js +28 -294
  40. package/dist/cjs/shared/utils.js +2 -8
  41. package/dist/esm/cli/index.mjs +2 -1
  42. package/dist/esm/cli/locales.mjs +13 -13
  43. package/dist/esm/runtime/Link.mjs +1 -82
  44. package/dist/esm/runtime/context.mjs +76 -92
  45. package/dist/esm/runtime/contextHelpers.mjs +87 -0
  46. package/dist/esm/runtime/core.mjs +30 -133
  47. package/dist/esm/runtime/hooks.mjs +56 -23
  48. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +5 -5
  49. package/dist/esm/runtime/i18n/detection/cache.mjs +103 -0
  50. package/dist/esm/runtime/i18n/detection/detector.mjs +62 -0
  51. package/dist/esm/runtime/i18n/detection/index.mjs +4 -260
  52. package/dist/esm/runtime/i18n/detection/initOptions.mjs +28 -0
  53. package/dist/esm/runtime/i18n/detection/language.mjs +21 -0
  54. package/dist/esm/runtime/i18n/detection/path.mjs +11 -0
  55. package/dist/esm/runtime/i18n/detection/priority.mjs +28 -0
  56. package/dist/esm/runtime/i18n/detection/ssr.mjs +26 -0
  57. package/dist/esm/runtime/i18n/detection/types.mjs +0 -0
  58. package/dist/esm/runtime/i18n/instance.mjs +35 -8
  59. package/dist/esm/runtime/linkHelpers.mjs +98 -0
  60. package/dist/esm/runtime/pluginSetup.mjs +64 -0
  61. package/dist/esm/runtime/providerComposition.mjs +77 -0
  62. package/dist/esm/runtime/reactI18next.mjs +5 -0
  63. package/dist/esm/runtime/routerAdapter.mjs +16 -8
  64. package/dist/esm/runtime/utils.mjs +2 -10
  65. package/dist/esm/server/apiPrefix.mjs +31 -0
  66. package/dist/esm/server/detectorOptions.mjs +59 -0
  67. package/dist/esm/server/index.mjs +8 -135
  68. package/dist/esm/server/redirectPolicy.mjs +46 -0
  69. package/dist/esm/shared/detection.mjs +1 -71
  70. package/dist/esm/shared/localisedUrls/config.mjs +11 -0
  71. package/dist/esm/shared/localisedUrls/index.mjs +7 -0
  72. package/dist/esm/shared/localisedUrls/normalise.mjs +37 -0
  73. package/dist/esm/shared/localisedUrls/pathname.mjs +24 -0
  74. package/dist/esm/shared/localisedUrls/patterns.mjs +86 -0
  75. package/dist/esm/shared/localisedUrls/redirect.mjs +56 -0
  76. package/dist/esm/shared/localisedUrls/resolve.mjs +47 -0
  77. package/dist/esm/shared/localisedUrls/routes.mjs +89 -0
  78. package/dist/esm/shared/localisedUrls/types.mjs +0 -0
  79. package/dist/esm/shared/localisedUrls.mjs +1 -283
  80. package/dist/esm/shared/utils.mjs +1 -1
  81. package/dist/esm-node/cli/index.mjs +2 -1
  82. package/dist/esm-node/cli/locales.mjs +13 -13
  83. package/dist/esm-node/runtime/Link.mjs +1 -82
  84. package/dist/esm-node/runtime/context.mjs +76 -92
  85. package/dist/esm-node/runtime/contextHelpers.mjs +88 -0
  86. package/dist/esm-node/runtime/core.mjs +30 -133
  87. package/dist/esm-node/runtime/hooks.mjs +56 -23
  88. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +5 -5
  89. package/dist/esm-node/runtime/i18n/detection/cache.mjs +104 -0
  90. package/dist/esm-node/runtime/i18n/detection/detector.mjs +63 -0
  91. package/dist/esm-node/runtime/i18n/detection/index.mjs +4 -260
  92. package/dist/esm-node/runtime/i18n/detection/initOptions.mjs +29 -0
  93. package/dist/esm-node/runtime/i18n/detection/language.mjs +22 -0
  94. package/dist/esm-node/runtime/i18n/detection/path.mjs +12 -0
  95. package/dist/esm-node/runtime/i18n/detection/priority.mjs +29 -0
  96. package/dist/esm-node/runtime/i18n/detection/ssr.mjs +27 -0
  97. package/dist/esm-node/runtime/i18n/detection/types.mjs +1 -0
  98. package/dist/esm-node/runtime/i18n/instance.mjs +35 -8
  99. package/dist/esm-node/runtime/linkHelpers.mjs +99 -0
  100. package/dist/esm-node/runtime/pluginSetup.mjs +65 -0
  101. package/dist/esm-node/runtime/providerComposition.mjs +78 -0
  102. package/dist/esm-node/runtime/reactI18next.mjs +6 -0
  103. package/dist/esm-node/runtime/routerAdapter.mjs +16 -8
  104. package/dist/esm-node/runtime/utils.mjs +2 -10
  105. package/dist/esm-node/server/apiPrefix.mjs +32 -0
  106. package/dist/esm-node/server/detectorOptions.mjs +60 -0
  107. package/dist/esm-node/server/index.mjs +8 -135
  108. package/dist/esm-node/server/redirectPolicy.mjs +47 -0
  109. package/dist/esm-node/shared/detection.mjs +1 -71
  110. package/dist/esm-node/shared/localisedUrls/config.mjs +12 -0
  111. package/dist/esm-node/shared/localisedUrls/index.mjs +8 -0
  112. package/dist/esm-node/shared/localisedUrls/normalise.mjs +38 -0
  113. package/dist/esm-node/shared/localisedUrls/pathname.mjs +25 -0
  114. package/dist/esm-node/shared/localisedUrls/patterns.mjs +87 -0
  115. package/dist/esm-node/shared/localisedUrls/redirect.mjs +57 -0
  116. package/dist/esm-node/shared/localisedUrls/resolve.mjs +48 -0
  117. package/dist/esm-node/shared/localisedUrls/routes.mjs +90 -0
  118. package/dist/esm-node/shared/localisedUrls/types.mjs +1 -0
  119. package/dist/esm-node/shared/localisedUrls.mjs +1 -283
  120. package/dist/esm-node/shared/utils.mjs +1 -1
  121. package/dist/types/cli/locales.d.ts +1 -1
  122. package/dist/types/runtime/Link.d.ts +3 -7
  123. package/dist/types/runtime/context.d.ts +21 -5
  124. package/dist/types/runtime/contextHelpers.d.ts +29 -0
  125. package/dist/types/runtime/core.d.ts +4 -8
  126. package/dist/types/runtime/hooks.d.ts +3 -7
  127. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +2 -1
  128. package/dist/types/runtime/i18n/detection/cache.d.ts +14 -0
  129. package/dist/types/runtime/i18n/detection/detector.d.ts +8 -0
  130. package/dist/types/runtime/i18n/detection/index.d.ts +5 -50
  131. package/dist/types/runtime/i18n/detection/initOptions.d.ts +10 -0
  132. package/dist/types/runtime/i18n/detection/language.d.ts +11 -0
  133. package/dist/types/runtime/i18n/detection/middleware.d.ts +4 -4
  134. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +4 -4
  135. package/dist/types/runtime/i18n/detection/path.d.ts +5 -0
  136. package/dist/types/runtime/i18n/detection/priority.d.ts +10 -0
  137. package/dist/types/runtime/i18n/detection/ssr.d.ts +9 -0
  138. package/dist/types/runtime/i18n/detection/types.d.ts +32 -0
  139. package/dist/types/runtime/i18n/instance.d.ts +31 -27
  140. package/dist/types/runtime/i18n/react-i18next.d.ts +1 -6
  141. package/dist/types/runtime/i18n/utils.d.ts +14 -5
  142. package/dist/types/runtime/linkHelpers.d.ts +17 -0
  143. package/dist/types/runtime/pluginSetup.d.ts +32 -0
  144. package/dist/types/runtime/providerComposition.d.ts +16 -0
  145. package/dist/types/runtime/reactI18next.d.ts +7 -0
  146. package/dist/types/runtime/routerAdapter.d.ts +7 -6
  147. package/dist/types/runtime/utils.d.ts +7 -2
  148. package/dist/types/server/apiPrefix.d.ts +7 -0
  149. package/dist/types/server/detectorOptions.d.ts +21 -0
  150. package/dist/types/server/index.d.ts +1 -6
  151. package/dist/types/server/redirectPolicy.d.ts +22 -0
  152. package/dist/types/shared/detection.d.ts +1 -11
  153. package/dist/types/shared/localisedUrls/config.d.ts +9 -0
  154. package/dist/types/shared/localisedUrls/index.d.ts +9 -0
  155. package/dist/types/shared/localisedUrls/normalise.d.ts +10 -0
  156. package/dist/types/shared/localisedUrls/pathname.d.ts +4 -0
  157. package/dist/types/shared/localisedUrls/patterns.d.ts +5 -0
  158. package/dist/types/shared/localisedUrls/redirect.d.ts +11 -0
  159. package/dist/types/shared/localisedUrls/resolve.d.ts +7 -0
  160. package/dist/types/shared/localisedUrls/routes.d.ts +3 -0
  161. package/dist/types/shared/localisedUrls/types.d.ts +14 -0
  162. package/dist/types/shared/localisedUrls.d.ts +2 -36
  163. package/dist/types/shared/utils.d.ts +0 -2
  164. package/package.json +17 -20
  165. package/rstest.config.mts +8 -1
  166. package/src/cli/index.ts +5 -4
  167. package/src/cli/locales.ts +3 -3
  168. package/src/runtime/Link.tsx +11 -138
  169. package/src/runtime/context.tsx +170 -207
  170. package/src/runtime/contextHelpers.ts +242 -0
  171. package/src/runtime/core.tsx +48 -275
  172. package/src/runtime/hooks.ts +86 -24
  173. package/src/runtime/i18n/backend/defaults.node.ts +2 -2
  174. package/src/runtime/i18n/backend/middleware.common.ts +5 -4
  175. package/src/runtime/i18n/detection/cache.ts +189 -0
  176. package/src/runtime/i18n/detection/detector.ts +166 -0
  177. package/src/runtime/i18n/detection/index.ts +10 -641
  178. package/src/runtime/i18n/detection/initOptions.ts +74 -0
  179. package/src/runtime/i18n/detection/language.ts +64 -0
  180. package/src/runtime/i18n/detection/middleware.node.ts +26 -7
  181. package/src/runtime/i18n/detection/middleware.ts +15 -7
  182. package/src/runtime/i18n/detection/path.ts +41 -0
  183. package/src/runtime/i18n/detection/priority.ts +82 -0
  184. package/src/runtime/i18n/detection/ssr.ts +47 -0
  185. package/src/runtime/i18n/detection/types.ts +41 -0
  186. package/src/runtime/i18n/instance.ts +98 -34
  187. package/src/runtime/i18n/react-i18next.ts +1 -6
  188. package/src/runtime/i18n/utils.ts +18 -9
  189. package/src/runtime/linkHelpers.ts +174 -0
  190. package/src/runtime/pluginSetup.ts +189 -0
  191. package/src/runtime/providerComposition.tsx +148 -0
  192. package/src/runtime/reactI18next.ts +20 -0
  193. package/src/runtime/routerAdapter.tsx +36 -15
  194. package/src/runtime/utils.ts +13 -35
  195. package/src/server/apiPrefix.ts +67 -0
  196. package/src/server/detectorOptions.ts +105 -0
  197. package/src/server/index.ts +14 -319
  198. package/src/server/redirectPolicy.ts +127 -0
  199. package/src/shared/detection.ts +1 -1
  200. package/src/shared/localisedUrls/config.ts +18 -0
  201. package/src/shared/localisedUrls/index.ts +24 -0
  202. package/src/shared/localisedUrls/normalise.ts +67 -0
  203. package/src/shared/localisedUrls/pathname.ts +60 -0
  204. package/src/shared/localisedUrls/patterns.ts +160 -0
  205. package/src/shared/localisedUrls/redirect.ts +93 -0
  206. package/src/shared/localisedUrls/resolve.ts +135 -0
  207. package/src/shared/localisedUrls/routes.ts +234 -0
  208. package/src/shared/localisedUrls/types.ts +18 -0
  209. package/src/shared/localisedUrls.ts +26 -623
  210. package/src/shared/utils.ts +2 -2
  211. package/tests/federatedI18nBoundary.client.test.tsx +112 -0
  212. package/tests/federatedI18nBoundary.test.tsx +120 -0
  213. package/tests/fixtures/localised-routes.golden.json +156 -0
  214. package/tests/link.test.tsx +111 -0
  215. package/tests/localisedUrlRewriteMatrix.fork.test.ts +147 -0
  216. package/tests/localisedUrls.test.ts +148 -2
  217. package/tests/reactI18nextRuntimeBoundary.test.ts +6 -3
  218. package/tests/redirectPolicy.test.ts +45 -0
  219. package/tests/routerAdapter.test.tsx +603 -0
  220. package/tests/type-fixture/fixture-globals.d.ts +11 -0
  221. package/tests/type-fixture/tsconfig.json +1 -0
@@ -15,31 +15,31 @@ import {
15
15
  import type React from 'react';
16
16
  import { useCallback, useContext, useEffect, useState } from 'react';
17
17
 
18
- export type I18nRouterFramework = 'react-router' | 'tanstack' | string;
18
+ type I18nRouterFramework = 'react-router' | 'tanstack' | string;
19
19
 
20
- export interface I18nRouterLocation {
20
+ interface I18nRouterLocation {
21
21
  pathname: string;
22
22
  search: string;
23
23
  hash: string;
24
24
  }
25
25
 
26
- export interface I18nRouterNavigateOptions {
26
+ interface I18nRouterNavigateOptions {
27
27
  replace?: boolean;
28
28
  state?: unknown;
29
29
  }
30
30
 
31
- export type I18nRouterNavigate = (
31
+ type I18nRouterNavigate = (
32
32
  href: string,
33
33
  options?: I18nRouterNavigateOptions,
34
34
  ) => void | Promise<void>;
35
35
 
36
- export type I18nRouterLink = React.ComponentType<{
36
+ type I18nRouterLink = React.ComponentType<{
37
37
  to: string;
38
38
  children?: React.ReactNode;
39
39
  [key: string]: unknown;
40
40
  }>;
41
41
 
42
- export interface I18nRouterAdapter {
42
+ interface I18nRouterAdapter {
43
43
  framework?: I18nRouterFramework;
44
44
  hasRouter: boolean;
45
45
  location: I18nRouterLocation | null;
@@ -76,7 +76,9 @@ type RouterInstance = {
76
76
  get?: () => Array<{ params?: Record<string, string> }>;
77
77
  };
78
78
  };
79
- subscribe?: (eventType: string, listener: () => void) => () => void;
79
+ subscribe?:
80
+ | ((listener: () => void) => () => void)
81
+ | ((eventType: string, listener: () => void) => () => void);
80
82
  };
81
83
 
82
84
  const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
@@ -231,10 +233,6 @@ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
231
233
  Boolean(reactRouterNavigate);
232
234
 
233
235
  useEffect(() => {
234
- if (framework !== 'tanstack') {
235
- return;
236
- }
237
-
238
236
  const router = getRouterInstance(internalContext, contextRouter);
239
237
  if (!router) {
240
238
  return;
@@ -243,16 +241,39 @@ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
243
241
  const update = () => setRouterVersion(version => version + 1);
244
242
  const unsubscribers: Array<() => void> = [];
245
243
 
246
- if (typeof router.stores?.location?.subscribe === 'function') {
244
+ if (
245
+ framework === 'react-router' &&
246
+ !inReactRouter &&
247
+ typeof router.subscribe === 'function'
248
+ ) {
249
+ const subscribe = router.subscribe as (
250
+ this: RouterInstance,
251
+ listener: () => void,
252
+ ) => () => void;
253
+ const unsubscribe = subscribe.call(router, update);
254
+ if (typeof unsubscribe === 'function') {
255
+ unsubscribers.push(unsubscribe);
256
+ }
257
+ }
258
+
259
+ if (
260
+ framework === 'tanstack' &&
261
+ typeof router.stores?.location?.subscribe === 'function'
262
+ ) {
247
263
  const unsubscribe = router.stores.location.subscribe(update);
248
264
  if (typeof unsubscribe === 'function') {
249
265
  unsubscribers.push(unsubscribe);
250
266
  }
251
267
  }
252
268
 
253
- if (typeof router.subscribe === 'function') {
269
+ if (framework === 'tanstack' && typeof router.subscribe === 'function') {
270
+ const subscribe = router.subscribe as (
271
+ this: RouterInstance,
272
+ eventType: string,
273
+ listener: () => void,
274
+ ) => () => void;
254
275
  for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
255
- const unsubscribe = router.subscribe(eventType, update);
276
+ const unsubscribe = subscribe.call(router, eventType, update);
256
277
  if (typeof unsubscribe === 'function') {
257
278
  unsubscribers.push(unsubscribe);
258
279
  }
@@ -264,7 +285,7 @@ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
264
285
  unsubscribe();
265
286
  }
266
287
  };
267
- }, [contextRouter, framework, internalContext]);
288
+ }, [contextRouter, framework, inReactRouter, internalContext]);
268
289
 
269
290
  const navigate = useCallback<I18nRouterNavigate>(
270
291
  (href, options) => {
@@ -1,12 +1,17 @@
1
1
  import { isBrowser } from '@modern-js/runtime';
2
- import {
3
- getGlobalBasename,
4
- type TInternalRuntimeContext,
5
- } from '@modern-js/runtime/context';
2
+ import { getGlobalBasename } from '@modern-js/runtime/context';
6
3
  import type { LocalisedUrlsOption } from '../shared/localisedUrls';
7
- import { localiseTargetPathname } from '../shared/localisedUrls';
8
-
9
- export const getPathname = (context: TInternalRuntimeContext): string => {
4
+ import {
5
+ localiseTargetPathname,
6
+ shouldSkipLocaleRedirect,
7
+ } from '../shared/localisedUrls';
8
+
9
+ // Structural parameter: hooks.ts passes a public-TRuntimeContext-based
10
+ // context while core.tsx passes the internal one; both carry the request
11
+ // pathname shape this helper needs.
12
+ export const getPathname = (context: {
13
+ ssrContext?: { request?: { pathname?: string } };
14
+ }): string => {
10
15
  if (isBrowser()) {
11
16
  return window.location.pathname;
12
17
  }
@@ -127,32 +132,5 @@ export const shouldIgnoreRedirect = (
127
132
  languages: string[],
128
133
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
129
134
  ): boolean => {
130
- if (!ignoreRedirectRoutes) {
131
- return false;
132
- }
133
-
134
- // Remove language prefix if present (e.g., /en/api -> /api)
135
- const segments = pathname.split('/').filter(Boolean);
136
- let pathWithoutLang = pathname;
137
- if (segments.length > 0 && languages.includes(segments[0])) {
138
- // Remove language prefix
139
- pathWithoutLang = `/${segments.slice(1).join('/')}`;
140
- }
141
-
142
- // Normalize path (ensure it starts with /)
143
- const normalizedPath = pathWithoutLang.startsWith('/')
144
- ? pathWithoutLang
145
- : `/${pathWithoutLang}`;
146
-
147
- if (typeof ignoreRedirectRoutes === 'function') {
148
- return ignoreRedirectRoutes(normalizedPath);
149
- }
150
-
151
- // Check if pathname matches any of the ignore patterns
152
- return ignoreRedirectRoutes.some(pattern => {
153
- // Support both exact match and prefix match
154
- return (
155
- normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
156
- );
157
- });
135
+ return shouldSkipLocaleRedirect(pathname, languages, ignoreRedirectRoutes);
158
136
  };
@@ -0,0 +1,67 @@
1
+ type ApiPrefixInput = string | string[] | undefined;
2
+
3
+ const normalizeApiPrefix = (prefix: string): string | null => {
4
+ const trimmedPrefix = prefix.trim();
5
+ if (!trimmedPrefix) {
6
+ return null;
7
+ }
8
+
9
+ const prefixedPath = trimmedPrefix.startsWith('/')
10
+ ? trimmedPrefix
11
+ : `/${trimmedPrefix}`;
12
+ const withoutWildcard = prefixedPath.replace(/\/\*$/, '');
13
+ const normalizedPrefix =
14
+ withoutWildcard.length > 1
15
+ ? withoutWildcard.replace(/\/+$/, '')
16
+ : withoutWildcard;
17
+
18
+ return normalizedPrefix === '/' ? null : normalizedPrefix;
19
+ };
20
+
21
+ export const collectApiPrefixes = (
22
+ routes: Array<{ isApi?: boolean; urlPath?: string }>,
23
+ bffPrefix?: ApiPrefixInput,
24
+ ): string[] => {
25
+ const prefixes = new Set<string>();
26
+
27
+ for (const route of routes) {
28
+ if (!route.isApi || !route.urlPath) {
29
+ continue;
30
+ }
31
+
32
+ const normalizedPrefix = normalizeApiPrefix(route.urlPath);
33
+ if (normalizedPrefix) {
34
+ prefixes.add(normalizedPrefix);
35
+ }
36
+ }
37
+
38
+ const bffPrefixes = Array.isArray(bffPrefix)
39
+ ? bffPrefix
40
+ : bffPrefix
41
+ ? [bffPrefix]
42
+ : [];
43
+
44
+ for (const prefix of bffPrefixes) {
45
+ const normalizedPrefix = normalizeApiPrefix(prefix);
46
+ if (normalizedPrefix) {
47
+ prefixes.add(normalizedPrefix);
48
+ }
49
+ }
50
+
51
+ return [...prefixes];
52
+ };
53
+
54
+ export const matchesApiPrefix = (
55
+ pathname: string,
56
+ apiPrefixes: string[],
57
+ ): boolean => {
58
+ const normalizedPathname = pathname.startsWith('/')
59
+ ? pathname
60
+ : `/${pathname}`;
61
+
62
+ return apiPrefixes.some(
63
+ prefix =>
64
+ normalizedPathname === prefix ||
65
+ normalizedPathname.startsWith(`${prefix}/`),
66
+ );
67
+ };
@@ -0,0 +1,105 @@
1
+ import {
2
+ DEFAULT_I18NEXT_DETECTION_OPTIONS,
3
+ mergeDetectionOptions,
4
+ } from '../runtime/i18n/detection/config.js';
5
+ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
6
+
7
+ /**
8
+ * Convert i18next detection options to hono languageDetector options
9
+ */
10
+ export const convertToHonoLanguageDetectorOptions = (
11
+ languages: string[],
12
+ fallbackLanguage: string,
13
+ detectionOptions?: LanguageDetectorOptions,
14
+ ) => {
15
+ // Merge user detection options with defaults
16
+ const mergedDetection = detectionOptions
17
+ ? mergeDetectionOptions(detectionOptions)
18
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS;
19
+
20
+ // Get detection order, excluding 'path' and browser-only detectors
21
+ const order = (mergedDetection.order || []).filter(
22
+ (item: string) =>
23
+ !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes(
24
+ item,
25
+ ),
26
+ );
27
+
28
+ // If no order specified, use default server-side order
29
+ const detectionOrder =
30
+ order.length > 0 ? order : ['querystring', 'cookie', 'header'];
31
+
32
+ // Map i18next order to hono order
33
+ const honoOrder = detectionOrder.map(item => {
34
+ // Map 'querystring' to 'querystring', 'cookie' to 'cookie', 'header' to 'header'
35
+ if (item === 'querystring') return 'querystring';
36
+ if (item === 'cookie') return 'cookie';
37
+ if (item === 'header') return 'header';
38
+ return item;
39
+ }) as ('querystring' | 'cookie' | 'header' | 'path')[];
40
+
41
+ // Determine caches option
42
+ // hono languageDetector expects: false | "cookie"[] | undefined
43
+ const caches: false | ['cookie'] | undefined =
44
+ mergedDetection.caches === false
45
+ ? false
46
+ : Array.isArray(mergedDetection.caches) &&
47
+ !mergedDetection.caches.includes('cookie')
48
+ ? false
49
+ : (['cookie'] as ['cookie']);
50
+
51
+ const cookieMinutes = (mergedDetection as Record<string, unknown>)
52
+ .cookieMinutes;
53
+ const cookieMaxAge =
54
+ typeof cookieMinutes === 'number' && Number.isFinite(cookieMinutes)
55
+ ? Math.max(0, Math.floor(cookieMinutes * 60))
56
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS.cookieMinutes * 60;
57
+
58
+ const cookieDomain = (mergedDetection as Record<string, unknown>)
59
+ .cookieDomain;
60
+ const cookieSecure = (mergedDetection as Record<string, unknown>)
61
+ .cookieSecure;
62
+ const cookieHttpOnly = (mergedDetection as Record<string, unknown>)
63
+ .cookieHttpOnly;
64
+ const cookieSameSite = (mergedDetection as Record<string, unknown>)
65
+ .cookieSameSite;
66
+
67
+ const normalizedCookieDomain =
68
+ typeof cookieDomain === 'string' ? cookieDomain : undefined;
69
+
70
+ // Keep cookie defaults aligned with i18next language detector behavior:
71
+ // language cookie should be readable from browser-side detector.
72
+ const cookieOptions = {
73
+ maxAge: cookieMaxAge,
74
+ sameSite:
75
+ cookieSameSite === 'None' || cookieSameSite === 'none'
76
+ ? ('None' as const)
77
+ : cookieSameSite === 'Lax' || cookieSameSite === 'lax'
78
+ ? ('Lax' as const)
79
+ : ('Strict' as const),
80
+ secure: typeof cookieSecure === 'boolean' ? cookieSecure : false,
81
+ httpOnly: typeof cookieHttpOnly === 'boolean' ? cookieHttpOnly : false,
82
+ ...(normalizedCookieDomain ? { domain: normalizedCookieDomain } : {}),
83
+ };
84
+
85
+ return {
86
+ supportedLanguages: languages.length > 0 ? languages : [fallbackLanguage],
87
+ fallbackLanguage,
88
+ order: honoOrder,
89
+ lookupQueryString:
90
+ mergedDetection.lookupQuerystring ||
91
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring ||
92
+ 'lng',
93
+ lookupCookie:
94
+ mergedDetection.lookupCookie ||
95
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie ||
96
+ 'i18next',
97
+ lookupFromHeaderKey:
98
+ mergedDetection.lookupHeader ||
99
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader ||
100
+ 'accept-language',
101
+ ...(caches !== undefined && { caches }),
102
+ ...(caches !== false && { cookieOptions }),
103
+ ignoreCase: true,
104
+ };
105
+ };
@@ -3,331 +3,26 @@ import * as honoPkg from '@modern-js/server-core/hono';
3
3
  const { languageDetector } = honoPkg;
4
4
 
5
5
  import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
6
- import {
7
- DEFAULT_I18NEXT_DETECTION_OPTIONS,
8
- mergeDetectionOptions,
9
- } from '../runtime/i18n/detection/config.js';
10
- import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
11
- import {
12
- localiseTargetPathname,
13
- resolveLocalisedUrlsConfig,
14
- } from '../shared/localisedUrls.js';
6
+ import { resolveLocalisedUrlsConfig } from '../shared/localisedUrls.js';
15
7
  import type { LocaleDetectionOptions } from '../shared/type';
16
8
  import { getLocaleDetectionOptions } from '../shared/utils.js';
9
+ import { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
10
+ import { convertToHonoLanguageDetectorOptions } from './detectorOptions.js';
11
+ import {
12
+ buildLocalizedUrl,
13
+ createLocaleRedirectResponse,
14
+ getLanguageFromPath,
15
+ isStaticResourceRequest,
16
+ shouldIgnoreRedirect,
17
+ } from './redirectPolicy.js';
18
+
19
+ export { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
17
20
 
18
21
  export interface I18nPluginOptions {
19
22
  localeDetection: LocaleDetectionOptions;
20
23
  staticRoutePrefixes: string[];
21
24
  }
22
25
 
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
-
91
- /**
92
- * Convert i18next detection options to hono languageDetector options
93
- */
94
- const convertToHonoLanguageDetectorOptions = (
95
- languages: string[],
96
- fallbackLanguage: string,
97
- detectionOptions?: LanguageDetectorOptions,
98
- ) => {
99
- // Merge user detection options with defaults
100
- const mergedDetection = detectionOptions
101
- ? mergeDetectionOptions(detectionOptions)
102
- : DEFAULT_I18NEXT_DETECTION_OPTIONS;
103
-
104
- // Get detection order, excluding 'path' and browser-only detectors
105
- const order = (mergedDetection.order || []).filter(
106
- (item: string) =>
107
- !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes(
108
- item,
109
- ),
110
- );
111
-
112
- // If no order specified, use default server-side order
113
- const detectionOrder =
114
- order.length > 0 ? order : ['querystring', 'cookie', 'header'];
115
-
116
- // Map i18next order to hono order
117
- const honoOrder = detectionOrder.map(item => {
118
- // Map 'querystring' to 'querystring', 'cookie' to 'cookie', 'header' to 'header'
119
- if (item === 'querystring') return 'querystring';
120
- if (item === 'cookie') return 'cookie';
121
- if (item === 'header') return 'header';
122
- return item;
123
- }) as ('querystring' | 'cookie' | 'header' | 'path')[];
124
-
125
- // Determine caches option
126
- // hono languageDetector expects: false | "cookie"[] | undefined
127
- const caches: false | ['cookie'] | undefined =
128
- mergedDetection.caches === false
129
- ? false
130
- : Array.isArray(mergedDetection.caches) &&
131
- !mergedDetection.caches.includes('cookie')
132
- ? false
133
- : (['cookie'] as ['cookie']);
134
-
135
- const cookieMinutes = (mergedDetection as Record<string, unknown>)
136
- .cookieMinutes;
137
- const cookieMaxAge =
138
- typeof cookieMinutes === 'number' && Number.isFinite(cookieMinutes)
139
- ? Math.max(0, Math.floor(cookieMinutes * 60))
140
- : DEFAULT_I18NEXT_DETECTION_OPTIONS.cookieMinutes * 60;
141
-
142
- const cookieDomain = (mergedDetection as Record<string, unknown>)
143
- .cookieDomain;
144
- const cookieSecure = (mergedDetection as Record<string, unknown>)
145
- .cookieSecure;
146
- const cookieHttpOnly = (mergedDetection as Record<string, unknown>)
147
- .cookieHttpOnly;
148
- const cookieSameSite = (mergedDetection as Record<string, unknown>)
149
- .cookieSameSite;
150
-
151
- const normalizedCookieDomain =
152
- typeof cookieDomain === 'string' ? cookieDomain : undefined;
153
-
154
- // Keep cookie defaults aligned with i18next language detector behavior:
155
- // language cookie should be readable from browser-side detector.
156
- const cookieOptions = {
157
- maxAge: cookieMaxAge,
158
- sameSite:
159
- cookieSameSite === 'None' || cookieSameSite === 'none'
160
- ? ('None' as const)
161
- : cookieSameSite === 'Lax' || cookieSameSite === 'lax'
162
- ? ('Lax' as const)
163
- : ('Strict' as const),
164
- secure: typeof cookieSecure === 'boolean' ? cookieSecure : false,
165
- httpOnly: typeof cookieHttpOnly === 'boolean' ? cookieHttpOnly : false,
166
- ...(normalizedCookieDomain ? { domain: normalizedCookieDomain } : {}),
167
- };
168
-
169
- return {
170
- supportedLanguages: languages.length > 0 ? languages : [fallbackLanguage],
171
- fallbackLanguage,
172
- order: honoOrder,
173
- lookupQueryString:
174
- mergedDetection.lookupQuerystring ||
175
- DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring ||
176
- 'lng',
177
- lookupCookie:
178
- mergedDetection.lookupCookie ||
179
- DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie ||
180
- 'i18next',
181
- lookupFromHeaderKey:
182
- mergedDetection.lookupHeader ||
183
- DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader ||
184
- 'accept-language',
185
- ...(caches !== undefined && { caches }),
186
- ...(caches !== false && { cookieOptions }),
187
- ignoreCase: true,
188
- };
189
- };
190
-
191
- /**
192
- * Check if the given pathname should ignore automatic locale redirect
193
- */
194
- const shouldIgnoreRedirect = (
195
- pathname: string,
196
- urlPath: string,
197
- ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
198
- ): boolean => {
199
- if (!ignoreRedirectRoutes) {
200
- return false;
201
- }
202
-
203
- // Remove urlPath prefix to get remaining path for matching
204
- const basePath = urlPath.replace('/*', '');
205
- const remainingPath = pathname.startsWith(basePath)
206
- ? pathname.slice(basePath.length)
207
- : pathname;
208
-
209
- // Normalize path (ensure it starts with /)
210
- const normalizedPath = remainingPath.startsWith('/')
211
- ? remainingPath
212
- : `/${remainingPath}`;
213
-
214
- if (typeof ignoreRedirectRoutes === 'function') {
215
- return ignoreRedirectRoutes(normalizedPath);
216
- }
217
-
218
- // Check if pathname matches any of the ignore patterns
219
- return ignoreRedirectRoutes.some(pattern => {
220
- // Support both exact match and prefix match
221
- return (
222
- normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
223
- );
224
- });
225
- };
226
-
227
- /**
228
- * Check if the given pathname is a static resource request
229
- * This includes:
230
- * 1. Paths matching staticRoutePrefixes (from public directories)
231
- * 2. Standard static resource paths like /static/, /upload/
232
- * 3. Paths with language prefix like /en/static/, /zh/static/
233
- */
234
- const isStaticResourceRequest = (
235
- pathname: string,
236
- staticRoutePrefixes: string[],
237
- languages: string[] = [],
238
- ): boolean => {
239
- // Check against staticRoutePrefixes (from public directories)
240
- if (
241
- staticRoutePrefixes.some(
242
- prefix => pathname.startsWith(`${prefix}/`) || pathname === prefix,
243
- )
244
- ) {
245
- return true;
246
- }
247
-
248
- // Check standard static resource paths
249
- const standardStaticPrefixes = ['/static/', '/upload/'];
250
- if (standardStaticPrefixes.some(prefix => pathname.startsWith(prefix))) {
251
- return true;
252
- }
253
-
254
- // Check paths with language prefix (e.g., /en/static/, /zh/static/)
255
- // Remove language prefix if present and check again
256
- const pathSegments = pathname.split('/').filter(Boolean);
257
- if (pathSegments.length > 0 && languages.includes(pathSegments[0])) {
258
- const pathWithoutLang = '/' + pathSegments.slice(1).join('/');
259
- if (
260
- standardStaticPrefixes.some(prefix =>
261
- pathWithoutLang.startsWith(prefix),
262
- ) ||
263
- staticRoutePrefixes.some(
264
- prefix =>
265
- pathWithoutLang.startsWith(`${prefix}/`) ||
266
- pathWithoutLang === prefix,
267
- )
268
- ) {
269
- return true;
270
- }
271
- }
272
-
273
- return false;
274
- };
275
-
276
- const getLanguageFromPath = (
277
- req: any,
278
- urlPath: string,
279
- languages: string[],
280
- ): string | null => {
281
- const url = new URL(req.url, `http://${req.header().host}`);
282
- const pathname = url.pathname;
283
-
284
- // Remove urlPath prefix to get remaining path
285
- // urlPath format is /lang/*, need to remove /lang part
286
- const basePath = urlPath.replace('/*', '');
287
- const remainingPath = pathname.startsWith(basePath)
288
- ? pathname.slice(basePath.length)
289
- : pathname;
290
-
291
- const segments = remainingPath.split('/').filter(Boolean);
292
- const firstSegment = segments[0];
293
-
294
- if (languages.includes(firstSegment)) {
295
- return firstSegment;
296
- }
297
-
298
- return null;
299
- };
300
-
301
- const buildLocalizedUrl = (
302
- req: any,
303
- urlPath: string,
304
- language: string,
305
- languages: string[],
306
- localisedUrls?: LocaleDetectionOptions['localisedUrls'],
307
- ): string => {
308
- const url = new URL(req.url);
309
- const pathname = url.pathname;
310
-
311
- // Remove urlPath prefix to get remaining path
312
- const basePath = urlPath.replace('/*', '');
313
- const remainingPath = pathname.startsWith(basePath)
314
- ? pathname.slice(basePath.length)
315
- : pathname;
316
-
317
- const newPathname = localiseTargetPathname(
318
- remainingPath,
319
- language,
320
- languages,
321
- localisedUrls,
322
- );
323
- // Handle root path case to avoid double slashes like //en
324
- const suffix = `${url.search}${url.hash}`;
325
- const localizedUrl =
326
- basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
327
-
328
- return localizedUrl;
329
- };
330
-
331
26
  export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
332
27
  name: '@modern-js/plugin-i18n/server',
333
28
  setup: api => {
@@ -475,7 +170,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
475
170
  languages,
476
171
  localisedUrls,
477
172
  );
478
- return c.redirect(localizedUrl);
173
+ return createLocaleRedirectResponse(localizedUrl);
479
174
  }
480
175
  const localisedUrlsConfig =
481
176
  resolveLocalisedUrlsConfig(localisedUrls);
@@ -488,7 +183,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
488
183
  localisedUrls,
489
184
  );
490
185
  if (expectedUrl !== `${pathname}${url.search}${url.hash}`) {
491
- return c.redirect(expectedUrl);
186
+ return createLocaleRedirectResponse(expectedUrl);
492
187
  }
493
188
  }
494
189
  await next();