@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.8 → 3.9.3-ultramodern.1
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 +11 -0
- package/dist/cjs/cli/localisedRoutes.js +47 -0
- package/dist/cjs/runtime/context.js +6 -14
- package/dist/cjs/runtime/contexts.js +49 -0
- package/dist/cjs/runtime/core.js +12 -2
- 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/runtime/utils.js +6 -2
- package/dist/cjs/server/defaultUrlStrategy.js +48 -0
- package/dist/cjs/server/index.js +3 -2
- package/dist/cjs/server/mappedUrlStrategy.js +43 -0
- package/dist/cjs/server/redirectPolicy.js +13 -9
- package/dist/cjs/shared/detection.js +118 -2
- package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
- package/dist/cjs/shared/urlStrategy.js +31 -1
- package/dist/esm/cli/index.mjs +11 -0
- package/dist/esm/cli/localisedRoutes.mjs +9 -0
- package/dist/esm/runtime/context.mjs +2 -10
- package/dist/esm/runtime/contexts.mjs +5 -0
- package/dist/esm/runtime/core.mjs +7 -3
- 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/runtime/utils.mjs +6 -2
- package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
- package/dist/esm/server/index.mjs +3 -2
- package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
- package/dist/esm/server/redirectPolicy.mjs +6 -2
- package/dist/esm/shared/detection.mjs +85 -1
- package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
- package/dist/esm/shared/urlStrategy.mjs +7 -0
- package/dist/esm-node/cli/index.mjs +11 -0
- package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
- 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 +7 -3
- 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/runtime/utils.mjs +6 -2
- package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
- package/dist/esm-node/server/index.mjs +3 -2
- package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
- package/dist/esm-node/server/redirectPolicy.mjs +6 -2
- package/dist/esm-node/shared/detection.mjs +85 -1
- package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
- package/dist/esm-node/shared/urlStrategy.mjs +7 -0
- package/dist/types/cli/localisedRoutes.d.ts +19 -0
- 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 +2 -1
- 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/runtime/utils.d.ts +1 -1
- package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
- package/dist/types/server/redirectPolicy.d.ts +1 -1
- package/dist/types/shared/detection.d.ts +11 -1
- package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
- package/dist/types/shared/type.d.ts +10 -0
- package/dist/types/shared/urlStrategy.d.ts +11 -0
- package/package.json +43 -24
- package/src/cli/index.ts +43 -1
- package/src/cli/localisedRoutes.ts +53 -0
- package/src/runtime/context.tsx +6 -31
- package/src/runtime/contexts.ts +16 -0
- package/src/runtime/core.tsx +30 -3
- 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/runtime/utils.ts +13 -4
- package/src/server/defaultUrlStrategy.ts +27 -0
- package/src/server/index.ts +7 -12
- package/src/server/mappedUrlStrategy.ts +10 -0
- package/src/server/redirectPolicy.ts +19 -4
- package/src/shared/detection.ts +34 -10
- package/src/shared/mappedUrlStrategy.ts +59 -0
- package/src/shared/type.ts +10 -0
- package/src/shared/urlStrategy.ts +27 -0
- package/rslib.config.mts +0 -4
- package/rstest.config.mts +0 -46
- package/tests/backendDefaults.test.ts +0 -30
- package/tests/federatedI18nBoundaryResolver.test.tsx +0 -65
- package/tests/i18nUtils.test.ts +0 -60
- package/tests/link.test.tsx +0 -139
- package/tests/localisedUrls.test.ts +0 -176
- package/tests/reactI18nextRuntimeBoundary.test.ts +0 -117
- package/tests/redirectPolicy.test.ts +0 -54
- package/tests/routerAdapter.test.tsx +0 -221
- package/tsconfig.json +0 -15
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { isDefaultLocaleRedirectSkipPath } from '../shared/mappedUrlStrategy';
|
|
2
|
+
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
2
3
|
|
|
3
4
|
interface LocaleRedirectRequest {
|
|
4
5
|
url: string;
|
|
@@ -23,7 +24,20 @@ export const shouldIgnoreRedirect = (
|
|
|
23
24
|
languages: string[] = [],
|
|
24
25
|
): boolean => {
|
|
25
26
|
const remainingPath = stripUrlPathPrefix(pathname, urlPath);
|
|
26
|
-
|
|
27
|
+
// Federation artifacts (`backend-mf-manifest.json`, `backendRemoteEntry.cjs`,
|
|
28
|
+
// `mf-manifest.json`, …) are never pages: redirecting them to a locale
|
|
29
|
+
// prefix hands a remote consumer an HTML document. This is native policy and
|
|
30
|
+
// does not depend on a URL strategy being configured; a strategy can only
|
|
31
|
+
// add exclusions.
|
|
32
|
+
if (isDefaultLocaleRedirectSkipPath(remainingPath, languages)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(
|
|
37
|
+
remainingPath,
|
|
38
|
+
languages,
|
|
39
|
+
)
|
|
40
|
+
) {
|
|
27
41
|
return true;
|
|
28
42
|
}
|
|
29
43
|
if (!ignoreRedirectRoutes) {
|
|
@@ -110,8 +124,9 @@ export const buildLocalizedUrl = (
|
|
|
110
124
|
} else {
|
|
111
125
|
segments.unshift(language);
|
|
112
126
|
}
|
|
113
|
-
const
|
|
114
|
-
|
|
127
|
+
const strategy = asI18nUrlStrategy(urlStrategy);
|
|
128
|
+
const pathname = strategy
|
|
129
|
+
? strategy.localizePathname(remainingPath, language, languages)
|
|
115
130
|
: `/${segments.join('/')}`;
|
|
116
131
|
return `${basePath === '/' ? '' : basePath}${pathname}${url.search}${url.hash}`;
|
|
117
132
|
};
|
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
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalTargetPathname,
|
|
3
|
+
isDefaultLocaleRedirectSkipPath,
|
|
4
|
+
type LocalisedUrlsOption,
|
|
5
|
+
localiseTargetPathname,
|
|
6
|
+
} from '@modern-js/i18n-runtime-extensions';
|
|
7
|
+
import type { I18nUrlStrategy } from './urlStrategy';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The fork's native locale-redirect exclusions (federation artifacts, static
|
|
11
|
+
* and upload prefixes). Re-exported from this fork-owned seam so the native
|
|
12
|
+
* runtime and server redirect paths apply them without importing the
|
|
13
|
+
* extensions package themselves.
|
|
14
|
+
*/
|
|
15
|
+
export { isDefaultLocaleRedirectSkipPath } from '@modern-js/i18n-runtime-extensions';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the URL policy from a mapped-locale-URL configuration.
|
|
19
|
+
*
|
|
20
|
+
* `localeDetection.localisedUrls` is plain data, so unlike a `urlStrategy`
|
|
21
|
+
* object it survives the JSON boundary between `modern.config.ts` and the
|
|
22
|
+
* generated runtime registration. It is therefore the one configuration both
|
|
23
|
+
* sides can rely on for mapped URLs.
|
|
24
|
+
*
|
|
25
|
+
* This is shared rather than server-only on purpose. The server and the client
|
|
26
|
+
* have to agree on the mapping or a hydrated `<Link>` navigates to a path the
|
|
27
|
+
* server will not serve: SSR renders `/cs/obchodni-podminky` while a
|
|
28
|
+
* client-side navigation asks for `/cs/terms-of-service`. The helpers below are
|
|
29
|
+
* pure pathname functions from `@modern-js/i18n-runtime-extensions`, so the
|
|
30
|
+
* client cost is a few hundred bytes of string work and no i18n engine.
|
|
31
|
+
*/
|
|
32
|
+
export const createMappedUrlStrategy = (
|
|
33
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
34
|
+
): I18nUrlStrategy => ({
|
|
35
|
+
localizePathname: (pathname, language, languages) =>
|
|
36
|
+
localiseTargetPathname(pathname, language, [...languages], localisedUrls),
|
|
37
|
+
canonicalPathname: (pathname, languages) =>
|
|
38
|
+
canonicalTargetPathname(pathname, [...languages], localisedUrls),
|
|
39
|
+
shouldSkipRedirect: (pathname, languages) =>
|
|
40
|
+
isDefaultLocaleRedirectSkipPath(pathname, [...languages]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Only a non-empty map needs a derived policy. */
|
|
44
|
+
export const resolveMappedUrlStrategy = (
|
|
45
|
+
localeDetection: unknown,
|
|
46
|
+
): I18nUrlStrategy | undefined => {
|
|
47
|
+
if (!localeDetection || typeof localeDetection !== 'object') {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const localisedUrls = Reflect.get(localeDetection, 'localisedUrls');
|
|
51
|
+
if (
|
|
52
|
+
!localisedUrls ||
|
|
53
|
+
typeof localisedUrls !== 'object' ||
|
|
54
|
+
Object.keys(localisedUrls).length === 0
|
|
55
|
+
) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return createMappedUrlStrategy(localisedUrls as LocalisedUrlsOption);
|
|
59
|
+
};
|
package/src/shared/type.ts
CHANGED
|
@@ -10,6 +10,16 @@ export interface BaseLocaleDetectionOptions {
|
|
|
10
10
|
fallbackLanguage?: string;
|
|
11
11
|
detection?: LanguageDetectorOptions;
|
|
12
12
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
13
|
+
/**
|
|
14
|
+
* Canonical-path -> per-language path map, e.g.
|
|
15
|
+
* `{ '/products': { en: '/products', cs: '/produkty' } }`.
|
|
16
|
+
*
|
|
17
|
+
* Plain data on purpose: the generated runtime registration carries plugin
|
|
18
|
+
* options as JSON, so a map survives the trip where a `urlStrategy` object
|
|
19
|
+
* would lose its methods. The CLI expands file-system routes from it, and
|
|
20
|
+
* the server derives its URL strategy from it, with no extra plugin.
|
|
21
|
+
*/
|
|
22
|
+
localisedUrls?: boolean | Record<string, Record<string, string>>;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
|
|
@@ -9,3 +9,30 @@ export interface I18nUrlStrategy {
|
|
|
9
9
|
/** Adds exclusions; it cannot enable redirects excluded by native rules. */
|
|
10
10
|
shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A URL strategy reaches the runtime through the generated runtime
|
|
15
|
+
* registration, which carries plugin options as JSON. Functions do not survive
|
|
16
|
+
* that, so a configured strategy can arrive as an empty object — which is
|
|
17
|
+
* truthy and would make every call site throw. Anything that is not a complete
|
|
18
|
+
* strategy is treated as absent, and the caller falls back to the built-in
|
|
19
|
+
* language-prefix behaviour.
|
|
20
|
+
*/
|
|
21
|
+
export const asI18nUrlStrategy = (
|
|
22
|
+
candidate: unknown,
|
|
23
|
+
): I18nUrlStrategy | undefined => {
|
|
24
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const strategy = candidate as Partial<I18nUrlStrategy>;
|
|
28
|
+
return typeof strategy.localizePathname === 'function' &&
|
|
29
|
+
typeof strategy.canonicalPathname === 'function'
|
|
30
|
+
? (strategy as I18nUrlStrategy)
|
|
31
|
+
: undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** `true` when a value was configured as a strategy but cannot be used as one. */
|
|
35
|
+
export const isUnusableI18nUrlStrategy = (candidate: unknown): boolean =>
|
|
36
|
+
Boolean(candidate) &&
|
|
37
|
+
typeof candidate === 'object' &&
|
|
38
|
+
asI18nUrlStrategy(candidate) === undefined;
|
package/rslib.config.mts
DELETED
package/rstest.config.mts
DELETED
|
@@ -1,46 +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/backendDefaults.test.ts',
|
|
33
|
-
'tests/redirectPolicy.test.ts',
|
|
34
|
-
'tests/reactI18nextRuntimeBoundary.test.ts',
|
|
35
|
-
'tests/federatedI18nBoundaryResolver.test.tsx',
|
|
36
|
-
],
|
|
37
|
-
extends: commonConfig,
|
|
38
|
-
}),
|
|
39
|
-
withTestPreset({
|
|
40
|
-
name: 'plugin-i18n-client',
|
|
41
|
-
testEnvironment: 'happy-dom',
|
|
42
|
-
include: ['tests/routerAdapter.test.tsx', 'tests/link.test.tsx'],
|
|
43
|
-
extends: commonConfig,
|
|
44
|
-
}),
|
|
45
|
-
],
|
|
46
|
-
};
|
|
@@ -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,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
|
-
});
|
package/tests/link.test.tsx
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import type { I18nInstance } from '../src/runtime/i18n';
|
|
2
|
-
import { detectLanguageWithPriority } from '../src/runtime/i18n/detection';
|
|
3
|
-
import { interpolateRouteParams } from '../src/runtime/Link';
|
|
4
|
-
import { canonicalPath, localizePath } from '../src/runtime/localizedPaths';
|
|
5
|
-
import { buildLocalizedUrl, splitUrlTarget } from '../src/runtime/utils';
|
|
6
|
-
|
|
7
|
-
const languages = ['en', 'cs'];
|
|
8
|
-
|
|
9
|
-
function createI18nInstance(language = 'en'): I18nInstance {
|
|
10
|
-
return {
|
|
11
|
-
language,
|
|
12
|
-
isInitialized: true,
|
|
13
|
-
init: () => Promise.resolve(undefined),
|
|
14
|
-
use: () => {},
|
|
15
|
-
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
|
|
16
|
-
createInstance: () => createI18nInstance(language),
|
|
17
|
-
services: {},
|
|
18
|
-
options: {},
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('splitUrlTarget public compatibility export', () => {
|
|
23
|
-
test('retains the shared suffix splitter', () => {
|
|
24
|
-
expect(splitUrlTarget('/a?q=1#x')).toEqual({
|
|
25
|
-
pathname: '/a',
|
|
26
|
-
search: '?q=1',
|
|
27
|
-
hash: '#x',
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('buildLocalizedUrl suffix handling', () => {
|
|
33
|
-
test('hash-only target keeps the hash and drops the trailing slash', () => {
|
|
34
|
-
expect(buildLocalizedUrl('/#work-with-me', 'en', languages)).toBe(
|
|
35
|
-
'/en#work-with-me',
|
|
36
|
-
);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('query-only target keeps the query', () => {
|
|
40
|
-
expect(buildLocalizedUrl('/products?tag=x', 'en', languages)).toBe(
|
|
41
|
-
'/en/products?tag=x',
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('root path', () => {
|
|
46
|
-
expect(buildLocalizedUrl('/', 'cs', languages)).toBe('/cs');
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('interpolateRouteParams', () => {
|
|
51
|
-
test('interpolates $param and :param segments', () => {
|
|
52
|
-
expect(interpolateRouteParams('/talks/$slug', { slug: 'ai slop' })).toBe(
|
|
53
|
-
'/talks/ai%20slop',
|
|
54
|
-
);
|
|
55
|
-
expect(interpolateRouteParams('/talks/:slug', { slug: 'x' })).toBe(
|
|
56
|
-
'/talks/x',
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('drops missing optional segments', () => {
|
|
61
|
-
expect(interpolateRouteParams('/opt/{-$slug}', {})).toBe('/opt');
|
|
62
|
-
expect(interpolateRouteParams('/opt/:slug?', {})).toBe('/opt');
|
|
63
|
-
expect(interpolateRouteParams('/opt/{-$slug}', { slug: 'v' })).toBe(
|
|
64
|
-
'/opt/v',
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('expands splat params', () => {
|
|
69
|
-
expect(interpolateRouteParams('/files/$', { _splat: 'a/b' })).toBe(
|
|
70
|
-
'/files/a/b',
|
|
71
|
-
);
|
|
72
|
-
expect(interpolateRouteParams('/files/*', { '*': 'a' })).toBe('/files/a');
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('native URL strategy helpers', () => {
|
|
77
|
-
test('preserves suffixes when adding or stripping only a language prefix', () => {
|
|
78
|
-
expect(localizePath('/products?q=1#x', 'cs', { languages })).toBe(
|
|
79
|
-
'/cs/products?q=1#x',
|
|
80
|
-
);
|
|
81
|
-
expect(canonicalPath('/CS/products?q=1#x', { languages })).toBe(
|
|
82
|
-
'/products?q=1#x',
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('language detection priority', () => {
|
|
88
|
-
test('path locale overrides stale SSR data', async () => {
|
|
89
|
-
const previousSsrData = (window as any)._SSR_DATA;
|
|
90
|
-
(window as any)._SSR_DATA = { data: { i18nData: { lng: 'en' } } };
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
await expect(
|
|
94
|
-
detectLanguageWithPriority(createI18nInstance('en'), {
|
|
95
|
-
languages,
|
|
96
|
-
fallbackLanguage: 'en',
|
|
97
|
-
localePathRedirect: true,
|
|
98
|
-
i18nextDetector: false,
|
|
99
|
-
detection: {},
|
|
100
|
-
userInitOptions: {},
|
|
101
|
-
pathname: '/cs/produkty',
|
|
102
|
-
ssrContext: undefined,
|
|
103
|
-
}),
|
|
104
|
-
).resolves.toEqual({ detectedLanguage: 'cs', finalLanguage: 'cs' });
|
|
105
|
-
} finally {
|
|
106
|
-
if (previousSsrData === undefined) {
|
|
107
|
-
delete (window as any)._SSR_DATA;
|
|
108
|
-
} else {
|
|
109
|
-
(window as any)._SSR_DATA = previousSsrData;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('regional SSR language resolves to supported base language', async () => {
|
|
115
|
-
const previousSsrData = (window as any)._SSR_DATA;
|
|
116
|
-
(window as any)._SSR_DATA = { data: { i18nData: { lng: 'en-US' } } };
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
await expect(
|
|
120
|
-
detectLanguageWithPriority(createI18nInstance('cs'), {
|
|
121
|
-
languages,
|
|
122
|
-
fallbackLanguage: 'cs',
|
|
123
|
-
localePathRedirect: true,
|
|
124
|
-
i18nextDetector: false,
|
|
125
|
-
detection: {},
|
|
126
|
-
userInitOptions: {},
|
|
127
|
-
pathname: '/products',
|
|
128
|
-
ssrContext: undefined,
|
|
129
|
-
}),
|
|
130
|
-
).resolves.toEqual({ detectedLanguage: 'en', finalLanguage: 'en' });
|
|
131
|
-
} finally {
|
|
132
|
-
if (previousSsrData === undefined) {
|
|
133
|
-
delete (window as any)._SSR_DATA;
|
|
134
|
-
} else {
|
|
135
|
-
(window as any)._SSR_DATA = previousSsrData;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
});
|