@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
@@ -1,6 +1,16 @@
1
1
  import type React from 'react';
2
2
  import type { BaseLocaleDetectionOptions } from '../shared/type.js';
3
+ import type { I18nUrlStrategy } from '../shared/urlStrategy.js';
3
4
  import type { I18nInstance } from './i18n/index.js';
5
+ export interface I18nLanguageSynchronizationProps {
6
+ i18nInstance: I18nInstance | undefined;
7
+ localePathRedirect: boolean;
8
+ languages: string[];
9
+ pathname: string | undefined;
10
+ prevLangRef: React.MutableRefObject<string>;
11
+ setLang: (language: string) => void;
12
+ children: (synchronizeLanguage: (language: string) => void) => React.ReactNode;
13
+ }
4
14
  interface I18nRootWrapperOptions {
5
15
  entryName?: string;
6
16
  htmlLangAttr: boolean;
@@ -8,7 +18,9 @@ interface I18nRootWrapperOptions {
8
18
  languages: string[];
9
19
  fallbackLanguage: string;
10
20
  ignoreRedirectRoutes?: BaseLocaleDetectionOptions['ignoreRedirectRoutes'];
11
- localisedUrls?: BaseLocaleDetectionOptions['localisedUrls'];
21
+ urlStrategy?: I18nUrlStrategy;
22
+ NavigationProvider?: React.ComponentType<React.PropsWithChildren>;
23
+ LanguageSynchronization?: React.ComponentType<I18nLanguageSynchronizationProps>;
12
24
  getLatestI18nInstance: () => I18nInstance | undefined;
13
25
  getI18nextProvider: () => React.ComponentType<any> | null | undefined;
14
26
  }
@@ -1,27 +1,44 @@
1
1
  import type React from 'react';
2
- type I18nRouterFramework = 'react-router' | 'tanstack' | string;
3
2
  interface I18nRouterLocation {
4
3
  pathname: string;
5
4
  search: string;
6
5
  hash: string;
7
6
  }
8
- interface I18nRouterNavigateOptions {
7
+ type I18nRouterNavigate = (href: string, options?: {
9
8
  replace?: boolean;
10
9
  state?: unknown;
11
- }
12
- type I18nRouterNavigate = (href: string, options?: I18nRouterNavigateOptions) => void | Promise<void>;
10
+ }) => void | Promise<void>;
13
11
  type I18nRouterLink = React.ComponentType<{
14
12
  to: string;
15
13
  children?: React.ReactNode;
16
14
  [key: string]: unknown;
17
15
  }>;
18
- interface I18nRouterAdapter {
19
- framework?: I18nRouterFramework;
16
+ export interface I18nRouterLinkTarget {
17
+ pathname: string;
18
+ href: string;
19
+ search?: Record<string, unknown>;
20
+ hash?: string;
21
+ hashScrollIntoView?: boolean | ScrollIntoViewOptions;
22
+ prefetch?: 'intent' | 'render' | 'viewport' | 'none';
23
+ preload?: unknown;
24
+ }
25
+ export interface I18nRouterAdapter {
26
+ framework?: string;
20
27
  hasRouter: boolean;
21
28
  location: I18nRouterLocation | null;
22
29
  navigate: I18nRouterNavigate | null;
23
30
  Link: I18nRouterLink | null;
24
31
  params: Record<string, string>;
32
+ /** Convert an already resolved target to the selected router's own props. */
33
+ createLinkProps?: (target: I18nRouterLinkTarget) => {
34
+ to: string;
35
+ [key: string]: unknown;
36
+ };
25
37
  }
38
+ export declare const I18nNavigationProvider: ({ children, value, }: React.PropsWithChildren<{
39
+ value: I18nRouterAdapter;
40
+ }>) => React.JSX.Element;
41
+ /** Read native router hooks only when their own provider is present. */
42
+ export declare const useNativeI18nRouterAdapter: () => I18nRouterAdapter;
26
43
  export declare const useI18nRouterAdapter: () => I18nRouterAdapter;
27
44
  export {};
@@ -0,0 +1 @@
1
+ export { ModernI18nContext, ReactI18nextProviderContext, } from './contexts.js';
@@ -1,4 +1,5 @@
1
- import { type LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
1
+ import { type I18nUrlStrategy } from '../shared/urlStrategy.js';
2
+ export { splitUrlTarget } from '@modern-js/runtime-utils/url';
2
3
  export declare const getPathname: (context: {
3
4
  ssrContext?: {
4
5
  request?: {
@@ -15,15 +16,6 @@ export declare const getEntryPath: () => string;
15
16
  * @returns The detected language or fallback language
16
17
  */
17
18
  export declare const getLanguageFromPath: (pathname: string, languages: string[], fallbackLanguage: string) => string;
18
- /**
19
- * Split a link target into its pathname, search and hash parts without
20
- * relying on `new URL` (SSR-hot path; targets are relative).
21
- */
22
- export declare const splitUrlTarget: (target: string) => {
23
- pathname: string;
24
- search: string;
25
- hash: string;
26
- };
27
19
  /**
28
20
  * Helper function to build localized URL
29
21
  * @param target - The language-agnostic target; may include `?search` and `#hash`
@@ -31,7 +23,7 @@ export declare const splitUrlTarget: (target: string) => {
31
23
  * @param languages - Array of supported languages
32
24
  * @returns The localized URL path with search and hash re-appended verbatim
33
25
  */
34
- export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
26
+ export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], urlStrategy?: I18nUrlStrategy) => string;
35
27
  export declare const detectLanguageFromPath: (pathname: string, languages: string[], localePathRedirect: boolean) => {
36
28
  detected: boolean;
37
29
  language?: string;
@@ -39,4 +31,4 @@ export declare const detectLanguageFromPath: (pathname: string, languages: strin
39
31
  /**
40
32
  * Check if the given pathname should ignore automatic locale redirect
41
33
  */
42
- export declare const shouldIgnoreRedirect: (pathname: string, languages: string[], ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)) => boolean;
34
+ export declare const shouldIgnoreRedirect: (pathname: string, languages: string[], ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean), urlStrategy?: I18nUrlStrategy) => boolean;
@@ -0,0 +1,11 @@
1
+ import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
2
+ import type { I18nUrlStrategy } from '../shared/urlStrategy.js';
3
+ /**
4
+ * Mapped locale URLs work without any extra plugin. `localeDetection.localisedUrls`
5
+ * alone is enough: the server derives the URL policy from the map it is given.
6
+ * An explicit `resolveUrlStrategy` still wins, so an integration can replace the
7
+ * policy wholesale.
8
+ */
9
+ export declare const createDefaultUrlStrategy: (localisedUrls?: LocalisedUrlsOption) => I18nUrlStrategy;
10
+ /** A non-empty map is the only configuration that needs a derived policy. */
11
+ export declare const resolveDefaultUrlStrategy: (localisedUrls: unknown) => I18nUrlStrategy | undefined;
@@ -1,9 +1,11 @@
1
1
  import type { ServerPlugin } from '@modern-js/server-runtime';
2
2
  import type { LocaleDetectionOptions } from '../shared/type.js';
3
+ import { type I18nUrlStrategy } from '../shared/urlStrategy.js';
3
4
  export { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
4
5
  export interface I18nPluginOptions {
5
6
  localeDetection: LocaleDetectionOptions;
6
7
  staticRoutePrefixes: string[];
8
+ resolveUrlStrategy?: (entryName: string) => I18nUrlStrategy | undefined;
7
9
  }
8
10
  export declare const i18nServerPlugin: (options: I18nPluginOptions) => ServerPlugin;
9
11
  export default i18nServerPlugin;
@@ -0,0 +1,7 @@
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 { createMappedUrlStrategy, resolveMappedUrlStrategy, } from '../shared/mappedUrlStrategy.js';
@@ -1,22 +1,14 @@
1
- import type { LocaleDetectionOptions } from '../shared/type.js';
1
+ import { type I18nUrlStrategy } from '../shared/urlStrategy.js';
2
2
  interface LocaleRedirectRequest {
3
3
  url: string;
4
- header: () => {
4
+ header?: () => {
5
5
  host?: string;
6
6
  };
7
7
  }
8
- /**
9
- * Check if pathname should ignore automatic locale redirect.
10
- */
11
- export declare const shouldIgnoreRedirect: (pathname: string, urlPath: string, ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)) => boolean;
12
- /**
13
- * Check if pathname is a static or federation resource request.
14
- *
15
- * This includes configured staticRoutePrefixes, the shared default skip policy
16
- * from ADR-0002, and language-prefixed variants such as /en/static/app.js.
17
- */
8
+ export declare const shouldIgnoreRedirect: (pathname: string, urlPath: string, ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean), urlStrategy?: I18nUrlStrategy, languages?: string[]) => boolean;
9
+ /** Native configured/static exclusions remain in effect for every strategy. */
18
10
  export declare const isStaticResourceRequest: (pathname: string, staticRoutePrefixes: string[], languages?: string[]) => boolean;
19
11
  export declare const getLanguageFromPath: (req: LocaleRedirectRequest, urlPath: string, languages: string[]) => string | null;
20
- export declare const buildLocalizedUrl: (req: LocaleRedirectRequest, urlPath: string, language: string, languages: string[], localisedUrls?: LocaleDetectionOptions['localisedUrls']) => string;
12
+ export declare const buildLocalizedUrl: (req: LocaleRedirectRequest, urlPath: string, language: string, languages: string[], urlStrategy?: I18nUrlStrategy) => string;
21
13
  export declare const createLocaleRedirectResponse: (location: string) => Response;
22
14
  export {};
@@ -0,0 +1,27 @@
1
+ import { type LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
2
+ import type { I18nUrlStrategy } from './urlStrategy.js';
3
+ /**
4
+ * The fork's native locale-redirect exclusions (federation artifacts, static
5
+ * and upload prefixes). Re-exported from this fork-owned seam so the native
6
+ * runtime and server redirect paths apply them without importing the
7
+ * extensions package themselves.
8
+ */
9
+ export { isDefaultLocaleRedirectSkipPath } from '@modern-js/i18n-runtime-extensions';
10
+ /**
11
+ * Build the URL policy from a mapped-locale-URL configuration.
12
+ *
13
+ * `localeDetection.localisedUrls` is plain data, so unlike a `urlStrategy`
14
+ * object it survives the JSON boundary between `modern.config.ts` and the
15
+ * generated runtime registration. It is therefore the one configuration both
16
+ * sides can rely on for mapped URLs.
17
+ *
18
+ * This is shared rather than server-only on purpose. The server and the client
19
+ * have to agree on the mapping or a hydrated `<Link>` navigates to a path the
20
+ * server will not serve: SSR renders `/cs/obchodni-podminky` while a
21
+ * client-side navigation asks for `/cs/terms-of-service`. The helpers below are
22
+ * pure pathname functions from `@modern-js/i18n-runtime-extensions`, so the
23
+ * client cost is a few hundred bytes of string work and no i18n engine.
24
+ */
25
+ export declare const createMappedUrlStrategy: (localisedUrls?: LocalisedUrlsOption) => I18nUrlStrategy;
26
+ /** Only a non-empty map needs a derived policy. */
27
+ export declare const resolveMappedUrlStrategy: (localeDetection: unknown) => I18nUrlStrategy | undefined;
@@ -1,4 +1,3 @@
1
- import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
2
1
  import type { LanguageDetectorOptions, Resources } from '../runtime/i18n/instance.js';
3
2
  export interface BaseLocaleDetectionOptions {
4
3
  localePathRedirect?: boolean;
@@ -8,18 +7,15 @@ export interface BaseLocaleDetectionOptions {
8
7
  detection?: LanguageDetectorOptions;
9
8
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
10
9
  /**
11
- * Enables localised pathnames in addition to the locale prefix.
10
+ * Canonical-path -> per-language path map, e.g.
11
+ * `{ '/products': { en: '/products', cs: '/produkty' } }`.
12
12
  *
13
- * - non-empty object: map canonical route paths to every configured
14
- * language; route generation then validates that every localisable route
15
- * path has entries for all configured languages.
16
- * - absent / `false` / `true` / empty object: keep only locale-prefix
17
- * behavior (`/en/about`).
18
- *
19
- * Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
20
- * exactly like upstream Modern.js.
13
+ * Plain data on purpose: the generated runtime registration carries plugin
14
+ * options as JSON, so a map survives the trip where a `urlStrategy` object
15
+ * would lose its methods. The CLI expands file-system routes from it, and
16
+ * the server derives its URL strategy from it, with no extra plugin.
21
17
  */
22
- localisedUrls?: LocalisedUrlsOption;
18
+ localisedUrls?: boolean | Record<string, Record<string, string>>;
23
19
  }
24
20
  export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
25
21
  localeDetectionByEntry?: Record<string, BaseLocaleDetectionOptions>;
@@ -0,0 +1,18 @@
1
+ /** Per-plugin URL policy. Search and hash remain owned by native URL handling. */
2
+ export interface I18nUrlStrategy {
3
+ localizePathname(pathname: string, language: string, languages: readonly string[]): string;
4
+ canonicalPathname(pathname: string, languages: readonly string[]): string;
5
+ /** Adds exclusions; it cannot enable redirects excluded by native rules. */
6
+ shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
7
+ }
8
+ /**
9
+ * A URL strategy reaches the runtime through the generated runtime
10
+ * registration, which carries plugin options as JSON. Functions do not survive
11
+ * that, so a configured strategy can arrive as an empty object — which is
12
+ * truthy and would make every call site throw. Anything that is not a complete
13
+ * strategy is treated as absent, and the caller falls back to the built-in
14
+ * language-prefix behaviour.
15
+ */
16
+ export declare const asI18nUrlStrategy: (candidate: unknown) => I18nUrlStrategy | undefined;
17
+ /** `true` when a value was configured as a strategy but cannot be used as one. */
18
+ export declare const isUnusableI18nUrlStrategy: (candidate: unknown) => boolean;
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "modern",
18
18
  "modern.js"
19
19
  ],
20
- "version": "3.9.0-ultramodern.2",
20
+ "version": "3.9.0-ultramodern.21",
21
21
  "engines": {
22
22
  "node": ">=20"
23
23
  },
@@ -55,6 +55,13 @@
55
55
  },
56
56
  "default": "./dist/esm/runtime/context.mjs"
57
57
  },
58
+ "./runtime/contexts": {
59
+ "types": "./dist/types/runtime/contexts.d.ts",
60
+ "node": {
61
+ "module": "./dist/esm/runtime/contexts.mjs"
62
+ },
63
+ "default": "./dist/esm/runtime/contexts.mjs"
64
+ },
58
65
  "./runtime/no-react-i18next": {
59
66
  "types": "./dist/types/runtime/no-react-i18next.d.ts",
60
67
  "node": {
@@ -80,6 +87,14 @@
80
87
  "./i18n": {
81
88
  "types": "./dist/types/runtime/i18n/index.d.ts",
82
89
  "default": "./dist/esm/runtime/i18n/index.mjs"
90
+ },
91
+ "./config": {
92
+ "types": "./dist/types/shared/utils.d.ts",
93
+ "node": {
94
+ "import": "./dist/esm-node/shared/utils.mjs",
95
+ "require": "./dist/cjs/shared/utils.js"
96
+ },
97
+ "default": "./dist/esm/shared/utils.mjs"
83
98
  }
84
99
  },
85
100
  "typesVersions": {
@@ -96,6 +111,9 @@
96
111
  "runtime/consumer": [
97
112
  "./dist/types/runtime/context.d.ts"
98
113
  ],
114
+ "runtime/contexts": [
115
+ "./dist/types/runtime/contexts.d.ts"
116
+ ],
99
117
  "runtime/no-react-i18next": [
100
118
  "./dist/types/runtime/no-react-i18next.d.ts"
101
119
  ],
@@ -107,26 +125,29 @@
107
125
  ],
108
126
  "i18n": [
109
127
  "./dist/types/runtime/i18n/index.d.ts"
128
+ ],
129
+ "config": [
130
+ "./dist/types/shared/utils.d.ts"
110
131
  ]
111
132
  }
112
133
  },
113
134
  "dependencies": {
114
- "@modern-js/i18n-runtime-extensions": "npm:@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.2",
115
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.9.0-ultramodern.2",
116
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.2",
117
- "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.9.0-ultramodern.2",
118
- "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.2",
119
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.9.0-ultramodern.2",
120
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.0-ultramodern.2",
135
+ "@modern-js/i18n-runtime-extensions": "npm:@bleedingdev/modern-js-i18n-runtime-extensions@3.9.0-ultramodern.21",
136
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.9.0-ultramodern.21",
137
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.21",
138
+ "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.9.0-ultramodern.21",
139
+ "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.21",
140
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.9.0-ultramodern.21",
141
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.0-ultramodern.21",
121
142
  "@swc/helpers": "^0.5.23",
122
143
  "i18next-browser-languagedetector": "^8.2.1",
123
144
  "i18next-chained-backend": "^5.0.6",
124
- "i18next-fs-backend": "^2.6.7",
145
+ "i18next-fs-backend": "^2.6.8",
125
146
  "i18next-http-backend": "^4.0.2",
126
- "i18next-http-middleware": "^3.9.8"
147
+ "i18next-http-middleware": "^3.9.9"
127
148
  },
128
149
  "peerDependencies": {
129
- "@modern-js/runtime": "3.9.0-ultramodern.2",
150
+ "@modern-js/runtime": "3.9.0-ultramodern.21",
130
151
  "i18next": ">=25.7.4",
131
152
  "react": "^19.2.8",
132
153
  "react-dom": "^19.2.8",
@@ -141,16 +162,18 @@
141
162
  }
142
163
  },
143
164
  "devDependencies": {
144
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.2",
145
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.2",
146
- "@rslib/core": "1.0.0",
147
- "@types/node": "^26.4.1",
165
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.21",
166
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.21",
167
+ "@modern-js/runtime-extensions": "npm:@bleedingdev/modern-js-runtime-extensions@3.9.0-ultramodern.21",
168
+ "@rslib/core": "1.0.2",
169
+ "@scripts/rstest-config": "2.66.0",
170
+ "@types/node": "^26.6.2",
148
171
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
149
172
  "esbuild": "^0.28.2",
150
173
  "i18next": "26.4.2",
151
- "react": "^19.2.8",
152
- "react-dom": "^19.2.8",
153
- "react-i18next": "17.0.13",
174
+ "react": "^19.3.0",
175
+ "react-dom": "^19.3.0",
176
+ "react-i18next": "17.0.15",
154
177
  "ts-node": "^10.9.2",
155
178
  "typescript": "^7.0.2"
156
179
  },
@@ -161,6 +184,6 @@
161
184
  "scripts": {
162
185
  "dev": "rslib build --watch",
163
186
  "build": "rslib build",
164
- "test": "rstest --passWithNoTests"
187
+ "test": "rstest"
165
188
  }
166
189
  }
package/rslib.config.mts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { rslibConfig } from '@modern-js/rslib';
2
2
  import { defineConfig } from '@rslib/core';
3
3
 
4
- export default defineConfig(rslibConfig);
4
+ export default defineConfig({
5
+ ...rslibConfig,
6
+ // Keep `@modern-js/plugin-i18n/runtime/contexts` a package self-reference in
7
+ // the output: Module Federation shares the i18n contexts by that request.
8
+ output: {
9
+ externals: ['@modern-js/plugin-i18n/runtime/contexts'],
10
+ },
11
+ });
package/rstest.config.mts CHANGED
@@ -29,13 +29,14 @@ export default {
29
29
  include: [
30
30
  'tests/i18nUtils.test.ts',
31
31
  'tests/localisedUrls.test.ts',
32
- 'tests/localisedUrlRewriteMatrix.fork.test.ts',
33
- 'tests/linkTypes.test.ts',
32
+ 'tests/localisedRouteGeneration.test.ts',
33
+ 'tests/mappedUrlStrategyDerivation.test.ts',
34
+ 'tests/defaultUrlStrategy.test.ts',
35
+ 'tests/urlStrategySerialization.test.ts',
34
36
  'tests/backendDefaults.test.ts',
35
37
  'tests/redirectPolicy.test.ts',
36
- 'tests/peerCohort.test.ts',
37
38
  'tests/reactI18nextRuntimeBoundary.test.ts',
38
- 'tests/federatedI18nBoundary.test.tsx',
39
+ 'tests/federatedI18nBoundaryResolver.test.tsx',
39
40
  ],
40
41
  extends: commonConfig,
41
42
  }),
