@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.2 → 3.9.0-ultramodern.21
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 +10 -21
- package/dist/cjs/cli/localisedRoutes.js +47 -0
- package/dist/cjs/runtime/Link.js +19 -32
- package/dist/cjs/runtime/context.js +30 -73
- package/dist/cjs/runtime/contextHelpers.js +5 -5
- package/dist/cjs/runtime/contexts.js +49 -0
- package/dist/cjs/runtime/core.js +27 -14
- package/dist/cjs/runtime/hooks.js +7 -8
- package/dist/cjs/runtime/localizedPaths.js +11 -9
- package/dist/cjs/runtime/navigation.js +48 -0
- package/dist/cjs/runtime/providerComposition.js +37 -13
- package/dist/cjs/runtime/routerAdapter.js +33 -140
- package/dist/cjs/runtime/sharedContexts.js +43 -0
- package/dist/cjs/runtime/utils.js +21 -19
- package/dist/cjs/server/defaultUrlStrategy.js +48 -0
- package/dist/cjs/server/index.js +11 -7
- package/dist/cjs/server/mappedUrlStrategy.js +43 -0
- package/dist/cjs/server/redirectPolicy.js +33 -24
- package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
- package/dist/cjs/shared/urlStrategy.js +48 -0
- package/dist/esm/cli/index.mjs +10 -21
- package/dist/esm/cli/localisedRoutes.mjs +9 -0
- package/dist/esm/runtime/Link.mjs +19 -32
- package/dist/esm/runtime/context.mjs +20 -72
- package/dist/esm/runtime/contextHelpers.mjs +5 -5
- package/dist/esm/runtime/contexts.mjs +5 -0
- package/dist/esm/runtime/core.mjs +13 -6
- package/dist/esm/runtime/hooks.mjs +7 -8
- package/dist/esm/runtime/localizedPaths.mjs +11 -9
- package/dist/esm/runtime/navigation.mjs +7 -0
- package/dist/esm/runtime/providerComposition.mjs +38 -14
- package/dist/esm/runtime/routerAdapter.mjs +28 -141
- package/dist/esm/runtime/sharedContexts.mjs +1 -0
- package/dist/esm/runtime/utils.mjs +19 -17
- package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
- package/dist/esm/server/index.mjs +11 -7
- package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
- package/dist/esm/server/redirectPolicy.mjs +33 -24
- package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
- package/dist/esm/shared/urlStrategy.mjs +7 -0
- package/dist/esm-node/cli/index.mjs +10 -21
- package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
- package/dist/esm-node/runtime/Link.mjs +19 -32
- package/dist/esm-node/runtime/context.mjs +20 -72
- package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
- package/dist/esm-node/runtime/contexts.mjs +6 -0
- package/dist/esm-node/runtime/core.mjs +13 -6
- package/dist/esm-node/runtime/hooks.mjs +7 -8
- package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
- package/dist/esm-node/runtime/navigation.mjs +8 -0
- package/dist/esm-node/runtime/providerComposition.mjs +38 -14
- package/dist/esm-node/runtime/routerAdapter.mjs +28 -141
- package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
- package/dist/esm-node/runtime/utils.mjs +19 -17
- package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
- package/dist/esm-node/server/index.mjs +11 -7
- package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
- package/dist/esm-node/server/redirectPolicy.mjs +33 -24
- package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
- package/dist/esm-node/shared/urlStrategy.mjs +8 -0
- package/dist/types/cli/localisedRoutes.d.ts +19 -0
- package/dist/types/runtime/context.d.ts +8 -11
- package/dist/types/runtime/contextHelpers.d.ts +2 -2
- package/dist/types/runtime/contexts.d.ts +6 -0
- package/dist/types/runtime/core.d.ts +12 -2
- package/dist/types/runtime/hooks.d.ts +4 -5
- package/dist/types/runtime/localizedPaths.d.ts +4 -4
- package/dist/types/runtime/navigation.d.ts +10 -0
- package/dist/types/runtime/providerComposition.d.ts +13 -1
- package/dist/types/runtime/routerAdapter.d.ts +23 -6
- package/dist/types/runtime/sharedContexts.d.ts +1 -0
- package/dist/types/runtime/utils.d.ts +4 -12
- package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
- package/dist/types/server/redirectPolicy.d.ts +5 -13
- package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
- package/dist/types/shared/type.d.ts +7 -11
- package/dist/types/shared/urlStrategy.d.ts +18 -0
- package/package.json +42 -19
- package/rslib.config.mts +8 -1
- package/rstest.config.mts +6 -5
- package/src/cli/index.ts +42 -42
- package/src/cli/localisedRoutes.ts +53 -0
- package/src/runtime/Link.tsx +20 -40
- package/src/runtime/context.tsx +33 -157
- package/src/runtime/contextHelpers.ts +7 -5
- package/src/runtime/contexts.ts +16 -0
- package/src/runtime/core.tsx +53 -6
- package/src/runtime/hooks.ts +14 -9
- package/src/runtime/i18n/instance.ts +1 -2
- package/src/runtime/localizedPaths.ts +24 -19
- package/src/runtime/navigation.tsx +20 -0
- package/src/runtime/providerComposition.tsx +71 -23
- package/src/runtime/routerAdapter.tsx +65 -339
- package/src/runtime/sharedContexts.ts +8 -0
- package/src/runtime/utils.ts +49 -31
- package/src/server/defaultUrlStrategy.ts +27 -0
- package/src/server/index.ts +39 -9
- package/src/server/mappedUrlStrategy.ts +10 -0
- package/src/server/redirectPolicy.ts +85 -70
- package/src/shared/mappedUrlStrategy.ts +59 -0
- package/src/shared/type.ts +7 -11
- package/src/shared/urlStrategy.ts +38 -0
- package/tests/backendDefaults.test.ts +2 -23
- package/tests/defaultUrlStrategy.test.ts +107 -0
- package/tests/federatedI18nBoundaryResolver.test.tsx +65 -0
- package/tests/i18nUtils.test.ts +31 -65
- package/tests/link.test.tsx +13 -511
- package/tests/localisedRouteGeneration.test.ts +200 -0
- package/tests/localisedUrls.test.ts +16 -208
- package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
- package/tests/navigation.test.tsx +403 -0
- package/tests/reactI18nextRuntimeBoundary.test.ts +8 -83
- package/tests/redirectPolicy.test.ts +89 -27
- package/tests/routerAdapter.test.tsx +98 -1171
- package/tests/urlStrategySerialization.test.ts +122 -0
- package/tsconfig.json +4 -2
- package/tests/federatedI18nBoundary.client.test.tsx +0 -129
- package/tests/federatedI18nBoundary.test.tsx +0 -132
- package/tests/linkTypes.test.ts +0 -59
- package/tests/localisedUrlRewriteMatrix.fork.test.ts +0 -147
- package/tests/peerCohort.test.ts +0 -25
- package/tests/type-fixture/fixture-globals.d.ts +0 -11
- package/tests/type-fixture/i18nInstanceTypes.fixture.ts +0 -29
- package/tests/type-fixture/linkTypes.fixture.tsx +0 -51
- package/tests/type-fixture/tsconfig.json +0 -16
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapped-URL policy moved to `../shared` so the client runtime can derive
|
|
3
|
+
* the same policy the server does — SSR and a hydrated `<Link>` have to agree
|
|
4
|
+
* on the mapping or client navigation targets a path the server will not serve.
|
|
5
|
+
* Re-exported here to keep the server-side import path stable.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
createMappedUrlStrategy,
|
|
9
|
+
resolveMappedUrlStrategy,
|
|
10
|
+
} from '../shared/mappedUrlStrategy';
|
|
@@ -1,76 +1,91 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
localiseTargetPathname,
|
|
4
|
-
matchesPathPrefix,
|
|
5
|
-
shouldSkipLocaleRedirect,
|
|
6
|
-
stripLanguagePrefix,
|
|
7
|
-
} from '@modern-js/i18n-runtime-extensions';
|
|
8
|
-
import type { LocaleDetectionOptions } from '../shared/type';
|
|
1
|
+
import { isDefaultLocaleRedirectSkipPath } from '../shared/mappedUrlStrategy';
|
|
2
|
+
import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
|
|
9
3
|
|
|
10
4
|
interface LocaleRedirectRequest {
|
|
11
5
|
url: string;
|
|
12
|
-
header
|
|
13
|
-
host?: string;
|
|
14
|
-
};
|
|
6
|
+
header?: () => { host?: string };
|
|
15
7
|
}
|
|
16
8
|
|
|
17
9
|
const stripUrlPathPrefix = (pathname: string, urlPath: string): string => {
|
|
18
10
|
const basePath = urlPath.replace('/*', '');
|
|
19
|
-
|
|
20
11
|
if (!basePath || basePath === '/') {
|
|
21
12
|
return pathname;
|
|
22
13
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
? pathname.slice(basePath.length)
|
|
14
|
+
return pathname === basePath || pathname.startsWith(`${basePath}/`)
|
|
15
|
+
? pathname.slice(basePath.length) || '/'
|
|
26
16
|
: pathname;
|
|
27
|
-
|
|
28
|
-
return remainingPath || '/';
|
|
29
17
|
};
|
|
30
18
|
|
|
31
|
-
/**
|
|
32
|
-
* Check if pathname should ignore automatic locale redirect.
|
|
33
|
-
*/
|
|
34
19
|
export const shouldIgnoreRedirect = (
|
|
35
20
|
pathname: string,
|
|
36
21
|
urlPath: string,
|
|
37
22
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
|
|
23
|
+
urlStrategy?: I18nUrlStrategy,
|
|
24
|
+
languages: string[] = [],
|
|
38
25
|
): boolean => {
|
|
39
26
|
const remainingPath = stripUrlPathPrefix(pathname, urlPath);
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (!ignoreRedirectRoutes) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const segments = remainingPath.split('/').filter(Boolean);
|
|
47
|
+
if (
|
|
48
|
+
segments[0] &&
|
|
49
|
+
languages.some(
|
|
50
|
+
language => language.toLowerCase() === segments[0].toLowerCase(),
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
segments.shift();
|
|
54
|
+
}
|
|
55
|
+
const normalizedPath = `/${segments.join('/')}`;
|
|
56
|
+
return typeof ignoreRedirectRoutes === 'function'
|
|
57
|
+
? ignoreRedirectRoutes(normalizedPath)
|
|
58
|
+
: ignoreRedirectRoutes.some(
|
|
59
|
+
pattern =>
|
|
60
|
+
normalizedPath === pattern ||
|
|
61
|
+
normalizedPath.startsWith(`${pattern}/`),
|
|
62
|
+
);
|
|
42
63
|
};
|
|
43
64
|
|
|
44
|
-
/**
|
|
45
|
-
* Check if pathname is a static or federation resource request.
|
|
46
|
-
*
|
|
47
|
-
* This includes configured staticRoutePrefixes, the shared default skip policy
|
|
48
|
-
* from ADR-0002, and language-prefixed variants such as /en/static/app.js.
|
|
49
|
-
*/
|
|
65
|
+
/** Native configured/static exclusions remain in effect for every strategy. */
|
|
50
66
|
export const isStaticResourceRequest = (
|
|
51
67
|
pathname: string,
|
|
52
68
|
staticRoutePrefixes: string[],
|
|
53
69
|
languages: string[] = [],
|
|
54
70
|
): boolean => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const matchesStaticRoutePrefix = (targetPathname: string): boolean =>
|
|
60
|
-
staticRoutePrefixes.some(prefix =>
|
|
61
|
-
matchesPathPrefix(targetPathname, prefix),
|
|
71
|
+
const prefixes = [...staticRoutePrefixes, '/static', '/upload'];
|
|
72
|
+
const matches = (target: string) =>
|
|
73
|
+
prefixes.some(
|
|
74
|
+
prefix => target === prefix || target.startsWith(`${prefix}/`),
|
|
62
75
|
);
|
|
63
|
-
|
|
64
|
-
if (matchesStaticRoutePrefix(pathname)) {
|
|
76
|
+
if (matches(pathname)) {
|
|
65
77
|
return true;
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
79
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
80
|
+
if (
|
|
81
|
+
segments[0] &&
|
|
82
|
+
languages.some(
|
|
83
|
+
language => language.toLowerCase() === segments[0].toLowerCase(),
|
|
84
|
+
)
|
|
85
|
+
) {
|
|
86
|
+
return matches(`/${segments.slice(1).join('/')}`);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
74
89
|
};
|
|
75
90
|
|
|
76
91
|
export const getLanguageFromPath = (
|
|
@@ -78,17 +93,16 @@ export const getLanguageFromPath = (
|
|
|
78
93
|
urlPath: string,
|
|
79
94
|
languages: string[],
|
|
80
95
|
): string | null => {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return null;
|
|
96
|
+
const host = req.header?.().host;
|
|
97
|
+
const url = new URL(req.url, host ? `http://${host}` : undefined);
|
|
98
|
+
const firstSegment = stripUrlPathPrefix(url.pathname, urlPath)
|
|
99
|
+
.split('/')
|
|
100
|
+
.filter(Boolean)[0];
|
|
101
|
+
return (
|
|
102
|
+
languages.find(
|
|
103
|
+
language => language.toLowerCase() === firstSegment?.toLowerCase(),
|
|
104
|
+
) ?? null
|
|
105
|
+
);
|
|
92
106
|
};
|
|
93
107
|
|
|
94
108
|
export const buildLocalizedUrl = (
|
|
@@ -96,24 +110,25 @@ export const buildLocalizedUrl = (
|
|
|
96
110
|
urlPath: string,
|
|
97
111
|
language: string,
|
|
98
112
|
languages: string[],
|
|
99
|
-
|
|
113
|
+
urlStrategy?: I18nUrlStrategy,
|
|
100
114
|
): string => {
|
|
101
115
|
const url = new URL(req.url);
|
|
102
|
-
const pathname = url.pathname;
|
|
103
116
|
const basePath = urlPath.replace('/*', '');
|
|
104
|
-
const remainingPath = stripUrlPathPrefix(pathname, urlPath);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
const remainingPath = stripUrlPathPrefix(url.pathname, urlPath);
|
|
118
|
+
const segments = remainingPath.split('/').filter(Boolean);
|
|
119
|
+
if (
|
|
120
|
+
segments[0] &&
|
|
121
|
+
languages.some(item => item.toLowerCase() === segments[0].toLowerCase())
|
|
122
|
+
) {
|
|
123
|
+
segments[0] = language;
|
|
124
|
+
} else {
|
|
125
|
+
segments.unshift(language);
|
|
126
|
+
}
|
|
127
|
+
const strategy = asI18nUrlStrategy(urlStrategy);
|
|
128
|
+
const pathname = strategy
|
|
129
|
+
? strategy.localizePathname(remainingPath, language, languages)
|
|
130
|
+
: `/${segments.join('/')}`;
|
|
131
|
+
return `${basePath === '/' ? '' : basePath}${pathname}${url.search}${url.hash}`;
|
|
117
132
|
};
|
|
118
133
|
|
|
119
134
|
export const createLocaleRedirectResponse = (location: string): Response =>
|
|
@@ -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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
|
|
2
1
|
import type {
|
|
3
2
|
LanguageDetectorOptions,
|
|
4
3
|
Resources,
|
|
@@ -12,18 +11,15 @@ export interface BaseLocaleDetectionOptions {
|
|
|
12
11
|
detection?: LanguageDetectorOptions;
|
|
13
12
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
14
|
+
* Canonical-path -> per-language path map, e.g.
|
|
15
|
+
* `{ '/products': { en: '/products', cs: '/produkty' } }`.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* behavior (`/en/about`).
|
|
22
|
-
*
|
|
23
|
-
* Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
|
|
24
|
-
* exactly like upstream Modern.js.
|
|
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.
|
|
25
21
|
*/
|
|
26
|
-
localisedUrls?:
|
|
22
|
+
localisedUrls?: boolean | Record<string, Record<string, string>>;
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Per-plugin URL policy. Search and hash remain owned by native URL handling. */
|
|
2
|
+
export interface I18nUrlStrategy {
|
|
3
|
+
localizePathname(
|
|
4
|
+
pathname: string,
|
|
5
|
+
language: string,
|
|
6
|
+
languages: readonly string[],
|
|
7
|
+
): string;
|
|
8
|
+
canonicalPathname(pathname: string, languages: readonly string[]): string;
|
|
9
|
+
/** Adds exclusions; it cannot enable redirects excluded by native rules. */
|
|
10
|
+
shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
|
|
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;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
DEFAULT_I18NEXT_BACKEND_OPTIONS,
|
|
7
|
-
resolveDefaultLocalesDir,
|
|
8
|
-
} from '../src/runtime/i18n/backend/defaults.node';
|
|
4
|
+
import { describe, expect, test } from '@rstest/core';
|
|
5
|
+
import { resolveDefaultLocalesDir } from '../src/runtime/i18n/backend/defaults.node';
|
|
9
6
|
|
|
10
7
|
const makeTempDir = (...dirs: string[]): string => {
|
|
11
8
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-defaults-'));
|
|
@@ -16,12 +13,6 @@ const makeTempDir = (...dirs: string[]): string => {
|
|
|
16
13
|
};
|
|
17
14
|
|
|
18
15
|
describe('node backend default loadPath', () => {
|
|
19
|
-
const originalCwd = process.cwd();
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
process.chdir(originalCwd);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
16
|
test('prefers the project-root ./locales convention (the detection root)', () => {
|
|
26
17
|
const root = makeTempDir('locales', 'config/public/locales');
|
|
27
18
|
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
@@ -36,16 +27,4 @@ describe('node backend default loadPath', () => {
|
|
|
36
27
|
const root = makeTempDir();
|
|
37
28
|
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
38
29
|
});
|
|
39
|
-
|
|
40
|
-
test('DEFAULT_I18NEXT_BACKEND_OPTIONS resolves against the working directory', () => {
|
|
41
|
-
const root = makeTempDir('locales');
|
|
42
|
-
process.chdir(root);
|
|
43
|
-
|
|
44
|
-
expect(DEFAULT_I18NEXT_BACKEND_OPTIONS.loadPath).toBe(
|
|
45
|
-
'./locales/{{lng}}/{{ns}}.json',
|
|
46
|
-
);
|
|
47
|
-
expect(DEFAULT_I18NEXT_BACKEND_OPTIONS.addPath).toBe(
|
|
48
|
-
'./locales/{{lng}}/{{ns}}.json',
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
30
|
});
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
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
CHANGED
|
@@ -1,28 +1,11 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { describe, expect, test } from '@rstest/core';
|
|
2
|
-
import type { I18nInstance } from '../src/runtime/i18n';
|
|
3
|
-
import {
|
|
4
|
-
DEFAULT_I18NEXT_BACKEND_OPTIONS as NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS,
|
|
5
|
-
resolveDefaultLocalesDir,
|
|
6
|
-
} from '../src/runtime/i18n/backend/defaults.node';
|
|
7
5
|
import {
|
|
8
6
|
FsBackendWithSave,
|
|
9
7
|
resolveFsBackendConstructor,
|
|
10
8
|
} from '../src/runtime/i18n/backend/middleware.node';
|
|
11
|
-
import { initializeI18nInstance } from '../src/runtime/i18n/utils';
|
|
12
|
-
|
|
13
|
-
function createBackendI18nInstance(): I18nInstance {
|
|
14
|
-
return {
|
|
15
|
-
language: 'en',
|
|
16
|
-
isInitialized: false,
|
|
17
|
-
init: async () => undefined,
|
|
18
|
-
use: () => {},
|
|
19
|
-
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
|
|
20
|
-
options: {},
|
|
21
|
-
store: {
|
|
22
|
-
data: {},
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
9
|
|
|
27
10
|
describe('i18n runtime utils', () => {
|
|
28
11
|
test('normalizes node fs backend CJS and ESM namespace shapes', () => {
|
|
@@ -45,50 +28,33 @@ describe('i18n runtime utils', () => {
|
|
|
45
28
|
).toBe(FakeBackend);
|
|
46
29
|
});
|
|
47
30
|
|
|
48
|
-
test('
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await initializeI18nInstance(
|
|
77
|
-
i18nInstance,
|
|
78
|
-
'cs',
|
|
79
|
-
'en',
|
|
80
|
-
['en', 'cs'],
|
|
81
|
-
{},
|
|
82
|
-
{
|
|
83
|
-
enabled: true,
|
|
84
|
-
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
|
85
|
-
},
|
|
86
|
-
{},
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
expect(init).toHaveBeenCalledTimes(1);
|
|
90
|
-
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
91
|
-
|
|
92
|
-
setTimeoutSpy.mockRestore();
|
|
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
|
+
}
|
|
93
59
|
});
|
|
94
60
|
});
|