@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.9 → 3.9.3-ultramodern.2
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 +1 -1
- package/dist/cjs/cli/localisedRoutes.js +3 -2
- package/dist/cjs/runtime/context.js +6 -14
- package/dist/cjs/runtime/contexts.js +49 -0
- package/dist/cjs/runtime/core.js +8 -1
- package/dist/cjs/runtime/i18n/detection/middleware.js +1 -1
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +10 -1
- package/dist/cjs/runtime/i18n/detection/priority.js +1 -1
- package/dist/cjs/runtime/navigation.js +48 -0
- package/dist/cjs/runtime/routerAdapter.js +3 -5
- package/dist/cjs/runtime/sharedContexts.js +43 -0
- package/dist/cjs/shared/detection.js +118 -2
- package/dist/esm/cli/index.mjs +1 -1
- package/dist/esm/cli/localisedRoutes.mjs +3 -2
- package/dist/esm/runtime/context.mjs +2 -10
- package/dist/esm/runtime/contexts.mjs +5 -0
- package/dist/esm/runtime/core.mjs +3 -2
- package/dist/esm/runtime/i18n/detection/middleware.mjs +1 -1
- package/dist/esm/runtime/i18n/detection/middleware.node.mjs +10 -1
- package/dist/esm/runtime/i18n/detection/priority.mjs +1 -1
- package/dist/esm/runtime/navigation.mjs +7 -0
- package/dist/esm/runtime/routerAdapter.mjs +2 -4
- package/dist/esm/runtime/sharedContexts.mjs +1 -0
- package/dist/esm/shared/detection.mjs +85 -1
- package/dist/esm-node/cli/index.mjs +1 -1
- package/dist/esm-node/cli/localisedRoutes.mjs +3 -2
- package/dist/esm-node/runtime/context.mjs +2 -10
- package/dist/esm-node/runtime/contexts.mjs +6 -0
- package/dist/esm-node/runtime/core.mjs +3 -2
- package/dist/esm-node/runtime/i18n/detection/middleware.mjs +1 -1
- package/dist/esm-node/runtime/i18n/detection/middleware.node.mjs +10 -1
- package/dist/esm-node/runtime/i18n/detection/priority.mjs +1 -1
- package/dist/esm-node/runtime/navigation.mjs +8 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +2 -4
- package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
- package/dist/esm-node/shared/detection.mjs +85 -1
- package/dist/types/cli/localisedRoutes.d.ts +3 -2
- package/dist/types/runtime/context.d.ts +2 -2
- package/dist/types/runtime/contexts.d.ts +6 -0
- package/dist/types/runtime/core.d.ts +1 -0
- package/dist/types/runtime/i18n/detection/middleware.d.ts +1 -1
- package/dist/types/runtime/i18n/detection/middleware.node.d.ts +1 -5
- package/dist/types/runtime/navigation.d.ts +10 -0
- package/dist/types/runtime/sharedContexts.d.ts +1 -0
- package/dist/types/shared/detection.d.ts +11 -1
- package/package.json +43 -25
- package/src/cli/index.ts +1 -0
- package/src/cli/localisedRoutes.ts +11 -1
- package/src/runtime/context.tsx +6 -31
- package/src/runtime/contexts.ts +16 -0
- package/src/runtime/core.tsx +12 -1
- package/src/runtime/i18n/detection/middleware.node.ts +25 -7
- package/src/runtime/i18n/detection/middleware.ts +2 -0
- package/src/runtime/i18n/detection/priority.ts +2 -0
- package/src/runtime/navigation.tsx +20 -0
- package/src/runtime/routerAdapter.tsx +2 -11
- package/src/runtime/sharedContexts.ts +8 -0
- package/src/shared/detection.ts +34 -10
- package/rslib.config.mts +0 -4
- package/rstest.config.mts +0 -50
- package/tests/backendDefaults.test.ts +0 -30
- package/tests/defaultUrlStrategy.test.ts +0 -107
- package/tests/federatedI18nBoundaryResolver.test.tsx +0 -65
- package/tests/i18nUtils.test.ts +0 -60
- package/tests/link.test.tsx +0 -139
- package/tests/localisedRouteGeneration.test.ts +0 -159
- package/tests/localisedUrls.test.ts +0 -193
- package/tests/mappedUrlStrategyDerivation.test.ts +0 -62
- package/tests/reactI18nextRuntimeBoundary.test.ts +0 -117
- package/tests/redirectPolicy.test.ts +0 -107
- package/tests/routerAdapter.test.tsx +0 -221
- package/tests/urlStrategySerialization.test.ts +0 -122
- package/tsconfig.json +0 -15
package/src/runtime/context.tsx
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { RuntimeComponentResolverContext } from '@modern-js/runtime/context';
|
|
2
2
|
import type { ComponentType, FC, ReactNode } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
useCallback,
|
|
6
|
-
useContext,
|
|
7
|
-
useEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
} from 'react';
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
|
10
4
|
import type { I18nUrlStrategy } from '../shared/urlStrategy';
|
|
11
5
|
import {
|
|
12
6
|
changeModernI18nLanguage,
|
|
@@ -19,6 +13,10 @@ import type { I18nInstance } from './i18n';
|
|
|
19
13
|
import type { Resources } from './i18n/instance';
|
|
20
14
|
|
|
21
15
|
import { useI18nRouterAdapter } from './routerAdapter';
|
|
16
|
+
import {
|
|
17
|
+
ModernI18nContext,
|
|
18
|
+
ReactI18nextProviderContext,
|
|
19
|
+
} from './sharedContexts';
|
|
22
20
|
|
|
23
21
|
export { getActualI18nextInstance } from './i18n/instance';
|
|
24
22
|
|
|
@@ -36,30 +34,7 @@ export interface ModernI18nContextValue {
|
|
|
36
34
|
synchronizeLanguage?: (newLang: string) => void;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
'@modern-js/plugin-i18n/runtime/ModernI18nContext',
|
|
41
|
-
);
|
|
42
|
-
const reactI18nextProviderContextKey = Symbol.for(
|
|
43
|
-
'@modern-js/plugin-i18n/runtime/ReactI18nextProviderContext',
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
type GlobalContextStore<T> = typeof globalThis & {
|
|
47
|
-
[key: symbol]: ReturnType<typeof createContext<T>> | undefined;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const getGlobalContext = <T,>(key: symbol, defaultValue: T) => {
|
|
51
|
-
const globalStore = globalThis as GlobalContextStore<T>;
|
|
52
|
-
globalStore[key] ??= createContext<T>(defaultValue);
|
|
53
|
-
return globalStore[key];
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export const ModernI18nContext =
|
|
57
|
-
getGlobalContext<ModernI18nContextValue | null>(modernI18nContextKey, null);
|
|
58
|
-
export const ReactI18nextProviderContext =
|
|
59
|
-
getGlobalContext<ComponentType<any> | null>(
|
|
60
|
-
reactI18nextProviderContextKey,
|
|
61
|
-
null,
|
|
62
|
-
);
|
|
37
|
+
export { ModernI18nContext, ReactI18nextProviderContext };
|
|
63
38
|
|
|
64
39
|
export interface ModernI18nProviderProps {
|
|
65
40
|
children: ReactNode;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import { createContext } from 'react';
|
|
3
|
+
import type { ModernI18nContextValue } from './context';
|
|
4
|
+
import type { I18nRouterAdapter } from './routerAdapter';
|
|
5
|
+
|
|
6
|
+
// Every other i18n runtime module imports these through
|
|
7
|
+
// `@modern-js/plugin-i18n/runtime/contexts` so Module Federation shares one
|
|
8
|
+
// copy of them. Their values stay provider-owned.
|
|
9
|
+
export const ModernI18nContext = createContext<ModernI18nContextValue | null>(
|
|
10
|
+
null,
|
|
11
|
+
);
|
|
12
|
+
export const ReactI18nextProviderContext =
|
|
13
|
+
createContext<ComponentType<any> | null>(null);
|
|
14
|
+
export const I18nNavigationContext = createContext<I18nRouterAdapter | null>(
|
|
15
|
+
null,
|
|
16
|
+
);
|
package/src/runtime/core.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from '../shared/type';
|
|
8
8
|
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
9
9
|
import type { I18nInitOptions, I18nInstance } from './i18n';
|
|
10
|
+
import { I18nRouterNavigationProvider } from './navigation';
|
|
10
11
|
import {
|
|
11
12
|
type RuntimeContextWithI18n,
|
|
12
13
|
setupI18nBeforeRender,
|
|
@@ -32,6 +33,10 @@ export type {
|
|
|
32
33
|
Resources,
|
|
33
34
|
TranslateFn,
|
|
34
35
|
} from './i18n/instance';
|
|
36
|
+
export {
|
|
37
|
+
I18nRouterNavigationProvider,
|
|
38
|
+
useIntegratedRouterAdapter,
|
|
39
|
+
} from './navigation';
|
|
35
40
|
export type {
|
|
36
41
|
LoadReactI18nextIntegration,
|
|
37
42
|
ReactI18nextIntegration,
|
|
@@ -137,7 +142,13 @@ export const createI18nPlugin =
|
|
|
137
142
|
fallbackLanguage,
|
|
138
143
|
ignoreRedirectRoutes,
|
|
139
144
|
urlStrategy: resolvedUrlStrategy,
|
|
140
|
-
|
|
145
|
+
// The selected router, not react-router, is what a localized `<Link>`
|
|
146
|
+
// has to navigate through, and the i18n language has to follow a
|
|
147
|
+
// client-side navigation to a mapped URL. Both need the actual router
|
|
148
|
+
// instance, so a bare `appTools()` consumer gets the integrated
|
|
149
|
+
// adapter by default; a composing runtime plugin can still replace it.
|
|
150
|
+
NavigationProvider:
|
|
151
|
+
NavigationProvider ?? I18nRouterNavigationProvider,
|
|
141
152
|
LanguageSynchronization,
|
|
142
153
|
getLatestI18nInstance: () => latestI18nInstance,
|
|
143
154
|
getI18nextProvider: () => I18nextProvider,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LanguageDetector } from 'i18next-http-middleware';
|
|
2
|
+
import { detectLanguageFromRequest } from '../../../shared/detection.js';
|
|
2
3
|
import type { I18nInstance, LanguageDetectorOptions } from '../instance';
|
|
3
4
|
|
|
4
5
|
type HttpDetectorInit = (
|
|
@@ -13,6 +14,20 @@ type HttpDetectorDetect = (
|
|
|
13
14
|
detectionOrder?: unknown,
|
|
14
15
|
) => string | string[] | undefined;
|
|
15
16
|
|
|
17
|
+
type DetectionRequest = Parameters<typeof detectLanguageFromRequest>[0];
|
|
18
|
+
|
|
19
|
+
const isDetectionRequest = (request: unknown): request is DetectionRequest => {
|
|
20
|
+
if (!request || typeof request !== 'object') {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { url, headers } = request as {
|
|
25
|
+
url?: unknown;
|
|
26
|
+
headers?: unknown;
|
|
27
|
+
};
|
|
28
|
+
return typeof url === 'string' && !!headers && typeof headers === 'object';
|
|
29
|
+
};
|
|
30
|
+
|
|
16
31
|
export const cacheUserLanguage = (
|
|
17
32
|
_i18nInstance: I18nInstance,
|
|
18
33
|
_language: string,
|
|
@@ -21,15 +36,18 @@ export const cacheUserLanguage = (
|
|
|
21
36
|
return;
|
|
22
37
|
};
|
|
23
38
|
|
|
24
|
-
/**
|
|
25
|
-
* Read language directly from storage (localStorage/cookie)
|
|
26
|
-
* Not available in Node.js environment, returns undefined
|
|
27
|
-
*/
|
|
28
39
|
export const readLanguageFromStorage = (
|
|
29
|
-
|
|
40
|
+
detectionOptions?: LanguageDetectorOptions,
|
|
41
|
+
request?: unknown,
|
|
42
|
+
languages: string[] = [],
|
|
30
43
|
): string | undefined => {
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
if (!isDetectionRequest(request)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
detectLanguageFromRequest(request, languages, detectionOptions) ?? undefined
|
|
50
|
+
);
|
|
33
51
|
};
|
|
34
52
|
/**
|
|
35
53
|
* Register LanguageDetector plugin to i18n instance
|
|
@@ -30,6 +30,8 @@ export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => {
|
|
|
30
30
|
*/
|
|
31
31
|
export const readLanguageFromStorage = (
|
|
32
32
|
detectionOptions?: LanguageDetectorOptions,
|
|
33
|
+
_request?: unknown,
|
|
34
|
+
_languages?: string[],
|
|
33
35
|
): string | undefined => {
|
|
34
36
|
try {
|
|
35
37
|
const options = detectionOptions || {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createI18nRouterNavigation } from '@modern-js/i18n-runtime-extensions/router-navigation';
|
|
2
|
+
import {
|
|
3
|
+
I18nNavigationProvider,
|
|
4
|
+
type I18nRouterAdapter,
|
|
5
|
+
useNativeI18nRouterAdapter,
|
|
6
|
+
} from './routerAdapter';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The seam, and only the seam. The selected-router navigation adapter is a
|
|
10
|
+
* fork subsystem, so it lives in the fork-owned
|
|
11
|
+
* `@modern-js/i18n-runtime-extensions` and is attached here through the
|
|
12
|
+
* `NavigationProvider` extension point this package already exposes. The
|
|
13
|
+
* factory takes this package's own primitives as arguments, so the fork
|
|
14
|
+
* package never imports back into it and the dependency stays one-way.
|
|
15
|
+
*/
|
|
16
|
+
export const { I18nRouterNavigationProvider, useIntegratedRouterAdapter } =
|
|
17
|
+
createI18nRouterNavigation<I18nRouterAdapter>({
|
|
18
|
+
I18nNavigationProvider,
|
|
19
|
+
useNativeI18nRouterAdapter,
|
|
20
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { I18nNavigationContext } from '@modern-js/plugin-i18n/runtime/contexts';
|
|
1
2
|
import { isBrowser, RuntimeContext } from '@modern-js/runtime';
|
|
2
3
|
import { InternalRuntimeContext } from '@modern-js/runtime/context';
|
|
3
4
|
import type React from 'react';
|
|
4
|
-
import {
|
|
5
|
+
import { useContext } from 'react';
|
|
5
6
|
|
|
6
7
|
interface I18nRouterLocation {
|
|
7
8
|
pathname: string;
|
|
@@ -44,16 +45,6 @@ export interface I18nRouterAdapter {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const navigationContextKey = Symbol.for(
|
|
48
|
-
'@modern-js/plugin-i18n/runtime/I18nNavigationContext',
|
|
49
|
-
);
|
|
50
|
-
// Only the React context identity is shared. Its values remain provider-owned.
|
|
51
|
-
const contextStore = globalThis as typeof globalThis & {
|
|
52
|
-
[navigationContextKey]?: React.Context<I18nRouterAdapter | null>;
|
|
53
|
-
};
|
|
54
|
-
const I18nNavigationContext = (contextStore[navigationContextKey] ??=
|
|
55
|
-
createContext<I18nRouterAdapter | null>(null));
|
|
56
|
-
|
|
57
48
|
export const I18nNavigationProvider = ({
|
|
58
49
|
children,
|
|
59
50
|
value,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Upstream-owned runtime modules take the i18n contexts from this fork-owned
|
|
2
|
+
// module, which reaches them through the shared
|
|
3
|
+
// `@modern-js/plugin-i18n/runtime/contexts` request. That keeps the fork
|
|
4
|
+
// import edge out of the upstream-owned files.
|
|
5
|
+
export {
|
|
6
|
+
ModernI18nContext,
|
|
7
|
+
ReactI18nextProviderContext,
|
|
8
|
+
} from '@modern-js/plugin-i18n/runtime/contexts';
|
package/src/shared/detection.ts
CHANGED
|
@@ -8,14 +8,13 @@ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
|
|
|
8
8
|
* Detect language from request using the same detection logic as i18next
|
|
9
9
|
* This ensures consistency between server-side and client-side detection
|
|
10
10
|
*/
|
|
11
|
-
function detectLanguageFromRequest(
|
|
11
|
+
export function detectLanguageFromRequest(
|
|
12
12
|
req: {
|
|
13
13
|
url: string;
|
|
14
14
|
headers:
|
|
15
|
-
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| Headers;
|
|
15
|
+
| Headers
|
|
16
|
+
| { get: (name: string) => string | null }
|
|
17
|
+
| Record<string, string | string[] | undefined>;
|
|
19
18
|
},
|
|
20
19
|
languages: string[],
|
|
21
20
|
detectionOptions?: LanguageDetectorOptions,
|
|
@@ -39,11 +38,21 @@ function detectLanguageFromRequest(
|
|
|
39
38
|
order.length > 0 ? order : ['querystring', 'cookie', 'header'];
|
|
40
39
|
|
|
41
40
|
// Helper to get header value
|
|
41
|
+
// req.headers is a Fetch API Headers instance when the caller passes the raw
|
|
42
|
+
// Request (e.g. ssrContext.request.raw), but modern.js's own SSR request object
|
|
43
|
+
// (ssrContext.request) carries headers as a plain, lower-cased key/value object.
|
|
42
44
|
const getHeader = (name: string): string | null => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
const headers = req.headers;
|
|
46
|
+
if (typeof (headers as Headers)?.get === 'function') {
|
|
47
|
+
return (headers as Headers).get(name);
|
|
48
|
+
}
|
|
49
|
+
const value = (headers as Record<string, string | string[] | undefined>)[
|
|
50
|
+
name.toLowerCase()
|
|
51
|
+
];
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return value[0] ?? null;
|
|
45
54
|
}
|
|
46
|
-
return
|
|
55
|
+
return value ?? null;
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
// Try each detection method in order
|
|
@@ -94,7 +103,8 @@ function detectLanguageFromRequest(
|
|
|
94
103
|
.map((lang: string) => {
|
|
95
104
|
const [code, q] = lang.trim().split(';');
|
|
96
105
|
return {
|
|
97
|
-
code
|
|
106
|
+
code,
|
|
107
|
+
baseCode: code.split('-')[0],
|
|
98
108
|
quality: q ? parseFloat(q.split('=')[1]) : 1.0,
|
|
99
109
|
};
|
|
100
110
|
})
|
|
@@ -103,12 +113,17 @@ function detectLanguageFromRequest(
|
|
|
103
113
|
b.quality - a.quality,
|
|
104
114
|
);
|
|
105
115
|
|
|
106
|
-
// Find first matching language
|
|
116
|
+
// Find first matching language, preferring an exact tag match
|
|
117
|
+
// (e.g. 'zh-Hant-TW') over the base language code (e.g. 'zh')
|
|
107
118
|
for (const lang of languagesList) {
|
|
108
119
|
if (languages.length === 0 || languages.includes(lang.code)) {
|
|
109
120
|
detectedLang = lang.code;
|
|
110
121
|
break;
|
|
111
122
|
}
|
|
123
|
+
if (languages.includes(lang.baseCode)) {
|
|
124
|
+
detectedLang = lang.baseCode;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
break;
|
|
@@ -122,6 +137,15 @@ function detectLanguageFromRequest(
|
|
|
122
137
|
) {
|
|
123
138
|
return detectedLang;
|
|
124
139
|
}
|
|
140
|
+
|
|
141
|
+
// Match query and cookie values like header values: exact tag first,
|
|
142
|
+
// then the supported base language (e.g. 'zh-CN' -> 'zh').
|
|
143
|
+
if (detectedLang) {
|
|
144
|
+
const baseLang = detectedLang.split('-')[0];
|
|
145
|
+
if (languages.includes(baseLang)) {
|
|
146
|
+
return baseLang;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
125
149
|
}
|
|
126
150
|
} catch (error) {
|
|
127
151
|
// Silently ignore errors
|
package/rslib.config.mts
DELETED
package/rstest.config.mts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
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: [
|
|
30
|
-
'tests/i18nUtils.test.ts',
|
|
31
|
-
'tests/localisedUrls.test.ts',
|
|
32
|
-
'tests/localisedRouteGeneration.test.ts',
|
|
33
|
-
'tests/mappedUrlStrategyDerivation.test.ts',
|
|
34
|
-
'tests/defaultUrlStrategy.test.ts',
|
|
35
|
-
'tests/urlStrategySerialization.test.ts',
|
|
36
|
-
'tests/backendDefaults.test.ts',
|
|
37
|
-
'tests/redirectPolicy.test.ts',
|
|
38
|
-
'tests/reactI18nextRuntimeBoundary.test.ts',
|
|
39
|
-
'tests/federatedI18nBoundaryResolver.test.tsx',
|
|
40
|
-
],
|
|
41
|
-
extends: commonConfig,
|
|
42
|
-
}),
|
|
43
|
-
withTestPreset({
|
|
44
|
-
name: 'plugin-i18n-client',
|
|
45
|
-
testEnvironment: 'happy-dom',
|
|
46
|
-
include: ['tests/routerAdapter.test.tsx', 'tests/link.test.tsx'],
|
|
47
|
-
extends: commonConfig,
|
|
48
|
-
}),
|
|
49
|
-
],
|
|
50
|
-
};
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { describe, expect, test } from '@rstest/core';
|
|
5
|
-
import { resolveDefaultLocalesDir } from '../src/runtime/i18n/backend/defaults.node';
|
|
6
|
-
|
|
7
|
-
const makeTempDir = (...dirs: string[]): string => {
|
|
8
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-defaults-'));
|
|
9
|
-
for (const dir of dirs) {
|
|
10
|
-
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
11
|
-
}
|
|
12
|
-
return root;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe('node backend default loadPath', () => {
|
|
16
|
-
test('prefers the project-root ./locales convention (the detection root)', () => {
|
|
17
|
-
const root = makeTempDir('locales', 'config/public/locales');
|
|
18
|
-
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('falls back to ./config/public/locales when only it exists', () => {
|
|
22
|
-
const root = makeTempDir('config/public/locales');
|
|
23
|
-
expect(resolveDefaultLocalesDir(root)).toBe('./config/public/locales');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('defaults to ./locales when neither conventional directory exists', () => {
|
|
27
|
-
const root = makeTempDir();
|
|
28
|
-
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
29
|
-
});
|
|
30
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from '@rstest/core';
|
|
2
|
-
import { i18nServerPlugin } from '../src/server';
|
|
3
|
-
import { resolveDefaultUrlStrategy } from '../src/server/defaultUrlStrategy';
|
|
4
|
-
|
|
5
|
-
const localisedUrls = {
|
|
6
|
-
'/about': { en: '/about', cs: '/o-nas' },
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const createRequestContext = (pathname: string) =>
|
|
10
|
-
({
|
|
11
|
-
req: { url: `http://localhost${pathname}` },
|
|
12
|
-
}) as any;
|
|
13
|
-
|
|
14
|
-
const bootServerPlugin = (options: Parameters<typeof i18nServerPlugin>[0]) => {
|
|
15
|
-
const middlewares: any[] = [];
|
|
16
|
-
const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
|
|
17
|
-
let prepare: (() => void) | undefined;
|
|
18
|
-
|
|
19
|
-
i18nServerPlugin(options).setup({
|
|
20
|
-
getServerConfig: () => ({}),
|
|
21
|
-
getServerContext: () => ({ middlewares, routes }),
|
|
22
|
-
onPrepare: (fn: () => void) => {
|
|
23
|
-
prepare = fn;
|
|
24
|
-
},
|
|
25
|
-
} as any);
|
|
26
|
-
|
|
27
|
-
prepare?.();
|
|
28
|
-
return middlewares;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
describe('default URL strategy', () => {
|
|
32
|
-
test('a mapped locale URL config alone produces a strategy', () => {
|
|
33
|
-
const strategy = resolveDefaultUrlStrategy(localisedUrls);
|
|
34
|
-
|
|
35
|
-
expect(strategy).toBeDefined();
|
|
36
|
-
expect(strategy?.localizePathname('/about', 'cs', ['en', 'cs'])).toBe(
|
|
37
|
-
'/cs/o-nas',
|
|
38
|
-
);
|
|
39
|
-
expect(strategy?.canonicalPathname('/cs/o-nas', ['en', 'cs'])).toBe(
|
|
40
|
-
'/about',
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('no map means no derived strategy', () => {
|
|
45
|
-
expect(resolveDefaultUrlStrategy(undefined)).toBeUndefined();
|
|
46
|
-
expect(resolveDefaultUrlStrategy({})).toBeUndefined();
|
|
47
|
-
expect(resolveDefaultUrlStrategy('nope')).toBeUndefined();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('`localisedUrls` boots the server without any extra plugin', () => {
|
|
51
|
-
// Previously this threw "Mapped locale URLs require a URL strategy",
|
|
52
|
-
// which stopped every existing consumer's server from starting.
|
|
53
|
-
const middlewares = bootServerPlugin({
|
|
54
|
-
localeDetection: {
|
|
55
|
-
fallbackLanguage: 'en',
|
|
56
|
-
languages: ['en', 'cs'],
|
|
57
|
-
localePathRedirect: true,
|
|
58
|
-
localisedUrls,
|
|
59
|
-
} as any,
|
|
60
|
-
staticRoutePrefixes: [],
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(middlewares.map(middleware => middleware.name)).toContain(
|
|
64
|
-
'i18n-server-middleware',
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('redirects a canonical path to its mapped locale URL', async () => {
|
|
69
|
-
const middlewares = bootServerPlugin({
|
|
70
|
-
localeDetection: {
|
|
71
|
-
fallbackLanguage: 'en',
|
|
72
|
-
languages: ['en', 'cs'],
|
|
73
|
-
localePathRedirect: true,
|
|
74
|
-
localisedUrls,
|
|
75
|
-
} as any,
|
|
76
|
-
staticRoutePrefixes: [],
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const redirect = middlewares.find(
|
|
80
|
-
middleware => middleware.name === 'i18n-server-middleware',
|
|
81
|
-
);
|
|
82
|
-
const context = createRequestContext('/about');
|
|
83
|
-
context.get = () => 'cs';
|
|
84
|
-
const response = await redirect.handler(context, async () => undefined);
|
|
85
|
-
|
|
86
|
-
expect(response?.headers?.get('location')).toBe('/cs/o-nas');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('an explicit strategy still wins over the derived one', () => {
|
|
90
|
-
const explicit = {
|
|
91
|
-
localizePathname: () => '/explicit',
|
|
92
|
-
canonicalPathname: () => '/explicit',
|
|
93
|
-
};
|
|
94
|
-
const middlewares = bootServerPlugin({
|
|
95
|
-
localeDetection: {
|
|
96
|
-
fallbackLanguage: 'en',
|
|
97
|
-
languages: ['en', 'cs'],
|
|
98
|
-
localePathRedirect: true,
|
|
99
|
-
localisedUrls,
|
|
100
|
-
} as any,
|
|
101
|
-
staticRoutePrefixes: [],
|
|
102
|
-
resolveUrlStrategy: () => explicit,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
expect(middlewares.length).toBeGreaterThan(0);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { RuntimeComponentResolverContext } from '@modern-js/runtime/context';
|
|
2
|
-
import { describe, expect, test } from '@rstest/core';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
5
|
-
import {
|
|
6
|
-
FederatedI18nBoundary,
|
|
7
|
-
type FederatedI18nBoundaryProps,
|
|
8
|
-
} from '../src/runtime/context';
|
|
9
|
-
|
|
10
|
-
const props: FederatedI18nBoundaryProps = {
|
|
11
|
-
defaultNamespace: 'inventory',
|
|
12
|
-
resources: {},
|
|
13
|
-
children: <span>child content</span>,
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
describe('native federation resolver wrapper', () => {
|
|
17
|
-
test('retains the actionable fallback when a resolver does not handle i18n', () => {
|
|
18
|
-
expect(() =>
|
|
19
|
-
renderToStaticMarkup(
|
|
20
|
-
<RuntimeComponentResolverContext.Provider
|
|
21
|
-
value={component => component}
|
|
22
|
-
>
|
|
23
|
-
<FederatedI18nBoundary {...props} />
|
|
24
|
-
</RuntimeComponentResolverContext.Provider>,
|
|
25
|
-
),
|
|
26
|
-
).toThrow(/requires the @modern-js\/i18n-integration runtime plugin/);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('rejects resolution back to the native wrapper', () => {
|
|
30
|
-
expect(() =>
|
|
31
|
-
renderToStaticMarkup(
|
|
32
|
-
<RuntimeComponentResolverContext.Provider
|
|
33
|
-
value={() => FederatedI18nBoundary}
|
|
34
|
-
>
|
|
35
|
-
<FederatedI18nBoundary {...props} />
|
|
36
|
-
</RuntimeComponentResolverContext.Provider>,
|
|
37
|
-
),
|
|
38
|
-
).toThrow(/resolver returned its own wrapper/);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('renders the resolved hook-bearing component with unchanged props', () => {
|
|
42
|
-
let receivedProps: FederatedI18nBoundaryProps | undefined;
|
|
43
|
-
function ResolvedBoundary(value: FederatedI18nBoundaryProps) {
|
|
44
|
-
receivedProps = value;
|
|
45
|
-
const [label] = useState('resolved');
|
|
46
|
-
return (
|
|
47
|
-
<section>
|
|
48
|
-
{label}:{value.children}
|
|
49
|
-
</section>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
const html = renderToStaticMarkup(
|
|
53
|
-
<RuntimeComponentResolverContext.Provider
|
|
54
|
-
value={(component, { name }) =>
|
|
55
|
-
name === 'i18n.FederatedI18nBoundary' ? ResolvedBoundary : component
|
|
56
|
-
}
|
|
57
|
-
>
|
|
58
|
-
<FederatedI18nBoundary {...props} />
|
|
59
|
-
</RuntimeComponentResolverContext.Provider>,
|
|
60
|
-
);
|
|
61
|
-
expect(html).toBe('<section>resolved:<span>child content</span></section>');
|
|
62
|
-
expect(receivedProps?.resources).toBe(props.resources);
|
|
63
|
-
expect(receivedProps?.defaultNamespace).toBe('inventory');
|
|
64
|
-
});
|
|
65
|
-
});
|
package/tests/i18nUtils.test.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { describe, expect, test } from '@rstest/core';
|
|
5
|
-
import {
|
|
6
|
-
FsBackendWithSave,
|
|
7
|
-
resolveFsBackendConstructor,
|
|
8
|
-
} from '../src/runtime/i18n/backend/middleware.node';
|
|
9
|
-
|
|
10
|
-
describe('i18n runtime utils', () => {
|
|
11
|
-
test('normalizes node fs backend CJS and ESM namespace shapes', () => {
|
|
12
|
-
class FakeBackend {}
|
|
13
|
-
|
|
14
|
-
expect(resolveFsBackendConstructor(FakeBackend)).toBe(FakeBackend);
|
|
15
|
-
expect(resolveFsBackendConstructor({ default: FakeBackend })).toBe(
|
|
16
|
-
FakeBackend,
|
|
17
|
-
);
|
|
18
|
-
expect(resolveFsBackendConstructor({ 'module.exports': FakeBackend })).toBe(
|
|
19
|
-
FakeBackend,
|
|
20
|
-
);
|
|
21
|
-
expect(
|
|
22
|
-
resolveFsBackendConstructor({ default: { default: FakeBackend } }),
|
|
23
|
-
).toBe(FakeBackend);
|
|
24
|
-
expect(
|
|
25
|
-
resolveFsBackendConstructor({
|
|
26
|
-
default: { 'module.exports': FakeBackend },
|
|
27
|
-
}),
|
|
28
|
-
).toBe(FakeBackend);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('loads translation resources through the node fs backend', async () => {
|
|
32
|
-
const root = await mkdtemp(join(tmpdir(), 'i18n-backend-'));
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
await mkdir(join(root, 'cs'), { recursive: true });
|
|
36
|
-
await writeFile(
|
|
37
|
-
join(root, 'cs', 'common.json'),
|
|
38
|
-
JSON.stringify({ title: 'Ahoj' }),
|
|
39
|
-
);
|
|
40
|
-
const backend = new FsBackendWithSave(
|
|
41
|
-
{},
|
|
42
|
-
{ loadPath: join(root, '{{lng}}', '{{ns}}.json') },
|
|
43
|
-
{},
|
|
44
|
-
);
|
|
45
|
-
const resources = await new Promise<unknown>((resolve, reject) => {
|
|
46
|
-
backend.read('cs', 'common', (error: Error | null, data: unknown) => {
|
|
47
|
-
if (error) {
|
|
48
|
-
reject(error);
|
|
49
|
-
} else {
|
|
50
|
-
resolve(data);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
expect(resources).toEqual({ title: 'Ahoj' });
|
|
56
|
-
} finally {
|
|
57
|
-
await rm(root, { force: true, recursive: true });
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
});
|