@@ -45,7 +46,7 @@ export default {
45
46
  include: [
46
47
  'tests/routerAdapter.test.tsx',
47
48
  'tests/link.test.tsx',
48
- 'tests/federatedI18nBoundary.client.test.tsx',
49
+ 'tests/navigation.test.tsx',
49
50
  ],
50
51
  extends: commonConfig,
51
52
  }),
package/src/cli/index.ts CHANGED
@@ -1,19 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type { AppTools, CliPlugin } from '@modern-js/app-tools';
4
- import {
5
- applyLocalisedUrlsToRoutes,
6
- resolveLocalisedUrlsConfig,
7
- } from '@modern-js/i18n-runtime-extensions';
8
4
  import { getPublicDirRoutePrefixes } from '@modern-js/server-core';
9
- import type {
10
- Entrypoint,
11
- NestedRouteForCli,
12
- PageRoute,
13
- } from '@modern-js/types';
5
+ import type { Entrypoint } from '@modern-js/types';
6
+ import { logger } from '@modern-js/utils';
14
7
  import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
8
+ import { isUnusableI18nUrlStrategy } from '../shared/urlStrategy';
15
9
  import { getBackendOptions, getLocaleDetectionOptions } from '../shared/utils';
16
10
  import { applyDetectedBackendPaths, detectLocalesDirectory } from './locales';
