@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.
- package/dist/cjs/cli/index.js +22 -0
- package/dist/cjs/runtime/I18nLink.js +4 -12
- package/dist/cjs/runtime/context.js +32 -5
- package/dist/cjs/runtime/hooks.js +8 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +1 -1
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
- package/dist/cjs/runtime/i18n/instance.js +4 -2
- package/dist/cjs/runtime/index.js +7 -6
- package/dist/cjs/runtime/routerAdapter.js +163 -0
- package/dist/cjs/runtime/utils.js +63 -94
- package/dist/cjs/server/index.js +60 -8
- package/dist/cjs/shared/localisedUrls.js +237 -0
- package/dist/esm/cli/index.mjs +22 -0
- package/dist/esm/runtime/I18nLink.mjs +4 -12
- package/dist/esm/runtime/context.mjs +34 -7
- package/dist/esm/runtime/hooks.mjs +9 -6
- package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +4 -2
- package/dist/esm/runtime/index.mjs +7 -6
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +11 -30
- package/dist/esm/server/index.mjs +53 -7
- package/dist/esm/shared/localisedUrls.mjs +191 -0
- package/dist/esm-node/cli/index.mjs +22 -0
- package/dist/esm-node/runtime/I18nLink.mjs +4 -12
- package/dist/esm-node/runtime/context.mjs +34 -7
- package/dist/esm-node/runtime/hooks.mjs +9 -6
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +4 -2
- package/dist/esm-node/runtime/index.mjs +7 -6
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +11 -30
- package/dist/esm-node/server/index.mjs +53 -7
- package/dist/esm-node/shared/localisedUrls.mjs +192 -0
- package/dist/types/cli/index.d.ts +21 -0
- package/dist/types/runtime/I18nLink.d.ts +23 -0
- package/dist/types/runtime/context.d.ts +41 -0
- package/dist/types/runtime/hooks.d.ts +30 -0
- package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
- package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
- package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
- package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
- package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
- package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
- package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
- package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
- package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
- package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
- package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
- package/dist/types/runtime/i18n/index.d.ts +3 -0
- package/dist/types/runtime/i18n/instance.d.ts +96 -0
- package/dist/types/runtime/i18n/utils.d.ts +29 -0
- package/dist/types/runtime/index.d.ts +21 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +15 -0
- package/dist/types/runtime/utils.d.ts +28 -0
- package/dist/types/server/index.d.ts +14 -0
- package/dist/types/shared/deepMerge.d.ts +1 -0
- package/dist/types/shared/detection.d.ts +11 -0
- package/dist/types/shared/localisedUrls.d.ts +13 -0
- package/dist/types/shared/type.d.ts +168 -0
- package/dist/types/shared/utils.d.ts +5 -0
- package/package.json +15 -15
- package/rstest.config.mts +39 -0
- package/src/cli/index.ts +43 -1
- package/src/runtime/I18nLink.tsx +10 -16
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.ts +3 -1
- package/src/runtime/i18n/backend/middleware.node.ts +1 -1
- package/src/runtime/i18n/instance.ts +14 -5
- package/src/runtime/index.tsx +10 -2
- package/src/runtime/routerAdapter.tsx +333 -0
- package/src/runtime/utils.ts +22 -34
- package/src/server/index.ts +117 -10
- package/src/shared/localisedUrls.ts +393 -0
- package/src/shared/type.ts +12 -0
- package/tests/localisedUrls.test.ts +278 -0
- package/tests/routerAdapter.test.tsx +278 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- 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.
|
|
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.
|
|
87
|
+
"i18next-fs-backend": "^2.6.6",
|
|
88
88
|
"i18next-http-backend": "^4.0.0",
|
|
89
|
-
"i18next-http-middleware": "^3.9.
|
|
90
|
-
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.
|
|
91
|
-
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.
|
|
92
|
-
"@modern-js/
|
|
93
|
-
"@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.
|
|
94
|
-
"@modern-js/
|
|
95
|
-
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.
|
|
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.
|
|
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.
|
|
116
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
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.
|
|
123
|
-
"@modern-js/
|
|
124
|
-
"@modern-js/
|
|
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 {
|
|
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();
|
package/src/runtime/I18nLink.tsx
CHANGED
|
@@ -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;
|
|
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 } =
|
|
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(
|
|
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}>
|
package/src/runtime/context.tsx
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { isBrowser } from '@modern-js/runtime';
|
|
2
2
|
import type { FC, ReactNode } from 'react';
|
|
3
|
-
import {
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
};
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
174
|
+
function getOptionalReactI18nextPackageName(): string {
|
|
175
|
+
return ['react', 'i18next'].join('-');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
|
|
171
179
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
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;
|