@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.
Files changed (59) hide show
  1. package/dist/cjs/cli/index.js +11 -0
  2. package/dist/cjs/cli/localisedRoutes.js +46 -0
  3. package/dist/cjs/runtime/core.js +4 -1
  4. package/dist/cjs/runtime/utils.js +6 -2
  5. package/dist/cjs/server/defaultUrlStrategy.js +48 -0
  6. package/dist/cjs/server/index.js +3 -2
  7. package/dist/cjs/server/mappedUrlStrategy.js +43 -0
  8. package/dist/cjs/server/redirectPolicy.js +13 -9
  9. package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
  10. package/dist/cjs/shared/urlStrategy.js +31 -1
  11. package/dist/esm/cli/index.mjs +11 -0
  12. package/dist/esm/cli/localisedRoutes.mjs +8 -0
  13. package/dist/esm/runtime/core.mjs +4 -1
  14. package/dist/esm/runtime/utils.mjs +6 -2
  15. package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
  16. package/dist/esm/server/index.mjs +3 -2
  17. package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
  18. package/dist/esm/server/redirectPolicy.mjs +6 -2
  19. package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
  20. package/dist/esm/shared/urlStrategy.mjs +7 -0
  21. package/dist/esm-node/cli/index.mjs +11 -0
  22. package/dist/esm-node/cli/localisedRoutes.mjs +9 -0
  23. package/dist/esm-node/runtime/core.mjs +4 -1
  24. package/dist/esm-node/runtime/utils.mjs +6 -2
  25. package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
  26. package/dist/esm-node/server/index.mjs +3 -2
  27. package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
  28. package/dist/esm-node/server/redirectPolicy.mjs +6 -2
  29. package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
  30. package/dist/esm-node/shared/urlStrategy.mjs +7 -0
  31. package/dist/types/cli/localisedRoutes.d.ts +18 -0
  32. package/dist/types/runtime/core.d.ts +1 -1
  33. package/dist/types/runtime/utils.d.ts +1 -1
  34. package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
  35. package/dist/types/server/index.d.ts +1 -1
  36. package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
  37. package/dist/types/server/redirectPolicy.d.ts +1 -1
  38. package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
  39. package/dist/types/shared/type.d.ts +10 -0
  40. package/dist/types/shared/urlStrategy.d.ts +11 -0
  41. package/package.json +11 -10
  42. package/rstest.config.mts +4 -0
  43. package/src/cli/index.ts +42 -1
  44. package/src/cli/localisedRoutes.ts +43 -0
  45. package/src/runtime/core.tsx +18 -2
  46. package/src/runtime/utils.ts +13 -4
  47. package/src/server/defaultUrlStrategy.ts +27 -0
  48. package/src/server/index.ts +7 -12
  49. package/src/server/mappedUrlStrategy.ts +10 -0
  50. package/src/server/redirectPolicy.ts +19 -4
  51. package/src/shared/mappedUrlStrategy.ts +59 -0
  52. package/src/shared/type.ts +10 -0
  53. package/src/shared/urlStrategy.ts +27 -0
  54. package/tests/defaultUrlStrategy.test.ts +107 -0
  55. package/tests/localisedRouteGeneration.test.ts +159 -0
  56. package/tests/localisedUrls.test.ts +21 -4
  57. package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
  58. package/tests/redirectPolicy.test.ts +53 -0
  59. package/tests/urlStrategySerialization.test.ts +122 -0
@@ -1,4 +1,5 @@
1
- import type { I18nUrlStrategy } from '../shared/urlStrategy';
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
- if (urlStrategy?.shouldSkipRedirect?.(remainingPath, languages)) {
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 pathname = urlStrategy
114
- ? urlStrategy.localizePathname(remainingPath, language, languages)
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
+ };
@@ -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
- const modifyRoutes = rstest.fn();
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: modifyRoutes,
67
+ modifyFileSystemRoutes: (fn: any) => {
68
+ handler = fn;
69
+ },
60
70
  } as any);
61
- expect(modifyRoutes).not.toHaveBeenCalled();
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(