11
+ import { applyLocalisedRoutes } from './localisedRoutes';
17
12
  import '../runtime/types';
18
13
 
19
14
  export type TransformRuntimeConfigFn = (
@@ -120,12 +115,28 @@ export const i18nPlugin = (
120
115
  }
121
116
 
122
117
  // Build final config with base config and transformed extended config
123
- const config = {
118
+ const config: Record<string, unknown> = {
124
119
  entryName: entrypoint.entryName,
125
120
  localeDetection: localeDetectionOptions,
126
121
  backend: backendOptions,
127
122
  ...extendedConfig,
128
123
  };
124
+
125
+ // This config is written into the generated runtime registration as
126
+ // JSON, so an object carrying functions arrives at the runtime with none
127
+ // of them. Drop it rather than shipping a broken `{}` that every call
128
+ // site would then have to survive, and say what to configure instead.
129
+ if (isUnusableI18nUrlStrategy(config.urlStrategy)) {
130
+ delete config.urlStrategy;
131
+ logger.warn(
132
+ '[i18n] `urlStrategy` cannot be passed through `modern.config.ts`: ' +
133
+ 'the generated runtime registration carries plugin options as ' +
134
+ 'JSON, which drops its methods. Configure ' +
135
+ '`localeDetection.localisedUrls` instead — the runtime and the ' +
136
+ 'server both derive the URL policy from it — or register a ' +
137
+ 'runtime plugin that supplies the strategy directly.',
138
+ );
139
+ }
129
140
  const { reactI18next } = extendedConfig as { reactI18next?: boolean };
130
141
  const runtimePluginPath =
131
142
  customPlugin?.runtime?.path ||
@@ -144,39 +155,28 @@ export const i18nPlugin = (
144
155
  };
145
156
  });
