@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121
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/README.md +221 -11
- package/dist/cjs/cli/index.js +32 -5
- package/dist/cjs/runtime/I18nLink.js +17 -28
- package/dist/cjs/runtime/Link.js +264 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +41 -10
- package/dist/cjs/runtime/hooks.js +17 -10
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
- package/dist/cjs/runtime/i18n/backend/index.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
- package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
- package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
- package/dist/cjs/runtime/i18n/detection/config.js +9 -5
- package/dist/cjs/runtime/i18n/detection/index.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
- package/dist/cjs/runtime/i18n/index.js +9 -5
- package/dist/cjs/runtime/i18n/instance.js +17 -37
- package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
- package/dist/cjs/runtime/i18n/utils.js +9 -17
- package/dist/cjs/runtime/index.js +50 -15
- package/dist/cjs/runtime/localizedPaths.js +102 -0
- package/dist/cjs/runtime/routerAdapter.js +167 -0
- package/dist/cjs/runtime/utils.js +80 -97
- package/dist/cjs/server/index.js +62 -14
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +351 -0
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +23 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -22
- package/dist/esm/runtime/Link.mjs +221 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- 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/defaults.node.mjs +24 -3
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +1 -19
- package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
- package/dist/esm/runtime/i18n/utils.mjs +0 -12
- package/dist/esm/runtime/index.mjs +23 -13
- package/dist/esm/runtime/localizedPaths.mjs +55 -0
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +19 -31
- package/dist/esm/server/index.mjs +46 -8
- package/dist/esm/shared/localisedUrls.mjs +283 -0
- package/dist/esm-node/cli/index.mjs +23 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -22
- package/dist/esm-node/runtime/Link.mjs +222 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- 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/defaults.node.mjs +24 -3
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
- package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
- package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
- package/dist/esm-node/runtime/index.mjs +23 -13
- package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +19 -31
- package/dist/esm-node/server/index.mjs +46 -8
- package/dist/esm-node/shared/localisedUrls.mjs +284 -0
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/runtime/I18nLink.d.ts +6 -0
- package/dist/types/runtime/Link.d.ts +66 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/context.d.ts +3 -0
- package/dist/types/runtime/hooks.d.ts +4 -2
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
- package/dist/types/runtime/i18n/instance.d.ts +4 -6
- package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
- package/dist/types/runtime/index.d.ts +6 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -9
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/shared/localisedUrls.d.ts +36 -0
- package/dist/types/shared/type.d.ts +14 -0
- package/package.json +24 -24
- package/rstest.config.mts +44 -0
- package/src/cli/index.ts +44 -1
- package/src/runtime/I18nLink.tsx +14 -51
- package/src/runtime/Link.tsx +430 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.node.ts +40 -2
- 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 +3 -30
- package/src/runtime/i18n/react-i18next.ts +25 -0
- package/src/runtime/i18n/utils.ts +4 -26
- package/src/runtime/index.tsx +47 -12
- package/src/runtime/localizedPaths.ts +107 -0
- package/src/runtime/routerAdapter.tsx +332 -0
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +33 -38
- package/src/server/index.ts +108 -11
- package/src/shared/localisedUrls.ts +623 -0
- package/src/shared/type.ts +14 -0
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +59 -0
- package/tests/link.test.tsx +525 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +536 -0
- package/tests/routerAdapter.test.tsx +456 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
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
|
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import nodePath from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Conventional locales roots, in the same priority order as the CLI plugin's
|
|
6
|
+
* `detectLocalesDirectory` auto-detection (project-root `./locales` first —
|
|
7
|
+
* the upstream convention — then the scaffold's `./config/public/locales`).
|
|
8
|
+
* The fs-backend default must read from the same directory whose existence
|
|
9
|
+
* enabled the backend in the first place.
|
|
10
|
+
*/
|
|
11
|
+
const CONVENTIONAL_LOCALES_DIRS = [
|
|
12
|
+
'./locales',
|
|
13
|
+
'./config/public/locales',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
const isDirectory = (dirPath: string): boolean => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.statSync(dirPath).isDirectory();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const resolveDefaultLocalesDir = (
|
|
25
|
+
cwd: string = process.cwd(),
|
|
26
|
+
): string => {
|
|
27
|
+
for (const dir of CONVENTIONAL_LOCALES_DIRS) {
|
|
28
|
+
if (isDirectory(nodePath.resolve(cwd, dir))) {
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return CONVENTIONAL_LOCALES_DIRS[0];
|
|
33
|
+
};
|
|
34
|
+
|
|
1
35
|
export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
|
|
2
|
-
loadPath:
|
|
3
|
-
|
|
36
|
+
get loadPath(): string {
|
|
37
|
+
return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
|
|
38
|
+
},
|
|
39
|
+
get addPath(): string {
|
|
40
|
+
return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
|
|
41
|
+
},
|
|
4
42
|
};
|
|
5
43
|
|
|
6
44
|
function convertPath(path: string | undefined): string | undefined {
|
|
@@ -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,9 +1,5 @@
|
|
|
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
|
-
|
|
7
3
|
export interface I18nResourceStore {
|
|
8
4
|
data?: {
|
|
9
5
|
[language: string]: {
|
|
@@ -110,9 +106,11 @@ export interface BackendOptions extends Omit<BaseBackendOptions, 'enabled'> {
|
|
|
110
106
|
[key: string]: any;
|
|
111
107
|
}
|
|
112
108
|
|
|
109
|
+
export type ResourceValue = string | { [key: string]: ResourceValue };
|
|
110
|
+
|
|
113
111
|
export interface Resources {
|
|
114
112
|
[lng: string]: {
|
|
115
|
-
[source: string]:
|
|
113
|
+
[source: string]: ResourceValue;
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
|
|
@@ -171,15 +169,6 @@ async function createI18nextInstance(): Promise<I18nInstance | null> {
|
|
|
171
169
|
}
|
|
172
170
|
}
|
|
173
171
|
|
|
174
|
-
async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
|
|
175
|
-
try {
|
|
176
|
-
const reactI18next = await import('react-i18next');
|
|
177
|
-
return reactI18next;
|
|
178
|
-
} catch (error) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
172
|
export function getI18nextInstanceForProvider(
|
|
184
173
|
instance: I18nInstance | any,
|
|
185
174
|
): any {
|
|
@@ -213,19 +202,3 @@ export async function getI18nInstance(
|
|
|
213
202
|
|
|
214
203
|
throw new Error('No i18n instance found');
|
|
215
204
|
}
|
|
216
|
-
|
|
217
|
-
export async function getInitReactI18next(): Promise<InitReactI18next | null> {
|
|
218
|
-
const reactI18nextModule = await tryImportReactI18next();
|
|
219
|
-
if (reactI18nextModule) {
|
|
220
|
-
return reactI18nextModule.initReactI18next;
|
|
221
|
-
}
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function getI18nextProvider(): Promise<I18nextProviderComponent | null> {
|
|
226
|
-
const reactI18nextModule = await tryImportReactI18next();
|
|
227
|
-
if (reactI18nextModule) {
|
|
228
|
-
return reactI18nextModule.I18nextProvider;
|
|
229
|
-
}
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
type ReactI18nextModule = typeof import('react-i18next');
|
|
4
|
+
|
|
5
|
+
interface ReactI18nextIntegration {
|
|
6
|
+
I18nextProvider: React.ComponentType<any> | null;
|
|
7
|
+
initReactI18next: any | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
|
|
11
|
+
try {
|
|
12
|
+
return (await import('react-i18next')) as ReactI18nextModule;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getReactI18nextIntegration(): Promise<ReactI18nextIntegration> {
|
|
19
|
+
const reactI18nextModule = await tryImportReactI18next();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
I18nextProvider: reactI18nextModule?.I18nextProvider ?? null,
|
|
23
|
+
initReactI18next: reactI18nextModule?.initReactI18next ?? null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -238,32 +238,10 @@ export const initializeI18nInstance = async (
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// 2. Asynchronously loads from the second backend (SDK) and updates the store
|
|
246
|
-
// 3. Triggers 'loaded' event when SDK resources are loaded, which causes React to re-render
|
|
247
|
-
//
|
|
248
|
-
// Note: i18next.init() returns a Promise that resolves when the first backend loads.
|
|
249
|
-
// For chained backend, it does NOT wait for the second backend (SDK) to load.
|
|
250
|
-
// The SDK backend loads asynchronously and triggers 'loaded' event automatically.
|
|
251
|
-
const defaultNS =
|
|
252
|
-
initOptions.defaultNS || initOptions.ns || 'translation';
|
|
253
|
-
const ns = Array.isArray(defaultNS) ? defaultNS[0] : defaultNS;
|
|
254
|
-
|
|
255
|
-
let retries = 20;
|
|
256
|
-
while (retries > 0) {
|
|
257
|
-
// Get the actual i18next instance to access store property
|
|
258
|
-
const actualInstance = getActualI18nextInstance(i18nInstance);
|
|
259
|
-
const store = (actualInstance as any).store;
|
|
260
|
-
if (store?.data?.[finalLanguage]?.[ns]) {
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
264
|
-
retries--;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
241
|
+
// i18next.init() is the synchronization boundary for the primary backend.
|
|
242
|
+
// Chained SDK refreshes update the store through their own loaded events and
|
|
243
|
+
// must not block SSR HTML, otherwise missing/edge-only resources add fixed
|
|
244
|
+
// latency to every route render.
|
|
267
245
|
}
|
|
268
246
|
};
|
|
269
247
|
|
package/src/runtime/index.tsx
CHANGED
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
mergeDetectionOptions,
|
|
30
30
|
} from './i18n/detection';
|
|
31
31
|
import { useI18nextLanguageDetector } from './i18n/detection/middleware';
|
|
32
|
-
import {
|
|
33
|
-
getI18nextInstanceForProvider,
|
|
34
|
-
getI18nextProvider,
|
|
35
|
-
getInitReactI18next,
|
|
36
|
-
} from './i18n/instance';
|
|
32
|
+
import { getI18nextInstanceForProvider } from './i18n/instance';
|
|
37
33
|
import {
|
|
38
34
|
changeI18nLanguage,
|
|
39
35
|
ensureLanguageMatch,
|
|
@@ -54,6 +50,7 @@ export interface I18nPluginOptions {
|
|
|
54
50
|
changeLanguage?: (lang: string) => void;
|
|
55
51
|
initOptions?: I18nInitOptions;
|
|
56
52
|
htmlLangAttr?: boolean;
|
|
53
|
+
reactI18next?: boolean;
|
|
57
54
|
[key: string]: any;
|
|
58
55
|
}
|
|
59
56
|
|
|
@@ -72,6 +69,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
72
69
|
localeDetection,
|
|
73
70
|
backend,
|
|
74
71
|
htmlLangAttr = false,
|
|
72
|
+
reactI18next = true,
|
|
75
73
|
} = options;
|
|
76
74
|
const {
|
|
77
75
|
localePathRedirect = false,
|
|
@@ -80,20 +78,31 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
80
78
|
fallbackLanguage = 'en',
|
|
81
79
|
detection,
|
|
82
80
|
ignoreRedirectRoutes,
|
|
81
|
+
localisedUrls,
|
|
83
82
|
} = localeDetection || {};
|
|
84
83
|
const { enabled: backendEnabled = false } = backend || {};
|
|
85
84
|
let latestI18nInstance: I18nInstance | undefined;
|
|
86
|
-
let I18nextProvider: React.
|
|
85
|
+
let I18nextProvider: React.ComponentType<any> | null;
|
|
86
|
+
|
|
87
|
+
const loadReactI18nextIntegration = async () => {
|
|
88
|
+
if (!reactI18next) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const { getReactI18nextIntegration } = await import(
|
|
92
|
+
'./i18n/react-i18next'
|
|
93
|
+
);
|
|
94
|
+
return getReactI18nextIntegration();
|
|
95
|
+
};
|
|
87
96
|
|
|
88
97
|
api.onBeforeRender(async context => {
|
|
89
98
|
let i18nInstance = await getI18nInstance(userI18nInstance);
|
|
90
99
|
const { i18n: otherConfig } = api.getRuntimeConfig();
|
|
91
100
|
const { initOptions: otherInitOptions } = otherConfig || {};
|
|
92
101
|
const userInitOptions = merge(otherInitOptions || {}, initOptions || {});
|
|
93
|
-
const
|
|
94
|
-
I18nextProvider =
|
|
95
|
-
if (initReactI18next) {
|
|
96
|
-
i18nInstance.use(initReactI18next);
|
|
102
|
+
const reactI18nextIntegration = await loadReactI18nextIntegration();
|
|
103
|
+
I18nextProvider = reactI18nextIntegration?.I18nextProvider ?? null;
|
|
104
|
+
if (reactI18nextIntegration?.initReactI18next) {
|
|
105
|
+
i18nInstance.use(reactI18nextIntegration.initReactI18next);
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
const pathname = getPathname(context);
|
|
@@ -227,6 +236,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
227
236
|
languages,
|
|
228
237
|
fallbackLanguage,
|
|
229
238
|
ignoreRedirectRoutes,
|
|
239
|
+
localisedUrls,
|
|
230
240
|
);
|
|
231
241
|
|
|
232
242
|
const contextValue = useMemo(
|
|
@@ -238,6 +248,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
238
248
|
languages,
|
|
239
249
|
localePathRedirect,
|
|
240
250
|
ignoreRedirectRoutes,
|
|
251
|
+
localisedUrls,
|
|
241
252
|
setLang,
|
|
242
253
|
),
|
|
243
254
|
[
|
|
@@ -247,15 +258,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
247
258
|
languages,
|
|
248
259
|
localePathRedirect,
|
|
249
260
|
ignoreRedirectRoutes,
|
|
261
|
+
localisedUrls,
|
|
250
262
|
forceUpdate,
|
|
251
263
|
],
|
|
252
264
|
);
|
|
253
265
|
|
|
266
|
+
const children = (props as React.PropsWithChildren).children;
|
|
254
267
|
const appContent = (
|
|
255
268
|
<>
|
|
256
269
|
{Boolean(htmlLangAttr) && <Helmet htmlAttributes={{ lang }} />}
|
|
257
270
|
<ModernI18nProvider value={contextValue}>
|
|
258
|
-
<App {...props}
|
|
271
|
+
{App ? <App {...props}>{children}</App> : children}
|
|
259
272
|
</ModernI18nProvider>
|
|
260
273
|
</>
|
|
261
274
|
);
|
|
@@ -280,6 +293,28 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
280
293
|
},
|
|
281
294
|
});
|
|
282
295
|
|
|
296
|
+
export type {
|
|
297
|
+
AllowedLinkTarget,
|
|
298
|
+
CanonicalRoutePath,
|
|
299
|
+
UltramodernCanonicalRoutes,
|
|
300
|
+
} from './canonicalRoutes';
|
|
283
301
|
export { useModernI18n } from './context';
|
|
284
|
-
export { I18nLink } from './I18nLink';
|
|
302
|
+
export { I18nLink, type I18nLinkProps } from './I18nLink';
|
|
303
|
+
export {
|
|
304
|
+
Link,
|
|
305
|
+
type LinkActiveOptions,
|
|
306
|
+
type LinkBaseProps,
|
|
307
|
+
type LinkParams,
|
|
308
|
+
type LinkProps,
|
|
309
|
+
} from './Link';
|
|
310
|
+
export {
|
|
311
|
+
canonicalPath,
|
|
312
|
+
type LocalizedPathsConfig,
|
|
313
|
+
localizePath,
|
|
314
|
+
type UseLocalizedLocationReturn,
|
|
315
|
+
type UseLocalizedPathsReturn,
|
|
316
|
+
useLocalizedLocation,
|
|
317
|
+
useLocalizedPaths,
|
|
318
|
+
} from './localizedPaths';
|
|
319
|
+
export { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
285
320
|
export default i18nPlugin;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { LocalisedUrlsOption } from '../shared/localisedUrls';
|
|
3
|
+
import { canonicalTargetPathname } from '../shared/localisedUrls';
|
|
4
|
+
import { useModernI18n } from './context';
|
|
5
|
+
import { useI18nRouterAdapter } from './routerAdapter';
|
|
6
|
+
import { buildLocalizedUrl, splitUrlTarget } from './utils';
|
|
7
|
+
|
|
8
|
+
export interface LocalizedPathsConfig {
|
|
9
|
+
languages: string[];
|
|
10
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Localize a canonical, language-agnostic target for the given language:
|
|
15
|
+
* adds the language prefix and applies `localisedUrls` pattern mapping.
|
|
16
|
+
* `?search`/`#hash` suffixes are preserved verbatim.
|
|
17
|
+
*/
|
|
18
|
+
export const localizePath = (
|
|
19
|
+
pathname: string,
|
|
20
|
+
language: string,
|
|
21
|
+
config: LocalizedPathsConfig,
|
|
22
|
+
): string =>
|
|
23
|
+
buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reverse of {@link localizePath}: strip the language prefix and map localized
|
|
27
|
+
* slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
|
|
28
|
+
* preserved verbatim.
|
|
29
|
+
*/
|
|
30
|
+
export const canonicalPath = (
|
|
31
|
+
target: string,
|
|
32
|
+
config: LocalizedPathsConfig,
|
|
33
|
+
): string => {
|
|
34
|
+
const { pathname, search, hash } = splitUrlTarget(target);
|
|
35
|
+
const resolvedPath = canonicalTargetPathname(
|
|
36
|
+
pathname,
|
|
37
|
+
config.languages,
|
|
38
|
+
config.localisedUrls,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return `${resolvedPath}${search}${hash}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface UseLocalizedPathsReturn {
|
|
45
|
+
localizePath: (pathname: string, language: string) => string;
|
|
46
|
+
canonicalPath: (pathname: string) => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Context-bound versions of {@link localizePath} and {@link canonicalPath} —
|
|
51
|
+
* the plugin configuration (languages, localisedUrls) is read from the i18n
|
|
52
|
+
* provider, so apps never copy pattern-matching helpers again.
|
|
53
|
+
*/
|
|
54
|
+
export const useLocalizedPaths = (): UseLocalizedPathsReturn => {
|
|
55
|
+
const { supportedLanguages, localisedUrls } = useModernI18n();
|
|
56
|
+
|
|
57
|
+
return useMemo(() => {
|
|
58
|
+
const config: LocalizedPathsConfig = {
|
|
59
|
+
languages: supportedLanguages,
|
|
60
|
+
localisedUrls,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
localizePath: (pathname: string, language: string) =>
|
|
65
|
+
localizePath(pathname, language, config),
|
|
66
|
+
canonicalPath: (pathname: string) => canonicalPath(pathname, config),
|
|
67
|
+
};
|
|
68
|
+
}, [supportedLanguages, localisedUrls]);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface UseLocalizedLocationReturn {
|
|
72
|
+
language: string;
|
|
73
|
+
/** Canonical (language-agnostic) path of the current location. */
|
|
74
|
+
canonical: string;
|
|
75
|
+
/** Per-language hrefs for the current location, search+hash preserved. */
|
|
76
|
+
alternates: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-language hrefs for the current location — for hreflang `<link>` tags and
|
|
81
|
+
* language switchers. SSR-safe: the location comes from the router adapter.
|
|
82
|
+
*/
|
|
83
|
+
export const useLocalizedLocation = (): UseLocalizedLocationReturn => {
|
|
84
|
+
const { language, supportedLanguages, localisedUrls } = useModernI18n();
|
|
85
|
+
const { location } = useI18nRouterAdapter();
|
|
86
|
+
const pathname = location?.pathname ?? '/';
|
|
87
|
+
const search = location?.search ?? '';
|
|
88
|
+
const hash = location?.hash ?? '';
|
|
89
|
+
|
|
90
|
+
return useMemo(() => {
|
|
91
|
+
const config: LocalizedPathsConfig = {
|
|
92
|
+
languages: supportedLanguages,
|
|
93
|
+
localisedUrls,
|
|
94
|
+
};
|
|
95
|
+
const alternates: Record<string, string> = {};
|
|
96
|
+
for (const supportedLanguage of supportedLanguages) {
|
|
97
|
+
alternates[supportedLanguage] =
|
|
98
|
+
`${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
language,
|
|
103
|
+
canonical: canonicalPath(pathname, config),
|
|
104
|
+
alternates,
|
|
105
|
+
};
|
|
106
|
+
}, [language, supportedLanguages, localisedUrls, pathname, search, hash]);
|
|
107
|
+
};
|