@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.3 → 3.2.0-ultramodern.30

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 (85) hide show
  1. package/dist/cjs/cli/index.js +22 -0
  2. package/dist/cjs/runtime/I18nLink.js +4 -12
  3. package/dist/cjs/runtime/context.js +32 -5
  4. package/dist/cjs/runtime/hooks.js +8 -5
  5. package/dist/cjs/runtime/i18n/backend/defaults.js +1 -1
  6. package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
  7. package/dist/cjs/runtime/i18n/instance.js +4 -2
  8. package/dist/cjs/runtime/index.js +7 -6
  9. package/dist/cjs/runtime/routerAdapter.js +163 -0
  10. package/dist/cjs/runtime/utils.js +63 -94
  11. package/dist/cjs/server/index.js +60 -8
  12. package/dist/cjs/shared/localisedUrls.js +237 -0
  13. package/dist/esm/cli/index.mjs +22 -0
  14. package/dist/esm/runtime/I18nLink.mjs +4 -12
  15. package/dist/esm/runtime/context.mjs +34 -7
  16. package/dist/esm/runtime/hooks.mjs +9 -6
  17. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  18. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  19. package/dist/esm/runtime/i18n/instance.mjs +4 -2
  20. package/dist/esm/runtime/index.mjs +7 -6
  21. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  22. package/dist/esm/runtime/utils.mjs +11 -30
  23. package/dist/esm/server/index.mjs +53 -7
  24. package/dist/esm/shared/localisedUrls.mjs +191 -0
  25. package/dist/esm-node/cli/index.mjs +22 -0
  26. package/dist/esm-node/runtime/I18nLink.mjs +4 -12
  27. package/dist/esm-node/runtime/context.mjs +34 -7
  28. package/dist/esm-node/runtime/hooks.mjs +9 -6
  29. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  30. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  31. package/dist/esm-node/runtime/i18n/instance.mjs +4 -2
  32. package/dist/esm-node/runtime/index.mjs +7 -6
  33. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  34. package/dist/esm-node/runtime/utils.mjs +11 -30
  35. package/dist/esm-node/server/index.mjs +53 -7
  36. package/dist/esm-node/shared/localisedUrls.mjs +192 -0
  37. package/dist/types/cli/index.d.ts +21 -0
  38. package/dist/types/runtime/I18nLink.d.ts +23 -0
  39. package/dist/types/runtime/context.d.ts +41 -0
  40. package/dist/types/runtime/hooks.d.ts +30 -0
  41. package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
  42. package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
  43. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
  44. package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
  45. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
  46. package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
  47. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
  48. package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
  49. package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
  50. package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
  51. package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
  52. package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
  53. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
  54. package/dist/types/runtime/i18n/index.d.ts +3 -0
  55. package/dist/types/runtime/i18n/instance.d.ts +96 -0
  56. package/dist/types/runtime/i18n/utils.d.ts +29 -0
  57. package/dist/types/runtime/index.d.ts +21 -0
  58. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  59. package/dist/types/runtime/types.d.ts +15 -0
  60. package/dist/types/runtime/utils.d.ts +28 -0
  61. package/dist/types/server/index.d.ts +14 -0
  62. package/dist/types/shared/deepMerge.d.ts +1 -0
  63. package/dist/types/shared/detection.d.ts +11 -0
  64. package/dist/types/shared/localisedUrls.d.ts +13 -0
  65. package/dist/types/shared/type.d.ts +168 -0
  66. package/dist/types/shared/utils.d.ts +5 -0
  67. package/package.json +15 -15
  68. package/rstest.config.mts +39 -0
  69. package/src/cli/index.ts +43 -1
  70. package/src/runtime/I18nLink.tsx +10 -16
  71. package/src/runtime/context.tsx +45 -7
  72. package/src/runtime/hooks.ts +13 -4
  73. package/src/runtime/i18n/backend/defaults.ts +3 -1
  74. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  75. package/src/runtime/i18n/instance.ts +14 -5
  76. package/src/runtime/index.tsx +10 -2
  77. package/src/runtime/routerAdapter.tsx +333 -0
  78. package/src/runtime/utils.ts +22 -34
  79. package/src/server/index.ts +117 -10
  80. package/src/shared/localisedUrls.ts +393 -0
  81. package/src/shared/type.ts +12 -0
  82. package/tests/localisedUrls.test.ts +278 -0
  83. package/tests/routerAdapter.test.tsx +278 -0
  84. package/dist/esm/rslib-runtime.mjs +0 -18
  85. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -0,0 +1,168 @@