146
157
 
147
- api.modifyFileSystemRoutes(({ entrypoint, routes }) => {
148
- if (!localeDetection) {
149
- return { entrypoint, routes };
150
- }
151
-
152
- const localeDetectionOptions = getLocaleDetectionOptions(
153
- entrypoint.entryName,
154
- localeDetection,
155
- );
156
- const {
157
- localePathRedirect,
158
- languages = [],
159
- localisedUrls,
160
- } = localeDetectionOptions;
161
-
162
- if (!localePathRedirect || languages.length === 0) {
163
- return { entrypoint, routes };
164
- }
165
-
166
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
167
- if (!localisedUrlsConfig.enabled) {
168
- return { entrypoint, routes };
169
- }
170
-
171
- return {
158
+ // Mapped locale URLs are a property of `localeDetection.localisedUrls`, not
159
+ // of any particular app-tools composition, so the route expansion lives
160
+ // here rather than in an opt-in fork plugin. A bare `appTools()` consumer
161
+ // that declares the map gets `/cs/obchodni-podminky` generated alongside
162
+ // `/cs/terms-of-service`; without this the localized path 404s under SSR.
163
+ //
164
+ // This is deliberately not registered from `@modern-js/i18n-integration`:
165
+ // that package peer-depends on `@modern-js/runtime`, and pulling it into
166
+ // the default app-tools path closes an Nx cycle
167
+ // (app-tools -> app-tools-extensions -> i18n-integration -> runtime -> app-tools).
168
+ // `@modern-js/i18n-runtime-extensions` is already a direct dependency here,
169
+ // so nothing new is added to the graph.
170
+ api.modifyFileSystemRoutes(({ entrypoint, routes }) => ({
171
+ entrypoint,
172
+ routes: applyLocalisedRoutes(
173
+ routes as any,
174
+ localeDetection
175
+ ? getLocaleDetectionOptions(entrypoint.entryName, localeDetection)
176
+ : undefined,
172
177
  entrypoint,
173
- routes: applyLocalisedUrlsToRoutes(
174
- routes as (NestedRouteForCli | PageRoute)[],
175
- languages,
176
- localisedUrlsConfig.map,
177
- ) as (NestedRouteForCli | PageRoute)[],
178
- };
179
- });
178
+ ) as typeof routes,
179
+ }));
180
180
 
