@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.7 → 3.9.0-ultramodern.9
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 +46 -0
- package/dist/cjs/runtime/core.js +4 -1
- 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/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 +8 -0
- package/dist/esm/runtime/core.mjs +4 -1
- 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/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 +9 -0
- package/dist/esm-node/runtime/core.mjs +4 -1
- 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/mappedUrlStrategy.mjs +20 -0
- package/dist/esm-node/shared/urlStrategy.mjs +7 -0
- package/dist/types/cli/localisedRoutes.d.ts +18 -0
- package/dist/types/runtime/core.d.ts +1 -1
- 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/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 +11 -10
- package/rstest.config.mts +4 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/localisedRoutes.ts +43 -0
- package/src/runtime/core.tsx +18 -2
- 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/mappedUrlStrategy.ts +59 -0
- package/src/shared/type.ts +10 -0
- package/src/shared/urlStrategy.ts +27 -0
- package/tests/defaultUrlStrategy.test.ts +107 -0
- package/tests/localisedRouteGeneration.test.ts +159 -0
- package/tests/localisedUrls.test.ts +21 -4
- package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
- package/tests/redirectPolicy.test.ts +53 -0
- package/tests/urlStrategySerialization.test.ts +122 -0
|
@@ -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
|
};
|
|
@@ -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;
|
|
@@ -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,159 @@
|
|
|
1
|
+
import type { AppTools, CliPlugin } from '@modern-js/app-tools';
|
|
2
|
+
import { createAsyncHook, createPluginManager } from '@modern-js/plugin';
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
initAppContext,
|
|
6
|
+
initPluginAPI,
|
|
7
|
+
} from '@modern-js/plugin/cli';
|
|
8
|
+
import type { NestedRouteForCli } from '@modern-js/types';
|
|
9
|
+
import { describe, expect, test } from '@rstest/core';
|
|
10
|
+
import { type I18nPluginOptions, i18nPlugin } from '../src/cli';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Localised route generation must reach a bare `appTools()` consumer.
|
|
14
|
+
*
|
|
15
|
+
* The fork used to register it only from `@modern-js/i18n-integration`, which
|
|
16
|
+
* `ultramodernAppTools()` composes but plain `appTools()` does not — so an app
|
|
17
|
+
* declaring `localeDetection.localisedUrls` got a 404 on every mapped path
|
|
18
|
+
* under SSR. These cases deliberately register *only* the native plugin, with
|
|
19
|
+
* no integration plugin in the manager, so a regression back to the opt-in
|
|
20
|
+
* wiring fails here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const createRoute = (
|
|
24
|
+
path: string,
|
|
25
|
+
children?: NestedRouteForCli[],
|
|
26
|
+
): NestedRouteForCli => ({
|
|
27
|
+
id: path,
|
|
28
|
+
path,
|
|
29
|
+
type: 'nested',
|
|
30
|
+
origin: 'file-system',
|
|
31
|
+
routeType: children ? 'layout' : 'page',
|
|
32
|
+
_component: `${path}.tsx`,
|
|
33
|
+
children,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function createNativeOnlyHarness(options: I18nPluginOptions = {}) {
|
|
37
|
+
const manager = createPluginManager();
|
|
38
|
+
const routeHost: CliPlugin<AppTools> = {
|
|
39
|
+
name: 'test-route-host',
|
|
40
|
+
registryHooks: { modifyFileSystemRoutes: createAsyncHook<any>() },
|
|
41
|
+
setup() {
|
|
42
|
+
// Only supplies the hook registry the app-tools router owns.
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
manager.addPlugins([i18nPlugin(options), routeHost]);
|
|
47
|
+
const plugins = manager.getPlugins() as CliPlugin<AppTools>[];
|
|
48
|
+
const context = await createContext<AppTools>({
|
|
49
|
+
appContext: initAppContext({
|
|
50
|
+
packageName: 'plugin-i18n-native-only-test',
|
|
51
|
+
configFile: false,
|
|
52
|
+
command: 'build',
|
|
53
|
+
appDirectory: process.cwd(),
|
|
54
|
+
metaName: 'modern-js',
|
|
55
|
+
plugins,
|
|
56
|
+
}),
|
|
57
|
+
config: {},
|
|
58
|
+
normalizedConfig: {} as any,
|
|
59
|
+
});
|
|
60
|
+
const api = initPluginAPI<AppTools>({ context, pluginManager: manager });
|
|
61
|
+
for (const plugin of plugins) {
|
|
62
|
+
await plugin.setup?.(api);
|
|
63
|
+
}
|
|
64
|
+
return api.getHooks().modifyFileSystemRoutes.call;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entrypoint = { entryName: 'main' } as any;
|
|
68
|
+
|
|
69
|
+
const localisedUrls = {
|
|
70
|
+
'/about': { en: '/about', cs: '/o-nas' },
|
|
71
|
+
'/products/:slug': { en: '/products/:slug', cs: '/produkty/:slug' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('native localised route generation', () => {
|
|
75
|
+
test('expands mapped locale paths without the integration plugin', async () => {
|
|
76
|
+
const modifyRoutes = await createNativeOnlyHarness({
|
|
77
|
+
localeDetection: {
|
|
78
|
+
localePathRedirect: true,
|
|
79
|
+
languages: ['en', 'cs'],
|
|
80
|
+
localisedUrls,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await modifyRoutes({
|
|
85
|
+
entrypoint,
|
|
86
|
+
routes: [
|
|
87
|
+
createRoute(':lang', [
|
|
88
|
+
createRoute('about'),
|
|
89
|
+
createRoute('products/:slug'),
|
|
90
|
+
]),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const paths = result.routes[0].children?.map(
|
|
95
|
+
(route: NestedRouteForCli) => route.path,
|
|
96
|
+
);
|
|
97
|
+
expect(paths).toContain('about');
|
|
98
|
+
expect(paths).toContain('o-nas');
|
|
99
|
+
expect(paths).toContain('products/:slug');
|
|
100
|
+
expect(paths).toContain('produkty/:slug');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('leaves routes untouched when no map is declared', async () => {
|
|
104
|
+
const modifyRoutes = await createNativeOnlyHarness({
|
|
105
|
+
localeDetection: {
|
|
106
|
+
localePathRedirect: true,
|
|
107
|
+
languages: ['en', 'cs'],
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const input = [createRoute(':lang', [createRoute('about')])];
|
|
112
|
+
const result = await modifyRoutes({ entrypoint, routes: input });
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
result.routes[0].children?.map((r: NestedRouteForCli) => r.path),
|
|
116
|
+
).toEqual(['about']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('leaves routes untouched without localePathRedirect', async () => {
|
|
120
|
+
const modifyRoutes = await createNativeOnlyHarness({
|
|
121
|
+
localeDetection: {
|
|
122
|
+
languages: ['en', 'cs'],
|
|
123
|
+
localisedUrls,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const input = [createRoute(':lang', [createRoute('about')])];
|
|
128
|
+
const result = await modifyRoutes({ entrypoint, routes: input });
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
result.routes[0].children?.map((r: NestedRouteForCli) => r.path),
|
|
132
|
+
).toEqual(['about']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('resolves the map from a per-entry locale detection override', async () => {
|
|
136
|
+
const modifyRoutes = await createNativeOnlyHarness({
|
|
137
|
+
localeDetection: {
|
|
138
|
+
localePathRedirect: true,
|
|
139
|
+
languages: ['en'],
|
|
140
|
+
localeDetectionByEntry: {
|
|
141
|
+
main: {
|
|
142
|
+
localePathRedirect: true,
|
|
143
|
+
languages: ['en', 'cs'],
|
|
144
|
+
localisedUrls,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await modifyRoutes({
|
|
151
|
+
entrypoint,
|
|
152
|
+
routes: [createRoute(':lang', [createRoute('about')])],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
result.routes[0].children?.map((r: NestedRouteForCli) => r.path),
|
|
157
|
+
).toContain('o-nas');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -49,16 +49,33 @@ describe('cli modifyFileSystemRoutes', () => {
|
|
|
49
49
|
expect(runtimePlugin?.config.reactI18next).toBe(false);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
test('upstream-style configs without a map keep routes untouched', () => {
|
|
53
|
-
|
|
52
|
+
test('upstream-style configs without a map keep routes untouched', async () => {
|
|
53
|
+
// The hook is registered unconditionally now that route expansion is a
|
|
54
|
+
// native feature; what matters is that a config with no `localisedUrls`
|
|
55
|
+
// map gets its routes back byte-for-byte instead of a locale-expanded tree.
|
|
56
|
+
let handler:
|
|
57
|
+
| ((args: { entrypoint: unknown; routes: unknown[] }) => Promise<{
|
|
58
|
+
routes: unknown[];
|
|
59
|
+
}>)
|
|
60
|
+
| undefined;
|
|
61
|
+
|
|
54
62
|
i18nCliPlugin({
|
|
55
63
|
localeDetection: { localePathRedirect: true, languages: ['en', 'cs'] },
|
|
56
64
|
}).setup({
|
|
57
65
|
_internalRuntimePlugins: () => {},
|
|
58
66
|
_internalServerPlugins: () => {},
|
|
59
|
-
modifyFileSystemRoutes:
|
|
67
|
+
modifyFileSystemRoutes: (fn: any) => {
|
|
68
|
+
handler = fn;
|
|
69
|
+
},
|
|
60
70
|
} as any);
|
|
61
|
-
|
|
71
|
+
|
|
72
|
+
const routes = [{ id: 'about', path: 'about', type: 'nested' }];
|
|
73
|
+
const result = await handler?.({
|
|
74
|
+
entrypoint: { entryName: 'main' },
|
|
75
|
+
routes,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result?.routes).toEqual(routes);
|
|
62
79
|
});
|
|
63
80
|
});
|
|
64
81
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from '@rstest/core';
|
|
2
|
+
import { resolveMappedUrlStrategy } from '../src/shared/mappedUrlStrategy';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mapped locale URLs must work for a bare `appTools()` consumer that only
|
|
6
|
+
* declares `localeDetection.localisedUrls`. The map is plain data, so it
|
|
7
|
+
* survives the JSON boundary a `urlStrategy` object does not; the runtime
|
|
8
|
+
* derives its policy from it, and the server derives the same policy from the
|
|
9
|
+
* same shared module.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const localisedUrls = {
|
|
13
|
+
'/terms-of-service': { en: '/terms-of-service', cs: '/obchodni-podminky' },
|
|
14
|
+
'/products/:slug': { en: '/products/:slug', cs: '/produkty/:slug' },
|
|
15
|
+
};
|
|
16
|
+
const languages = ['en', 'cs'];
|
|
17
|
+
|
|
18
|
+
describe('mapped url strategy derivation', () => {
|
|
19
|
+
test('derives a localizing strategy from localisedUrls', () => {
|
|
20
|
+
const strategy = resolveMappedUrlStrategy({ localisedUrls });
|
|
21
|
+
|
|
22
|
+
expect(strategy).toBeDefined();
|
|
23
|
+
expect(
|
|
24
|
+
strategy?.localizePathname?.('/terms-of-service', 'cs', languages),
|
|
25
|
+
).toBe('/cs/obchodni-podminky');
|
|
26
|
+
expect(
|
|
27
|
+
strategy?.localizePathname?.('/terms-of-service', 'en', languages),
|
|
28
|
+
).toBe('/en/terms-of-service');
|
|
29
|
+
expect(
|
|
30
|
+
strategy?.canonicalPathname?.('/cs/obchodni-podminky', languages),
|
|
31
|
+
).toBe('/terms-of-service');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('round-trips a parameterised mapped route', () => {
|
|
35
|
+
const strategy = resolveMappedUrlStrategy({ localisedUrls });
|
|
36
|
+
const localized = strategy?.localizePathname?.(
|
|
37
|
+
'/products/widget',
|
|
38
|
+
'cs',
|
|
39
|
+
languages,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(localized).toBe('/cs/produkty/widget');
|
|
43
|
+
expect(strategy?.canonicalPathname?.(localized!, languages)).toBe(
|
|
44
|
+
'/products/widget',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('leaves unmapped paths on the plain language prefix', () => {
|
|
49
|
+
const strategy = resolveMappedUrlStrategy({ localisedUrls });
|
|
50
|
+
|
|
51
|
+
expect(strategy?.localizePathname?.('/unmapped', 'cs', languages)).toBe(
|
|
52
|
+
'/cs/unmapped',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('derives nothing without a usable map', () => {
|
|
57
|
+
expect(resolveMappedUrlStrategy(undefined)).toBeUndefined();
|
|
58
|
+
expect(resolveMappedUrlStrategy({})).toBeUndefined();
|
|
59
|
+
expect(resolveMappedUrlStrategy({ localisedUrls: {} })).toBeUndefined();
|
|
60
|
+
expect(resolveMappedUrlStrategy({ localisedUrls: true })).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -6,6 +6,59 @@ import {
|
|
|
6
6
|
} from '../src/server/redirectPolicy';
|
|
7
7
|
|
|
8
8
|
describe('native locale redirect safeguards', () => {
|
|
9
|
+
test('never locale-redirects federation artifacts, with no URL strategy configured', () => {
|
|
10
|
+
// 3.9.0-ultramodern.9 regression: with `localisedUrls: {}` no strategy is
|
|
11
|
+
// derived, and the backend federation manifest was redirected to
|
|
12
|
+
// `/en/backend-mf-manifest.json` (an HTML page) — the Node backend
|
|
13
|
+
// federation proof then read a 404 instead of the manifest.
|
|
14
|
+
const languages = ['en', 'cs'];
|
|
15
|
+
for (const pathname of [
|
|
16
|
+
'/backend-mf-manifest.json',
|
|
17
|
+
'/backendRemoteEntry.cjs',
|
|
18
|
+
'/mf-manifest.json',
|
|
19
|
+
'/mf-stats.json',
|
|
20
|
+
'/remoteEntry.js',
|
|
21
|
+
'/remoteEntry.catalog.js',
|
|
22
|
+
'/backendRemoteEntry.catalog.cjs',
|
|
23
|
+
'/cs/backend-mf-manifest.json',
|
|
24
|
+
]) {
|
|
25
|
+
expect(
|
|
26
|
+
shouldIgnoreServerRedirect(
|
|
27
|
+
pathname,
|
|
28
|
+
'/*',
|
|
29
|
+
undefined,
|
|
30
|
+
undefined,
|
|
31
|
+
languages,
|
|
32
|
+
),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
shouldIgnoreServerRedirect(
|
|
36
|
+
`/app${pathname}`,
|
|
37
|
+
'/app/*',
|
|
38
|
+
undefined,
|
|
39
|
+
undefined,
|
|
40
|
+
languages,
|
|
41
|
+
),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
shouldIgnoreRuntimeRedirect(pathname, languages, undefined, undefined),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
// A page is still redirected.
|
|
48
|
+
expect(
|
|
49
|
+
shouldIgnoreServerRedirect(
|
|
50
|
+
'/products',
|
|
51
|
+
'/*',
|
|
52
|
+
undefined,
|
|
53
|
+
undefined,
|
|
54
|
+
languages,
|
|
55
|
+
),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
expect(
|
|
58
|
+
shouldIgnoreRuntimeRedirect('/products', languages, undefined, undefined),
|
|
59
|
+
).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
9
62
|
test('retains configured ignores across runtime and server entry prefixes', () => {
|
|
10
63
|
const rules = ['/private'];
|
|
11
64
|
expect(
|