1
+ import type { LanguageDetectorOptions, Resources } from '../runtime/i18n/instance';
2
+ import type { LocalisedUrlsOption } from './localisedUrls';
3
+ export interface BaseLocaleDetectionOptions {
4
+ localePathRedirect?: boolean;
5
+ i18nextDetector?: boolean;
6
+ languages?: string[];
7
+ fallbackLanguage?: string;
8
+ detection?: LanguageDetectorOptions;
9
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
10
+ /**
11
+ * Enables localised pathnames in addition to the locale prefix.
12
+ *
13
+ * - `false`: keep only locale-prefix behavior (`/en/about`).
14
+ * - object: map canonical route paths to every configured language.
15
+ *
16
+ * Defaults to `true` when `localePathRedirect` is enabled, so route
17
+ * generation validates that every localisable route path has entries for all
18
+ * configured languages.
19
+ */
20
+ localisedUrls?: LocalisedUrlsOption;
21
+ }
22
+ export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
23
+ localeDetectionByEntry?: Record<string, BaseLocaleDetectionOptions>;
24
+ }
25
+ /**
26
+ * Options for loading i18n resources via SDK
27
+ */
28
+ export interface I18nSdkLoadOptions {
29
+ /** Single language code to load (e.g., 'en', 'zh') */
30
+ lng?: string;
31
+ /** Single namespace to load (e.g., 'translation', 'common') */
32
+ ns?: string;
33
+ /** Multiple language codes to load */
34
+ lngs?: string[];
35
+ /** Multiple namespaces to load */
36
+ nss?: string[];
37
+ /** Load all available languages and namespaces */
38
+ all?: boolean;
39
+ }
40
+ /**
41
+ * SDK function to load i18n resources
42
+ * Supports multiple loading modes:
43
+ * 1. Single resource: sdk({ lng: 'en', ns: 'translation' })
44
+ * 2. Batch by languages: sdk({ lngs: ['en', 'zh'], ns: 'translation' })
45
+ * 3. Batch by namespaces: sdk({ lng: 'en', nss: ['translation', 'common'] })
46
+ * 4. Batch by both: sdk({ lngs: ['en', 'zh'], nss: ['translation', 'common'] })
47
+ * 5. Load all: sdk({ all: true }) or sdk()
48
+ *
49
+ * @param options - Loading options
50
+ * @returns Promise that resolves to resources object or the resources object directly
51
+ * Resources format: { [lng]: { [ns]: { [key]: value } } }
52
+ */
53
+ export type I18nSdkLoader = (options: I18nSdkLoadOptions) => Promise<Resources> | Resources;
54
+ /**
55
+ * Chained backend configuration
56
+ * Used internally when both loadPath and sdk are provided
57
+ */
58
+ export interface ChainedBackendConfig {
59
+ _useChainedBackend: boolean;
60
+ _chainedBackendConfig: {
61
+ backendOptions: Array<Record<string, unknown>>;
62
+ };
63
+ cacheHitMode?: 'none' | 'refresh' | 'refreshAndUpdateStore';
64
+ }
65
+ /**
66
+ * Extended backend options that may include chained backend configuration
67
+ */
68
+ export type ExtendedBackendOptions = BaseBackendOptions & Partial<ChainedBackendConfig>;
69
+ export interface BaseBackendOptions {
70
+ enabled?: boolean;
71
+ loadPath?: string;
72
+ addPath?: string;
73
+ /**
74
+ * Cache hit mode for chained backend (only effective when both `loadPath` and `sdk` are provided)
75
+ *
76
+ * - `'none'` (default): If the first backend returns resources, stop and don't try the next backend
77
+ * - `'refresh'`: Try to refresh the cache by loading from the next backend and update the cache
78
+ * - `'refreshAndUpdateStore'`: Try to refresh the cache by loading from the next backend,
79
+ * update the cache and also update the i18next resource store. This allows FS/HTTP resources
80
+ * to be displayed first, then SDK resources will update them asynchronously.
81
+ *
82
+ * @default 'refreshAndUpdateStore' when both loadPath and sdk are provided
83
+ */
84
+ cacheHitMode?: 'none' | 'refresh' | 'refreshAndUpdateStore';
85
+ /**
86
+ * SDK function to load i18n resources dynamically
87
+ *
88
+ * **Important**: In `modern.config.ts`, you can only set this to `true` or any identifier
89
+ * to enable SDK mode. The actual SDK function must be provided in `modern.runtime.ts`
90
+ * via `initOptions.backend.sdk`.
91
+ *
92
+ * When both `loadPath` (or FS backend) and `sdk` are provided, the plugin will automatically
93
+ * use `i18next-chained-backend` to chain multiple backends. The order will be:
94
+ * 1. HTTP/FS backend (primary) - loads from `loadPath` or file system first for quick initial display
95
+ * 2. SDK backend (fallback/update) - loads from the SDK function to update/refresh translations
96
+ *
97
+ * With `cacheHitMode: 'refreshAndUpdateStore'` (default), FS/HTTP resources will be displayed
98
+ * immediately, then SDK resources will be loaded asynchronously to update the translations.
99
+ *
100
+ * If only `sdk` is provided, it will be used instead of the default HTTP/FS backend
101
+ *
102
+ * @example In modern.config.ts - enable SDK mode
103
+ * ```ts
104
+ * backend: {
105
+ * enabled: true,
106
+ * sdk: true, // or any identifier, just to enable SDK mode
107
+ * }
108
+ * ```
109
+ *
110
+ * @example In modern.runtime.ts - provide the actual SDK function
111
+ * ```ts
112
+ * export default defineRuntimeConfig({
113
+ * i18n: {
114
+ * initOptions: {
115
+ * backend: {
116
+ * sdk: async (options) => {
117
+ * // Your SDK implementation
118
+ * if (options.all) {
119
+ * return await mySdk.getAllResources();
120
+ * }
121
+ * if (options.lng && options.ns) {
122
+ * return await mySdk.getResource(options.lng, options.ns);
123
+ * }
124
+ * // Handle other cases...
125
+ * }
126
+ * }
127
+ * }
128
+ * }
129
+ * });
130
+ * ```
131
+ *
132
+ * @example Single resource loading
133
+ * ```ts
134
+ * sdk: async (options) => {
135
+ * if (options.lng && options.ns) {
136
+ * const response = await fetch(`/api/i18n/${options.lng}/${options.ns}`);
137
+ * return response.json();
138
+ * }
139
+ * }
140
+ * ```
141
+ *
142
+ * @example Load all resources at once
143
+ * ```ts
144
+ * sdk: async (options) => {
145
+ * if (options?.all) {
146
+ * // Load all languages and namespaces
147
+ * return await mySdk.getAllResources();
148
+ * }
149
+ * // Handle other cases...
150
+ * }
151
+ * ```
152
+ *
153
+ * @example Batch loading
154
+ * ```ts
155
+ * sdk: async (options) => {
156
+ * if (options?.lngs && options?.nss) {
157
+ * // Load multiple languages and namespaces
158
+ * return await mySdk.getBatchResources(options.lngs, options.nss);
159
+ * }
160
+ * // Handle single or other cases...
161
+ * }
162
+ * ```
163
+ */
164
+ sdk?: I18nSdkLoader | boolean | string;
165
+ }
166
+ export interface BackendOptions extends BaseBackendOptions {
167
+ backendOptionsByEntry?: Record<string, BaseBackendOptions>;
168
+ }
@@ -0,0 +1,5 @@
1
+ import type { BaseBackendOptions, BaseLocaleDetectionOptions } from './type';
2
+ export declare function getEntryConfig<T extends Record<string, any>>(entryName: string, config: T, entryKey: string): T | undefined;
3
+ export declare function removeEntryConfigKey<T extends Record<string, any>>(config: T, entryKey: string): Omit<T, typeof entryKey>;
4
+ export declare function getLocaleDetectionOptions(entryName: string, localeDetection: BaseLocaleDetectionOptions): BaseLocaleDetectionOptions;
5
+ export declare function getBackendOptions(entryName: string, backend: BaseBackendOptions): BaseBackendOptions;
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "modern",
18
18
  "modern.js"