181
181
  api._internalServerPlugins(({ plugins }) => {
182
182
  const { serverRoutes, metaName } = api.getAppContext();
@@ -0,0 +1,53 @@
1
+ import {
2
+ applyLocalisedUrlsToRoutes,
3
+ resolveLocalisedUrlsConfig,
4
+ } from '@modern-js/i18n-runtime-extensions';
5
+ import { getEntrypointRoutesOwner } from '@modern-js/runtime/cli';
6
+ import type {
7
+ Entrypoint,
8
+ NestedRouteForCli,
9
+ PageRoute,
10
+ } from '@modern-js/types';
11
+ import type { BaseLocaleDetectionOptions } from '../shared/type';
12
+
13
+ type FileSystemRoutes = (NestedRouteForCli | PageRoute)[];
14
+
15
+ /**
16
+ * Expand file-system routes from `localeDetection.localisedUrls`.
17
+ *
18
+ * Fork-owned: the localised-URL engine lives in
19
+ * `@modern-js/i18n-runtime-extensions`, which upstream-owned files must not
20
+ * import. `cli/index.ts` calls this through a relative import so the seam, not
21
+ * the edge, is what appears in the upstream-shaped file.
22
+ *
23
+ * Returns the routes unchanged unless locale-path redirects are on, languages
24
+ * are declared, and a non-empty map is configured — an upstream-style config
25
+ * with `localePathRedirect` and `languages` but no map keeps plain
26
+ * locale-prefix behaviour.
27
+ */
28
+ export const applyLocalisedRoutes = (
29
+ routes: FileSystemRoutes,
30
+ localeDetection: BaseLocaleDetectionOptions | undefined,
31
+ entrypoint?: Entrypoint & Parameters<typeof getEntrypointRoutesOwner>[0],
32
+ ): FileSystemRoutes => {
33
+ const {
34
+ localePathRedirect,
35
+ languages = [],
36
+ localisedUrls,
37
+ } = localeDetection ?? {};
38
+ const resolved = resolveLocalisedUrlsConfig(localisedUrls);
39
+
40
+ if (!localePathRedirect || !languages.length || !resolved.enabled) {
41
+ return routes;
42
+ }
43
+
44
+ return applyLocalisedUrlsToRoutes(
45
+ routes,
46
+ languages,
47
+ resolved.map,
48
+ entrypoint &&
49
+ getEntrypointRoutesOwner(entrypoint) === '@modern-js/plugin-tanstack'
50
+ ? 'canonical'
51
+ : 'physical',
52
+ ) as FileSystemRoutes;
53
+ };