@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.
Files changed (127) hide show
  1. package/dist/cjs/cli/index.js +10 -21
  2. package/dist/cjs/cli/localisedRoutes.js +47 -0
  3. package/dist/cjs/runtime/Link.js +19 -32
  4. package/dist/cjs/runtime/context.js +30 -73
  5. package/dist/cjs/runtime/contextHelpers.js +5 -5
  6. package/dist/cjs/runtime/contexts.js +49 -0
  7. package/dist/cjs/runtime/core.js +27 -14
  8. package/dist/cjs/runtime/hooks.js +7 -8
  9. package/dist/cjs/runtime/localizedPaths.js +11 -9
  10. package/dist/cjs/runtime/navigation.js +48 -0
  11. package/dist/cjs/runtime/providerComposition.js +37 -13
  12. package/dist/cjs/runtime/routerAdapter.js +33 -140
  13. package/dist/cjs/runtime/sharedContexts.js +43 -0
  14. package/dist/cjs/runtime/utils.js +21 -19
  15. package/dist/cjs/server/defaultUrlStrategy.js +48 -0
  16. package/dist/cjs/server/index.js +11 -7
  17. package/dist/cjs/server/mappedUrlStrategy.js +43 -0
  18. package/dist/cjs/server/redirectPolicy.js +33 -24
  19. package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
  20. package/dist/cjs/shared/urlStrategy.js +48 -0
  21. package/dist/esm/cli/index.mjs +10 -21
  22. package/dist/esm/cli/localisedRoutes.mjs +9 -0
  23. package/dist/esm/runtime/Link.mjs +19 -32
  24. package/dist/esm/runtime/context.mjs +20 -72
  25. package/dist/esm/runtime/contextHelpers.mjs +5 -5
  26. package/dist/esm/runtime/contexts.mjs +5 -0
  27. package/dist/esm/runtime/core.mjs +13 -6
  28. package/dist/esm/runtime/hooks.mjs +7 -8
  29. package/dist/esm/runtime/localizedPaths.mjs +11 -9
  30. package/dist/esm/runtime/navigation.mjs +7 -0
  31. package/dist/esm/runtime/providerComposition.mjs +38 -14
  32. package/dist/esm/runtime/routerAdapter.mjs +28 -141
  33. package/dist/esm/runtime/sharedContexts.mjs +1 -0
  34. package/dist/esm/runtime/utils.mjs +19 -17
  35. package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
  36. package/dist/esm/server/index.mjs +11 -7
  37. package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
  38. package/dist/esm/server/redirectPolicy.mjs +33 -24
  39. package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
  40. package/dist/esm/shared/urlStrategy.mjs +7 -0
  41. package/dist/esm-node/cli/index.mjs +10 -21
  42. package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
  43. package/dist/esm-node/runtime/Link.mjs +19 -32
  44. package/dist/esm-node/runtime/context.mjs +20 -72
  45. package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
  46. package/dist/esm-node/runtime/contexts.mjs +6 -0
  47. package/dist/esm-node/runtime/core.mjs +13 -6
  48. package/dist/esm-node/runtime/hooks.mjs +7 -8
  49. package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
  50. package/dist/esm-node/runtime/navigation.mjs +8 -0
  51. package/dist/esm-node/runtime/providerComposition.mjs +38 -14
  52. package/dist/esm-node/runtime/routerAdapter.mjs +28 -141
  53. package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
  54. package/dist/esm-node/runtime/utils.mjs +19 -17
  55. package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
  56. package/dist/esm-node/server/index.mjs +11 -7
  57. package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
  58. package/dist/esm-node/server/redirectPolicy.mjs +33 -24
  59. package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
  60. package/dist/esm-node/shared/urlStrategy.mjs +8 -0
  61. package/dist/types/cli/localisedRoutes.d.ts +19 -0
  62. package/dist/types/runtime/context.d.ts +8 -11
  63. package/dist/types/runtime/contextHelpers.d.ts +2 -2
  64. package/dist/types/runtime/contexts.d.ts +6 -0
  65. package/dist/types/runtime/core.d.ts +12 -2
  66. package/dist/types/runtime/hooks.d.ts +4 -5
  67. package/dist/types/runtime/localizedPaths.d.ts +4 -4
  68. package/dist/types/runtime/navigation.d.ts +10 -0
  69. package/dist/types/runtime/providerComposition.d.ts +13 -1
  70. package/dist/types/runtime/routerAdapter.d.ts +23 -6
  71. package/dist/types/runtime/sharedContexts.d.ts +1 -0
  72. package/dist/types/runtime/utils.d.ts +4 -12
  73. package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
  74. package/dist/types/server/index.d.ts +2 -0
  75. package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
  76. package/dist/types/server/redirectPolicy.d.ts +5 -13
  77. package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
  78. package/dist/types/shared/type.d.ts +7 -11
  79. package/dist/types/shared/urlStrategy.d.ts +18 -0
  80. package/package.json +42 -19
  81. package/rslib.config.mts +8 -1
  82. package/rstest.config.mts +6 -5
  83. package/src/cli/index.ts +42 -42
  84. package/src/cli/localisedRoutes.ts +53 -0
  85. package/src/runtime/Link.tsx +20 -40
  86. package/src/runtime/context.tsx +33 -157
  87. package/src/runtime/contextHelpers.ts +7 -5
  88. package/src/runtime/contexts.ts +16 -0
  89. package/src/runtime/core.tsx +53 -6
  90. package/src/runtime/hooks.ts +14 -9
  91. package/src/runtime/i18n/instance.ts +1 -2
  92. package/src/runtime/localizedPaths.ts +24 -19
  93. package/src/runtime/navigation.tsx +20 -0
  94. package/src/runtime/providerComposition.tsx +71 -23
  95. package/src/runtime/routerAdapter.tsx +65 -339
  96. package/src/runtime/sharedContexts.ts +8 -0
  97. package/src/runtime/utils.ts +49 -31
  98. package/src/server/defaultUrlStrategy.ts +27 -0
  99. package/src/server/index.ts +39 -9
  100. package/src/server/mappedUrlStrategy.ts +10 -0
  101. package/src/server/redirectPolicy.ts +85 -70
  102. package/src/shared/mappedUrlStrategy.ts +59 -0
  103. package/src/shared/type.ts +7 -11
  104. package/src/shared/urlStrategy.ts +38 -0
  105. package/tests/backendDefaults.test.ts +2 -23
  106. package/tests/defaultUrlStrategy.test.ts +107 -0
  107. package/tests/federatedI18nBoundaryResolver.test.tsx +65 -0
  108. package/tests/i18nUtils.test.ts +31 -65
  109. package/tests/link.test.tsx +13 -511
  110. package/tests/localisedRouteGeneration.test.ts +200 -0
  111. package/tests/localisedUrls.test.ts +16 -208
  112. package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
  113. package/tests/navigation.test.tsx +403 -0
  114. package/tests/reactI18nextRuntimeBoundary.test.ts +8 -83
  115. package/tests/redirectPolicy.test.ts +89 -27
  116. package/tests/routerAdapter.test.tsx +98 -1171
  117. package/tests/urlStrategySerialization.test.ts +122 -0
  118. package/tsconfig.json +4 -2
  119. package/tests/federatedI18nBoundary.client.test.tsx +0 -129
  120. package/tests/federatedI18nBoundary.test.tsx +0 -132
  121. package/tests/linkTypes.test.ts +0 -59
  122. package/tests/localisedUrlRewriteMatrix.fork.test.ts +0 -147
  123. package/tests/peerCohort.test.ts +0 -25
  124. package/tests/type-fixture/fixture-globals.d.ts +0 -11
  125. package/tests/type-fixture/i18nInstanceTypes.fixture.ts +0 -29
  126. package/tests/type-fixture/linkTypes.fixture.tsx +0 -51
  127. 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