19
19
  ],
20
- "version": "3.2.0-ultramodern.3",
20
+ "version": "3.2.0-ultramodern.30",
21
21
  "engines": {
22
22
  "node": ">=20"
23
23
  },
@@ -84,18 +84,18 @@
84
84
  "@swc/helpers": "^0.5.21",
85
85
  "i18next-browser-languagedetector": "^8.2.1",
86
86
  "i18next-chained-backend": "^5.0.4",
87
- "i18next-fs-backend": "^2.6.5",
87
+ "i18next-fs-backend": "^2.6.6",
88
88
  "i18next-http-backend": "^4.0.0",
89
- "i18next-http-middleware": "^3.9.6",
90
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.3",
91
- "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.3",
92
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.3",
93
- "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.3",
94
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.3",
95
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.3"
89
+ "i18next-http-middleware": "^3.9.7",
90
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.30",
91
+ "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.30",
92
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.30",
93
+ "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.30",
94
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.30",
95
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.30"
96
96
  },
97
97
  "peerDependencies": {
98
- "@modern-js/runtime": "3.2.0-ultramodern.3",
98
+ "@modern-js/runtime": "3.2.0-ultramodern.30",
99
99
  "i18next": ">=25.7.4",
100
100
  "react": "^19.2.6",
101
101
  "react-dom": "^19.2.6",
@@ -112,16 +112,16 @@
112
112
  "devDependencies": {
113
113
  "@rslib/core": "0.21.5",
114
114
  "@types/jest": "^30.0.0",
115
- "@types/node": "^25.8.0",
116
- "@typescript/native-preview": "7.0.0-dev.20260516.1",
115
+ "@types/node": "^25.9.1",
116
+ "@typescript/native-preview": "7.0.0-dev.20260527.2",
117
117
  "i18next": "26.2.0",
118
118
  "jest": "^30.4.2",
119
119
  "react": "^19.2.6",
120
120
  "react-dom": "^19.2.6",
121
121
  "react-i18next": "17.0.8",
122
- "ts-jest": "^29.4.9",
123
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.3",
124
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.3"
122
+ "ts-jest": "^29.4.11",
123
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.30",
124
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.30"
125
125
  },
126
126
  "sideEffects": false,
127
127
  "publishConfig": {
@@ -0,0 +1,39 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ProjectConfig } from '@rstest/core';
4
+ import { withTestPreset } from '@scripts/rstest-config';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ const commonConfig: ProjectConfig = {
9
+ setupFiles: [resolve(__dirname, '../../../scripts/rstest-config/setup.ts')],
10
+ globals: true,
11
+ tools: {
12
+ swc: {
13
+ jsc: {
14
+ transform: {
15
+ react: {
16
+ runtime: 'automatic',
17
+ },
18
+ },
19
+ },
20
+ },
21
+ },
22
+ };
23
+
24
+ export default {
25
+ projects: [
26
+ withTestPreset({
27
+ name: 'plugin-i18n-node',
28
+ testEnvironment: 'node',
29
+ include: ['tests/localisedUrls.test.ts'],
30
+ extends: commonConfig,
31
+ }),
32
+ withTestPreset({
33
+ name: 'plugin-i18n-client',
34
+ testEnvironment: 'happy-dom',
35
+ include: ['tests/routerAdapter.test.tsx'],
36
+ extends: commonConfig,
37
+ }),
38
+ ],
39
+ };
package/src/cli/index.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  import type { AppTools, CliPlugin } from '@modern-js/app-tools';
2
2
  import { getPublicDirRoutePrefixes } from '@modern-js/server-core';
3
- import type { Entrypoint } from '@modern-js/types';
3
+ import type {
4
+ Entrypoint,
5
+ NestedRouteForCli,
6
+ PageRoute,
7
+ } from '@modern-js/types';
4
8
  import fs from 'fs';
5
9
  import path from 'path';
10
+ import {
11
+ applyLocalisedUrlsToRoutes,
12
+ resolveLocalisedUrlsConfig,
13
+ } from '../shared/localisedUrls';
6
14
  import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
7
15
  import { getBackendOptions, getLocaleDetectionOptions } from '../shared/utils';
8
16
 
@@ -203,6 +211,40 @@ export const i18nPlugin = (
203
211
  };
204
212
  });