- isDefaultLocaleRedirectSkipPath,
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
- const remainingPath = pathname.startsWith(basePath)
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
- return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
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
- if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
56
- return true;
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
- const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
69
-
70
- return (
71
- pathWithoutLanguage !== pathname &&
72
- matchesStaticRoutePrefix(pathWithoutLanguage)
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 url = new URL(req.url, `http://${req.header().host}`);
82
- const pathname = url.pathname;
83
- const remainingPath = stripUrlPathPrefix(pathname, urlPath);
84
- const segments = remainingPath.split('/').filter(Boolean);
85
- const firstSegment = segments[0];
86
-
87
- if (languages.includes(firstSegment)) {
88
- return firstSegment;
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
- localisedUrls?: LocaleDetectionOptions['localisedUrls'],
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
- const newPathname = localiseTargetPathname(
107
- remainingPath,
108
- language,
109
- languages,
110
- localisedUrls,
111
- );
112
- const suffix = `${url.search}${url.hash}`;
113
- const localizedUrl =
114
- basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
115
-
116
- return localizedUrl;
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
+ };
@@ -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
- * Enables localised pathnames in addition to the locale prefix.
14
+ * Canonical-path -> per-language path map, e.g.
15
+ * `{ '/products': { en: '/products', cs: '/produkty' } }`.
16
16
  *
17
- * - non-empty object: map canonical route paths to every configured
18
- * language; route generation then validates that every localisable route
19
- * path has entries for all configured languages.
20
- * - absent / `false` / `true` / empty object: keep only locale-prefix
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?: LocalisedUrlsOption;
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 { afterEach, describe, expect, test } from '@rstest/core';
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
+ });
@@ -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('node fs backend wrapper extends a resolved constructor', () => {
49
- const backend = new FsBackendWithSave({}, {}, {}) as {
50
- type?: string;
51
- save: (language: string, namespace: string, data: unknown) => void;
52
- };
53
-
54
- expect(backend.type).toBe('backend');
55
- expect(() => backend.save('en', 'translation', {})).not.toThrow();
56
- });
57
-
58
- test('node fs backend defaults follow the detected locales directory', () => {
59
- // The default must match whichever conventional root exists at runtime
60
- // (./locales first, then ./config/public/locales), mirroring the CLI
61
- // plugin's detectLocalesDirectory. Detailed precedence cases live in
62
- // tests/backendDefaults.test.ts.
63
- expect(NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS.loadPath).toBe(
64
- `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`,
65
- );
66
- });
67
-
68
- test('does not poll for backend resources after init', async () => {
69
- const i18nInstance = createBackendI18nInstance();
70
- const init = rstest.fn(async () => {
71
- i18nInstance.isInitialized = true;
72
- });
73
- i18nInstance.init = init as I18nInstance['init'];
74
- const setTimeoutSpy = rstest.spyOn(globalThis, 'setTimeout');
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
  });