205
213
 
214
+ api.modifyFileSystemRoutes(({ entrypoint, routes }) => {
215
+ if (!localeDetection) {
216
+ return { entrypoint, routes };
217
+ }
218
+
219
+ const localeDetectionOptions = getLocaleDetectionOptions(
220
+ entrypoint.entryName,
221
+ localeDetection,
222
+ );
223
+ const {
224
+ localePathRedirect,
225
+ languages = [],
226
+ localisedUrls,
227
+ } = localeDetectionOptions;
228
+
229
+ if (!localePathRedirect || languages.length === 0) {
230
+ return { entrypoint, routes };
231
+ }
232
+
233
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
234
+ if (!localisedUrlsConfig.enabled) {
235
+ return { entrypoint, routes };
236
+ }
237
+
238
+ return {
239
+ entrypoint,
240
+ routes: applyLocalisedUrlsToRoutes(
241
+ routes as (NestedRouteForCli | PageRoute)[],
242
+ languages,
243
+ localisedUrlsConfig.map,
244
+ ),
245
+ };
246
+ });
247
+
206
248
  api._internalServerPlugins(({ plugins }) => {
207
249
  const { serverRoutes, metaName } = api.getAppContext();
208
250
  const normalizedConfig = api.getNormalizedConfig();
@@ -1,12 +1,12 @@
1
- import { Link, useInRouterContext, useParams } from '@modern-js/runtime/router';
2
1
  import type React from 'react';
3
2
  import { useModernI18n } from './context';
3
+ import { useI18nRouterAdapter } from './routerAdapter';
4
4
  import { buildLocalizedUrl } from './utils';
5
5
 
6
6
  export interface I18nLinkProps {
7
7
  to: string;
8
8
  children: React.ReactNode;
9
- [key: string]: any; // Allow other props to be passed through
9
+ [key: string]: any;
10
10
  }
11
11
 
12
12
  /**
@@ -24,30 +24,25 @@ export interface I18nLinkProps {
24
24
  * <I18nLink to="/">Home</I18nLink>
25
25
  * ```
26
26
  */
27
- // Use static imports to avoid breaking router tree-shaking. Detect router context via useInRouterContext.
28
- const useRouterHooks = () => {
29
- const inRouter = useInRouterContext();
30
- return {
31
- Link: inRouter ? Link : null,
32
- params: inRouter ? useParams() : ({} as any),
33
- hasRouter: inRouter,
34
- };
35
- };
36
-
37
27
  export const I18nLink: React.FC<I18nLinkProps> = ({
38
28
  to,
39
29
  children,
40
30
  ...props
41
31
  }) => {
42
- const { Link, params, hasRouter } = useRouterHooks();
43
- const { language, supportedLanguages } = useModernI18n();
32
+ const { Link, params, hasRouter } = useI18nRouterAdapter();
33
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
44
34
 
45
35
  // Get the current language from context (which reflects the actual current language)
46
36
  // URL params might be stale after language changes, so we prioritize the context language
47
37
  const currentLang = language;
48
38
 
49
39
  // Build the localized URL by adding language prefix
50
- const localizedTo = buildLocalizedUrl(to, currentLang, supportedLanguages);
40
+ const localizedTo = buildLocalizedUrl(
41
+ to,
42
+ currentLang,
43
+ supportedLanguages,
44
+ localisedUrls,
45
+ );
51
46
 
52
47
  // In development mode, warn if used outside of :lang route context
53
48
  if (process.env.NODE_ENV === 'development' && hasRouter && !params.lang) {
@@ -57,7 +52,6 @@ export const I18nLink: React.FC<I18nLinkProps> = ({
57
52
  );
58
53
  }
59
54
 
60
- // If router is not available, render as a regular anchor tag
61
55
  if (!hasRouter || !Link) {
62
56
  return (
63
57
  <a href={localizedTo} {...props}>
@@ -1,15 +1,22 @@
1
1
  import { isBrowser } from '@modern-js/runtime';
2
2
  import type { FC, ReactNode } from 'react';
3
- import { createContext, useCallback, useContext, useMemo } from 'react';
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ } from 'react';
10
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
4
11
  import type { I18nInstance } from './i18n';
5
12
  import type { SdkBackend } from './i18n/backend/sdk-backend';
6
13
  import { cacheUserLanguage } from './i18n/detection';
14
+ import { useI18nRouterAdapter } from './routerAdapter';
7
15
  import {
8
16
  buildLocalizedUrl,
9
17
  detectLanguageFromPath,
10
18
  getEntryPath,
11
19
  shouldIgnoreRedirect,
12
- useRouterHooks,
13
20
  } from './utils';
14
21
 
15
22
  export interface ModernI18nContextValue {
@@ -20,6 +27,7 @@ export interface ModernI18nContextValue {
20
27
  languages?: string[];
21
28
  localePathRedirect?: boolean;
22
29
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
30
+ localisedUrls?: LocalisedUrlsOption;
23
31
  // Callback to update language in context
24
32
  updateLanguage?: (newLang: string) => void;
25
33
  }
@@ -47,6 +55,7 @@ export interface UseModernI18nReturn {
47
55
  changeLanguage: (newLang: string) => Promise<void>;
48
56
  i18nInstance: I18nInstance;
49
57
  supportedLanguages: string[];
58
+ localisedUrls?: LocalisedUrlsOption;
50
59
  isLanguageSupported: (lang: string) => boolean;
51
60
  // Indicates if translation resources for current language are ready to use
52
61
  isResourcesReady: boolean;
@@ -77,15 +86,40 @@ export const useModernI18n = (): UseModernI18nReturn => {
77
86
  languages,
78
87
  localePathRedirect,
79
88
  ignoreRedirectRoutes,
89
+ localisedUrls,
80
90
  updateLanguage,
81
91
  } = context;
82
92
 
83
- // Get router hooks safely
84
- const { navigate, location, hasRouter } = useRouterHooks();
93
+ const { navigate, location, hasRouter } = useI18nRouterAdapter();
94
+
95
+ const pathLanguage = useMemo(() => {
96
+ if (!localePathRedirect || !location?.pathname) {
97
+ return undefined;
98
+ }
99
+ const detected = detectLanguageFromPath(
100
+ location.pathname,
101
+ languages || [],
102
+ localePathRedirect,
103
+ );
104
+ return detected.detected ? detected.language : undefined;
105
+ }, [languages, localePathRedirect, location?.pathname]);
106
+
107
+ const currentLanguage = pathLanguage || contextLanguage;
85
108
 
86
- // Get current language from context (which reflects the actual current language)
87
- // URL params might be stale after language changes, so we prioritize the context language
88
- const currentLanguage = contextLanguage;
109
+ useEffect(() => {
110
+ if (!pathLanguage || pathLanguage === contextLanguage) {
111
+ return;
112
+ }
113
+
114
+ updateLanguage?.(pathLanguage);
115
+ i18nInstance?.setLang?.(pathLanguage);
116
+ void i18nInstance?.changeLanguage?.(pathLanguage);
117
+
118
+ if (isBrowser()) {
119
+ const detectionOptions = i18nInstance.options?.detection;
120
+ cacheUserLanguage(i18nInstance, pathLanguage, detectionOptions);
121
+ }
122
+ }, [contextLanguage, i18nInstance, pathLanguage, updateLanguage]);
89
123
 
90
124
  /**
91
125
  * Changes the current language and updates the URL accordingly.
@@ -147,6 +181,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
147
181
  relativePath,
148
182
  newLang,
149
183
  languages || [],
184
+ localisedUrls,
150
185
  );
151
186
  const newUrl =
152
187
  entryPath + newPath + location.search + location.hash;
@@ -181,6 +216,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
181
216
  relativePath,
182
217
  newLang,
183
218
  languages || [],
219
+ localisedUrls,
184
220
  );
185
221
  const newUrl =
186
222
  entryPath +
@@ -206,6 +242,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
206
242
  updateLanguage,
207
243
  localePathRedirect,
208
244
  ignoreRedirectRoutes,
245
+ localisedUrls,
209
246
  languages,
210
247
  hasRouter,
211
248
  navigate,
@@ -275,6 +312,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
275
312
  changeLanguage,
276
313
  i18nInstance,
277
314
  supportedLanguages: languages || [],
315
+ localisedUrls,
278
316
  isLanguageSupported,
279
317
  isResourcesReady,
280
318
  };
@@ -2,6 +2,7 @@ import type { TRuntimeContext } from '@modern-js/runtime';
2
2
  import { isBrowser } from '@modern-js/runtime';
3
3
  import type React from 'react';
4
4
  import { useEffect, useRef } from 'react';
5
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
5
6
  import type { I18nInstance } from './i18n';
6
7
  import {
7
8
  getI18nSdkBackendId,
@@ -9,13 +10,13 @@ import {
9
10
  type I18nSdkResourcesLoadedEventDetail,
10
11
  } from './i18n/backend/sdk-event';
11
12
  import { cacheUserLanguage } from './i18n/detection';
13
+ import { useI18nRouterAdapter } from './routerAdapter';
12
14
  import {
13
15
  buildLocalizedUrl,
14
16
  detectLanguageFromPath,
15
17
  getEntryPath,
16
18
  getPathname,
17
19
  shouldIgnoreRedirect,
18
- useRouterHooks,
19
20
  } from './utils';
20
21
 
21
22
  interface RuntimeContextWithI18n extends TRuntimeContext {
@@ -41,6 +42,7 @@ export function createContextValue(
41
42
  languages: string[],
42
43
  localePathRedirect: boolean,
43
44
  ignoreRedirectRoutes: string[] | ((pathname: string) => boolean) | undefined,
45
+ localisedUrls: LocalisedUrlsOption | undefined,
44
46
  setLang: (lang: string) => void,
45
47
  ) {
46
48
  const instance = i18nInstance || createMinimalI18nInstance(lang);
@@ -51,6 +53,7 @@ export function createContextValue(
51
53
  languages,
52
54
  localePathRedirect,
53
55
  ignoreRedirectRoutes,
56
+ localisedUrls,
54
57
  updateLanguage: setLang,
55
58
  };
56
59
  }
@@ -162,10 +165,10 @@ export function useClientSideRedirect(
162
165
  languages: string[],
163
166
  fallbackLanguage: string,
164
167
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
168
+ localisedUrls?: LocalisedUrlsOption,
165
169
  ) {
166
170
  const hasRedirectedRef = useRef(false);
167
- // Get router hooks safely
168
- const { navigate, location, hasRouter } = useRouterHooks();
171
+ const { navigate, location, hasRouter } = useI18nRouterAdapter();
169
172
 
170
173
  useEffect(() => {
171
174
  if (process.env.MODERN_TARGET !== 'browser') {
@@ -220,7 +223,12 @@ export function useClientSideRedirect(
220
223
  const targetLanguage =
221
224
  i18nInstance.language || fallbackLanguage || languages[0] || 'en';
222
225
 
223
- const newPath = buildLocalizedUrl(relativePath, targetLanguage, languages);
226
+ const newPath = buildLocalizedUrl(
227
+ relativePath,
228
+ targetLanguage,
229
+ languages,
230
+ localisedUrls,
231
+ );
224
232
  const newUrl = entryPath + newPath + currentSearch + currentHash;
225
233
 
226
234
  if (newUrl !== currentPathname + currentSearch + currentHash) {
@@ -244,6 +252,7 @@ export function useClientSideRedirect(
244
252
  languages,
245
253
  fallbackLanguage,
246
254
  ignoreRedirectRoutes,
255
+ localisedUrls,
247
256
  ]);
248
257
  }
249
258
 
@@ -15,7 +15,9 @@ function convertPath(path: string | undefined): string | undefined {
15
15
  }
16
16
  // If it's an absolute path (starts with /), convert to relative path
17
17
  if (path.startsWith('/')) {
18
- return `${window.__assetPrefix__ || ''}${path}`;
18
+ return typeof window === 'undefined'
19
+ ? path
20
+ : `${window.__assetPrefix__ || ''}${path}`;
19
21
  }
20
22
  return path;
21
23
  }
@@ -1,4 +1,4 @@
1
- import Backend from 'i18next-fs-backend';
1
+ import Backend from 'i18next-fs-backend/cjs';
2
2
  import type { ExtendedBackendOptions } from '../../../shared/type';
3
3
  import type { I18nInstance } from '../instance';
4
4
  import { useI18nextBackendCommon } from './middleware.common';
@@ -1,5 +1,9 @@
1
1
  import type { BaseBackendOptions } from '../../shared/type';
2
2
 
3
+ type ReactI18nextModule = typeof import('react-i18next');
4
+ type InitReactI18next = ReactI18nextModule['initReactI18next'];
5
+ type I18nextProviderComponent = ReactI18nextModule['I18nextProvider'];
6
+
3
7
  export interface I18nResourceStore {
4
8
  data?: {
5
9
  [language: string]: {
@@ -167,10 +171,15 @@ async function createI18nextInstance(): Promise<I18nInstance | null> {
167
171
  }
168
172
  }
169
173
 
170
- async function tryImportReactI18next() {
174
+ function getOptionalReactI18nextPackageName(): string {
175
+ return ['react', 'i18next'].join('-');
176
+ }
177
+
178
+ async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
171
179
  try {
172
- const reactI18next = await import('react-i18next');
173
- return reactI18next;
180
+ return (await import(
181
+ getOptionalReactI18nextPackageName()
182
+ )) as ReactI18nextModule;
174
183
  } catch (error) {
175
184
  return null;
176
185
  }
@@ -210,7 +219,7 @@ export async function getI18nInstance(
210
219
  throw new Error('No i18n instance found');
211
220
  }
212
221
 
213
- export async function getInitReactI18next() {
222
+ export async function getInitReactI18next(): Promise<InitReactI18next | null> {
214
223
  const reactI18nextModule = await tryImportReactI18next();
215
224
  if (reactI18nextModule) {
216
225
  return reactI18nextModule.initReactI18next;
@@ -218,7 +227,7 @@ export async function getInitReactI18next() {
218
227
  return null;
219
228
  }
220
229
 
221
- export async function getI18nextProvider() {
230
+ export async function getI18nextProvider(): Promise<I18nextProviderComponent | null> {
222
231
  const reactI18nextModule = await tryImportReactI18next();
223
232
  if (reactI18nextModule) {
224
233
  return reactI18nextModule.I18nextProvider;