@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.8 → 3.9.3-ultramodern.1

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 (106) hide show
  1. package/dist/cjs/cli/index.js +11 -0
  2. package/dist/cjs/cli/localisedRoutes.js +47 -0
  3. package/dist/cjs/runtime/context.js +6 -14
  4. package/dist/cjs/runtime/contexts.js +49 -0
  5. package/dist/cjs/runtime/core.js +12 -2
  6. package/dist/cjs/runtime/i18n/detection/middleware.js +1 -1
  7. package/dist/cjs/runtime/i18n/detection/middleware.node.js +10 -1
  8. package/dist/cjs/runtime/i18n/detection/priority.js +1 -1
  9. package/dist/cjs/runtime/navigation.js +48 -0
  10. package/dist/cjs/runtime/routerAdapter.js +3 -5
  11. package/dist/cjs/runtime/sharedContexts.js +43 -0
  12. package/dist/cjs/runtime/utils.js +6 -2
  13. package/dist/cjs/server/defaultUrlStrategy.js +48 -0
  14. package/dist/cjs/server/index.js +3 -2
  15. package/dist/cjs/server/mappedUrlStrategy.js +43 -0
  16. package/dist/cjs/server/redirectPolicy.js +13 -9
  17. package/dist/cjs/shared/detection.js +118 -2
  18. package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
  19. package/dist/cjs/shared/urlStrategy.js +31 -1
  20. package/dist/esm/cli/index.mjs +11 -0
  21. package/dist/esm/cli/localisedRoutes.mjs +9 -0
  22. package/dist/esm/runtime/context.mjs +2 -10
  23. package/dist/esm/runtime/contexts.mjs +5 -0
  24. package/dist/esm/runtime/core.mjs +7 -3
  25. package/dist/esm/runtime/i18n/detection/middleware.mjs +1 -1
  26. package/dist/esm/runtime/i18n/detection/middleware.node.mjs +10 -1
  27. package/dist/esm/runtime/i18n/detection/priority.mjs +1 -1
  28. package/dist/esm/runtime/navigation.mjs +7 -0
  29. package/dist/esm/runtime/routerAdapter.mjs +2 -4
  30. package/dist/esm/runtime/sharedContexts.mjs +1 -0
  31. package/dist/esm/runtime/utils.mjs +6 -2
  32. package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
  33. package/dist/esm/server/index.mjs +3 -2
  34. package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
  35. package/dist/esm/server/redirectPolicy.mjs +6 -2
  36. package/dist/esm/shared/detection.mjs +85 -1
  37. package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
  38. package/dist/esm/shared/urlStrategy.mjs +7 -0
  39. package/dist/esm-node/cli/index.mjs +11 -0
  40. package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
  41. package/dist/esm-node/runtime/context.mjs +2 -10
  42. package/dist/esm-node/runtime/contexts.mjs +6 -0
  43. package/dist/esm-node/runtime/core.mjs +7 -3
  44. package/dist/esm-node/runtime/i18n/detection/middleware.mjs +1 -1
  45. package/dist/esm-node/runtime/i18n/detection/middleware.node.mjs +10 -1
  46. package/dist/esm-node/runtime/i18n/detection/priority.mjs +1 -1
  47. package/dist/esm-node/runtime/navigation.mjs +8 -0
  48. package/dist/esm-node/runtime/routerAdapter.mjs +2 -4
  49. package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
  50. package/dist/esm-node/runtime/utils.mjs +6 -2
  51. package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
  52. package/dist/esm-node/server/index.mjs +3 -2
  53. package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
  54. package/dist/esm-node/server/redirectPolicy.mjs +6 -2
  55. package/dist/esm-node/shared/detection.mjs +85 -1
  56. package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
  57. package/dist/esm-node/shared/urlStrategy.mjs +7 -0
  58. package/dist/types/cli/localisedRoutes.d.ts +19 -0
  59. package/dist/types/runtime/context.d.ts +2 -2
  60. package/dist/types/runtime/contexts.d.ts +6 -0
  61. package/dist/types/runtime/core.d.ts +2 -1
  62. package/dist/types/runtime/i18n/detection/middleware.d.ts +1 -1
  63. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +1 -5
  64. package/dist/types/runtime/navigation.d.ts +10 -0
  65. package/dist/types/runtime/sharedContexts.d.ts +1 -0
  66. package/dist/types/runtime/utils.d.ts +1 -1
  67. package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
  68. package/dist/types/server/index.d.ts +1 -1
  69. package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
  70. package/dist/types/server/redirectPolicy.d.ts +1 -1
  71. package/dist/types/shared/detection.d.ts +11 -1
  72. package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
  73. package/dist/types/shared/type.d.ts +10 -0
  74. package/dist/types/shared/urlStrategy.d.ts +11 -0
  75. package/package.json +43 -24
  76. package/src/cli/index.ts +43 -1
  77. package/src/cli/localisedRoutes.ts +53 -0
  78. package/src/runtime/context.tsx +6 -31
  79. package/src/runtime/contexts.ts +16 -0
  80. package/src/runtime/core.tsx +30 -3
  81. package/src/runtime/i18n/detection/middleware.node.ts +25 -7
  82. package/src/runtime/i18n/detection/middleware.ts +2 -0
  83. package/src/runtime/i18n/detection/priority.ts +2 -0
  84. package/src/runtime/navigation.tsx +20 -0
  85. package/src/runtime/routerAdapter.tsx +2 -11
  86. package/src/runtime/sharedContexts.ts +8 -0
  87. package/src/runtime/utils.ts +13 -4
  88. package/src/server/defaultUrlStrategy.ts +27 -0
  89. package/src/server/index.ts +7 -12
  90. package/src/server/mappedUrlStrategy.ts +10 -0
  91. package/src/server/redirectPolicy.ts +19 -4
  92. package/src/shared/detection.ts +34 -10
  93. package/src/shared/mappedUrlStrategy.ts +59 -0
  94. package/src/shared/type.ts +10 -0
  95. package/src/shared/urlStrategy.ts +27 -0
  96. package/rslib.config.mts +0 -4
  97. package/rstest.config.mts +0 -46
  98. package/tests/backendDefaults.test.ts +0 -30
  99. package/tests/federatedI18nBoundaryResolver.test.tsx +0 -65
  100. package/tests/i18nUtils.test.ts +0 -60
  101. package/tests/link.test.tsx +0 -139
  102. package/tests/localisedUrls.test.ts +0 -176
  103. package/tests/reactI18nextRuntimeBoundary.test.ts +0 -117
  104. package/tests/redirectPolicy.test.ts +0 -54
  105. package/tests/routerAdapter.test.tsx +0 -221
  106. package/tsconfig.json +0 -15
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@bleedingdev/modern-js-plugin-i18n",
3
3
  "description": "A Progressive React Framework for modern web development.",
4
- "homepage": "https://github.com/BleedingDev/ultramodern.js#readme",
4
+ "homepage": "https://github.com/ultramodernjs/ultramodern.js#readme",
5
5
  "bugs": {
6
- "url": "https://github.com/BleedingDev/ultramodern.js/issues"
6
+ "url": "https://github.com/ultramodernjs/ultramodern.js/issues"
7
7
  },
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/BleedingDev/ultramodern.js.git",
10
+ "url": "git+https://github.com/ultramodernjs/ultramodern.js.git",
11
11
  "directory": "packages/runtime/plugin-i18n"
12
12
  },
13
13
  "license": "MIT",
@@ -17,7 +17,7 @@
17
17
  "modern",
18
18
  "modern.js"
19
19
  ],
20
- "version": "3.9.0-ultramodern.8",
20
+ "version": "3.9.3-ultramodern.1",
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": {
@@ -104,6 +111,9 @@
104
111
  "runtime/consumer": [
105
112
  "./dist/types/runtime/context.d.ts"
106
113
  ],
114
+ "runtime/contexts": [
115
+ "./dist/types/runtime/contexts.d.ts"
116
+ ],
107
117
  "runtime/no-react-i18next": [
108
118
  "./dist/types/runtime/no-react-i18next.d.ts"
109
119
  ],
@@ -122,22 +132,23 @@
122
132
  }
123
133
  },
124
134
  "dependencies": {
125
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.9.0-ultramodern.8",
126
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.0-ultramodern.8",
127
- "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.9.0-ultramodern.8",
128
- "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.9.0-ultramodern.8",
129
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.9.0-ultramodern.8",
130
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.0-ultramodern.8",
135
+ "@modern-js/i18n-runtime-extensions": "npm:@bleedingdev/modern-js-i18n-runtime-extensions@3.9.3-ultramodern.1",
136
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.9.3-ultramodern.1",
137
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.9.3-ultramodern.1",
138
+ "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.9.3-ultramodern.1",
139
+ "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.9.3-ultramodern.1",
140
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.9.3-ultramodern.1",
141
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.9.3-ultramodern.1",
131
142
  "@swc/helpers": "^0.5.23",
132
143
  "i18next-browser-languagedetector": "^8.2.1",
133
144
  "i18next-chained-backend": "^5.0.6",
134
- "i18next-fs-backend": "^2.6.7",
145
+ "i18next-fs-backend": "^2.6.8",
135
146
  "i18next-http-backend": "^4.0.2",
136
- "i18next-http-middleware": "^3.9.8"
147
+ "i18next-http-middleware": "^3.9.9"
137
148
  },
138
149
  "peerDependencies": {
139
- "@modern-js/runtime": "3.9.0-ultramodern.8",
140
- "i18next": ">=25.7.4",
150
+ "@modern-js/runtime": "3.9.3-ultramodern.1",
151
+ "i18next": ">=25.10.10",
141
152
  "react": "^19.2.8",
142
153
  "react-dom": "^19.2.8",
143
154
  "react-i18next": ">=15.7.4"
@@ -151,26 +162,34 @@
151
162
  }
152
163
  },
153
164
  "devDependencies": {
154
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.0-ultramodern.8",
155
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.0-ultramodern.8",
156
- "@rslib/core": "1.0.0",
157
- "@types/node": "^26.4.1",
158
- "@typescript/native-preview": "7.0.0-dev.20260707.2",
165
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.9.3-ultramodern.1",
166
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.9.3-ultramodern.1",
167
+ "@modern-js/runtime-extensions": "npm:@bleedingdev/modern-js-runtime-extensions@3.9.3-ultramodern.1",
168
+ "@rslib/core": "1.0.3",
169
+ "@scripts/rstest-config": "2.66.0",
170
+ "@types/node": "^26.6.3",
159
171
  "esbuild": "^0.28.2",
160
172
  "i18next": "26.4.2",
161
- "react": "^19.2.8",
162
- "react-dom": "^19.2.8",
163
- "react-i18next": "17.0.13",
173
+ "react": "^19.3.0",
174
+ "react-dom": "^19.3.0",
175
+ "react-i18next": "17.0.15",
164
176
  "ts-node": "^10.9.2",
165
- "typescript": "^7.0.2"
177
+ "typescript": "7.0.2"
166
178
  },
167
179
  "sideEffects": false,
168
180
  "publishConfig": {
169
181
  "access": "public"
170
182
  },
183
+ "files": [
184
+ "dist",
185
+ "src"
186
+ ],
171
187
  "scripts": {
172
188
  "dev": "rslib build --watch",
173
189
  "build": "rslib build",
174
- "test": "rstest --passWithNoTests"
190
+ "test": "rstest"
191
+ },
192
+ "ultramodern": {
193
+ "sourceName": "@modern-js/plugin-i18n"
175
194
  }
176
195
  }
package/src/cli/index.ts CHANGED
@@ -3,9 +3,12 @@ import path from 'node:path';
3
3
  import type { AppTools, CliPlugin } from '@modern-js/app-tools';
4
4
  import { getPublicDirRoutePrefixes } from '@modern-js/server-core';
5
5
  import type { Entrypoint } from '@modern-js/types';
6
+ import { logger } from '@modern-js/utils';
6
7
  import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
8
+ import { isUnusableI18nUrlStrategy } from '../shared/urlStrategy';
7
9
  import { getBackendOptions, getLocaleDetectionOptions } from '../shared/utils';
8
10
  import { applyDetectedBackendPaths, detectLocalesDirectory } from './locales';
11
+ import { applyLocalisedRoutes } from './localisedRoutes';
9
12
  import '../runtime/types';
10
13
 
11
14
  export type TransformRuntimeConfigFn = (
@@ -112,12 +115,28 @@ export const i18nPlugin = (
112
115
  }
113
116
 
114
117
  // Build final config with base config and transformed extended config
115
- const config = {
118
+ const config: Record<string, unknown> = {
116
119
  entryName: entrypoint.entryName,
117
120
  localeDetection: localeDetectionOptions,
118
121
  backend: backendOptions,
119
122
  ...extendedConfig,
120
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
+ }
121
140
  const { reactI18next } = extendedConfig as { reactI18next?: boolean };
122
141
  const runtimePluginPath =
123
142
  customPlugin?.runtime?.path ||
@@ -136,6 +155,29 @@ export const i18nPlugin = (
136
155
  };
137
156
  });
138
157
 
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,
177
+ entrypoint,
178
+ ) as typeof routes,
179
+ }));
180
+
139
181
  api._internalServerPlugins(({ plugins }) => {
140
182
  const { serverRoutes, metaName } = api.getAppContext();
141
183
  const normalizedConfig = api.getNormalizedConfig();
@@ -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
+ };
@@ -1,12 +1,6 @@
1
1
  import { RuntimeComponentResolverContext } from '@modern-js/runtime/context';
2
2
  import type { ComponentType, FC, ReactNode } from 'react';
3
- import {
4
- createContext,
5
- useCallback,
6
- useContext,
7
- useEffect,
8
- useMemo,
9
- } from 'react';
3
+ import { useCallback, useContext, useEffect, useMemo } from 'react';
10
4
  import type { I18nUrlStrategy } from '../shared/urlStrategy';
11
5
  import {
12
6
  changeModernI18nLanguage,
@@ -19,6 +13,10 @@ import type { I18nInstance } from './i18n';
19
13
  import type { Resources } from './i18n/instance';
20
14
 
21
15
  import { useI18nRouterAdapter } from './routerAdapter';
16
+ import {
17
+ ModernI18nContext,
18
+ ReactI18nextProviderContext,
19
+ } from './sharedContexts';
22
20
 
23
21
  export { getActualI18nextInstance } from './i18n/instance';
24
22
 
@@ -36,30 +34,7 @@ export interface ModernI18nContextValue {
36
34
  synchronizeLanguage?: (newLang: string) => void;
37
35
  }
38
36
 
39
- const modernI18nContextKey = Symbol.for(
40
- '@modern-js/plugin-i18n/runtime/ModernI18nContext',
41
- );
42
- const reactI18nextProviderContextKey = Symbol.for(
43
- '@modern-js/plugin-i18n/runtime/ReactI18nextProviderContext',
44
- );
45
-
46
- type GlobalContextStore<T> = typeof globalThis & {
47
- [key: symbol]: ReturnType<typeof createContext<T>> | undefined;
48
- };
49
-
50
- const getGlobalContext = <T,>(key: symbol, defaultValue: T) => {
51
- const globalStore = globalThis as GlobalContextStore<T>;
52
- globalStore[key] ??= createContext<T>(defaultValue);
53
- return globalStore[key];
54
- };
55
-
56
- export const ModernI18nContext =
57
- getGlobalContext<ModernI18nContextValue | null>(modernI18nContextKey, null);
58
- export const ReactI18nextProviderContext =
59
- getGlobalContext<ComponentType<any> | null>(
60
- reactI18nextProviderContextKey,
61
- null,
62
- );
37
+ export { ModernI18nContext, ReactI18nextProviderContext };
63
38
 
64
39
  export interface ModernI18nProviderProps {
65
40
  children: ReactNode;
@@ -0,0 +1,16 @@
1
+ import type { ComponentType } from 'react';
2
+ import { createContext } from 'react';
3
+ import type { ModernI18nContextValue } from './context';
4
+ import type { I18nRouterAdapter } from './routerAdapter';
5
+
6
+ // Every other i18n runtime module imports these through
7
+ // `@modern-js/plugin-i18n/runtime/contexts` so Module Federation shares one
8
+ // copy of them. Their values stay provider-owned.
9
+ export const ModernI18nContext = createContext<ModernI18nContextValue | null>(
10
+ null,
11
+ );
12
+ export const ReactI18nextProviderContext =
13
+ createContext<ComponentType<any> | null>(null);
14
+ export const I18nNavigationContext = createContext<I18nRouterAdapter | null>(
15
+ null,
16
+ );
@@ -1,11 +1,13 @@
1
1
  import type { RuntimePlugin } from '@modern-js/runtime';
2
2
  import type React from 'react';
3
+ import { resolveMappedUrlStrategy } from '../shared/mappedUrlStrategy';
3
4
  import type {
4
5
  BaseBackendOptions,
5
6
  BaseLocaleDetectionOptions,
6
7
  } from '../shared/type';
7
- import type { I18nUrlStrategy } from '../shared/urlStrategy';
8
+ import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
8
9
  import type { I18nInitOptions, I18nInstance } from './i18n';
10
+ import { I18nRouterNavigationProvider } from './navigation';
9
11
  import {
10
12
  type RuntimeContextWithI18n,
11
13
  setupI18nBeforeRender,
@@ -31,6 +33,10 @@ export type {
31
33
  Resources,
32
34
  TranslateFn,
33
35
  } from './i18n/instance';
36
+ export {
37
+ I18nRouterNavigationProvider,
38
+ useIntegratedRouterAdapter,
39
+ } from './navigation';
34
40
  export type {
35
41
  LoadReactI18nextIntegration,
36
42
  ReactI18nextIntegration,
@@ -86,6 +92,21 @@ export const createI18nPlugin =
86
92
  ignoreRedirectRoutes,
87
93
  } = localeDetection || {};
88
94
  const { enabled: backendEnabled = false } = backend || {};
95
+ // A strategy configured in `modern.config.ts` reaches here through JSON,
96
+ // which drops its methods. Anything unusable is treated as absent so the
97
+ // built-in language-prefix behaviour applies instead of throwing.
98
+ //
99
+ // Falling back to `localeDetection.localisedUrls` makes mapped locale
100
+ // URLs work for a bare `appTools()` consumer: the map is plain data, so
101
+ // it survives the JSON boundary that a `urlStrategy` object does not.
102
+ // The server already derives its policy from the same field, and the two
103
+ // must agree — otherwise SSR renders `/cs/obchodni-podminky` and the
104
+ // hydrated `<Link>` navigates to `/cs/terms-of-service`, which 404s.
105
+ // An explicit, usable `urlStrategy` (supplied by a composing runtime
106
+ // plugin) still wins.
107
+ const resolvedUrlStrategy =
108
+ asI18nUrlStrategy(urlStrategy) ??
109
+ resolveMappedUrlStrategy(localeDetection);
89
110
  let latestI18nInstance: I18nInstance | undefined;
90
111
  let I18nextProvider: React.ComponentType<any> | null;
91
112
 
@@ -120,8 +141,14 @@ export const createI18nPlugin =
120
141
  languages,
121
142
  fallbackLanguage,
122
143
  ignoreRedirectRoutes,
123
- urlStrategy,
124
- NavigationProvider,
144
+ urlStrategy: resolvedUrlStrategy,
145
+ // The selected router, not react-router, is what a localized `<Link>`
146
+ // has to navigate through, and the i18n language has to follow a
147
+ // client-side navigation to a mapped URL. Both need the actual router
148
+ // instance, so a bare `appTools()` consumer gets the integrated
149
+ // adapter by default; a composing runtime plugin can still replace it.
150
+ NavigationProvider:
151
+ NavigationProvider ?? I18nRouterNavigationProvider,
125
152
  LanguageSynchronization,
126
153
  getLatestI18nInstance: () => latestI18nInstance,
127
154
  getI18nextProvider: () => I18nextProvider,
@@ -1,4 +1,5 @@
1
1
  import { LanguageDetector } from 'i18next-http-middleware';
2
+ import { detectLanguageFromRequest } from '../../../shared/detection.js';
2
3
  import type { I18nInstance, LanguageDetectorOptions } from '../instance';
3
4
 
4
5
  type HttpDetectorInit = (
@@ -13,6 +14,20 @@ type HttpDetectorDetect = (
13
14
  detectionOrder?: unknown,
14
15
  ) => string | string[] | undefined;
15
16
 
17
+ type DetectionRequest = Parameters<typeof detectLanguageFromRequest>[0];
18
+
19
+ const isDetectionRequest = (request: unknown): request is DetectionRequest => {
20
+ if (!request || typeof request !== 'object') {
21
+ return false;
22
+ }
23
+
24
+ const { url, headers } = request as {
25
+ url?: unknown;
26
+ headers?: unknown;
27
+ };
28
+ return typeof url === 'string' && !!headers && typeof headers === 'object';
29
+ };
30
+
16
31
  export const cacheUserLanguage = (
17
32
  _i18nInstance: I18nInstance,
18
33
  _language: string,
@@ -21,15 +36,18 @@ export const cacheUserLanguage = (
21
36
  return;
22
37
  };
23
38
 
24
- /**
25
- * Read language directly from storage (localStorage/cookie)
26
- * Not available in Node.js environment, returns undefined
27
- */
28
39
  export const readLanguageFromStorage = (
29
- _detectionOptions?: LanguageDetectorOptions,
40
+ detectionOptions?: LanguageDetectorOptions,
41
+ request?: unknown,
42
+ languages: string[] = [],
30
43
  ): string | undefined => {
31
- // In Node.js environment, storage-based detection is not available
32
- return undefined;
44
+ if (!isDetectionRequest(request)) {
45
+ return undefined;
46
+ }
47
+
48
+ return (
49
+ detectLanguageFromRequest(request, languages, detectionOptions) ?? undefined
50
+ );
33
51
  };
34
52
  /**
35
53
  * Register LanguageDetector plugin to i18n instance
@@ -30,6 +30,8 @@ export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => {
30
30
  */
31
31
  export const readLanguageFromStorage = (
32
32
  detectionOptions?: LanguageDetectorOptions,
33
+ _request?: unknown,
34
+ _languages?: string[],
33
35
  ): string | undefined => {
34
36
  try {
35
37
  const options = detectionOptions || {};
@@ -59,6 +59,8 @@ export const detectLanguageWithPriority = async (
59
59
  localePathRedirect,
60
60
  userInitOptions,
61
61
  ),
62
+ ssrContext?.request,
63
+ languages,
62
64
  );
63
65
  } else {
64
66
  detectedLanguage = await detectLanguageFromI18nextDetector(i18nInstance, {
@@ -0,0 +1,20 @@
1
+ import { createI18nRouterNavigation } from '@modern-js/i18n-runtime-extensions/router-navigation';
2
+ import {
3
+ I18nNavigationProvider,
4
+ type I18nRouterAdapter,
5
+ useNativeI18nRouterAdapter,
6
+ } from './routerAdapter';
7
+
8
+ /**
9
+ * The seam, and only the seam. The selected-router navigation adapter is a
10
+ * fork subsystem, so it lives in the fork-owned
11
+ * `@modern-js/i18n-runtime-extensions` and is attached here through the
12
+ * `NavigationProvider` extension point this package already exposes. The
13
+ * factory takes this package's own primitives as arguments, so the fork
14
+ * package never imports back into it and the dependency stays one-way.
15
+ */
16
+ export const { I18nRouterNavigationProvider, useIntegratedRouterAdapter } =
17
+ createI18nRouterNavigation<I18nRouterAdapter>({
18
+ I18nNavigationProvider,
19
+ useNativeI18nRouterAdapter,
20
+ });
@@ -1,7 +1,8 @@
1
+ import { I18nNavigationContext } from '@modern-js/plugin-i18n/runtime/contexts';
1
2
  import { isBrowser, RuntimeContext } from '@modern-js/runtime';
2
3
  import { InternalRuntimeContext } from '@modern-js/runtime/context';
3
4
  import type React from 'react';
4
- import { createContext, useContext } from 'react';
5
+ import { useContext } from 'react';
5
6
 
6
7
  interface I18nRouterLocation {
7
8
  pathname: string;
@@ -44,16 +45,6 @@ export interface I18nRouterAdapter {
44
45
  };
45
46
  }
46
47
 
47
- const navigationContextKey = Symbol.for(
48
- '@modern-js/plugin-i18n/runtime/I18nNavigationContext',
49
- );
50
- // Only the React context identity is shared. Its values remain provider-owned.
51
- const contextStore = globalThis as typeof globalThis & {
52
- [navigationContextKey]?: React.Context<I18nRouterAdapter | null>;
53
- };
54
- const I18nNavigationContext = (contextStore[navigationContextKey] ??=
55
- createContext<I18nRouterAdapter | null>(null));
56
-
57
48
  export const I18nNavigationProvider = ({
58
49
  children,
59
50
  value,
@@ -0,0 +1,8 @@
1
+ // Upstream-owned runtime modules take the i18n contexts from this fork-owned
2
+ // module, which reaches them through the shared
3
+ // `@modern-js/plugin-i18n/runtime/contexts` request. That keeps the fork
4
+ // import edge out of the upstream-owned files.
5
+ export {
6
+ ModernI18nContext,
7
+ ReactI18nextProviderContext,
8
+ } from '@modern-js/plugin-i18n/runtime/contexts';
@@ -1,7 +1,8 @@
1
1
  import { isBrowser } from '@modern-js/runtime';
2
2
  import { getGlobalBasename } from '@modern-js/runtime/context';
3
3
  import { splitUrlTarget } from '@modern-js/runtime-utils/url';
4
- import type { I18nUrlStrategy } from '../shared/urlStrategy';
4
+ import { isDefaultLocaleRedirectSkipPath } from '../shared/mappedUrlStrategy';
5
+ import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
5
6
 
6
7
  export { splitUrlTarget } from '@modern-js/runtime-utils/url';
7
8
 
@@ -60,8 +61,9 @@ export const buildLocalizedUrl = (
60
61
  urlStrategy?: I18nUrlStrategy,
61
62
  ): string => {
62
63
  const { pathname, search, hash } = splitUrlTarget(target);
63
- if (urlStrategy) {
64
- return `${urlStrategy.localizePathname(pathname, language, languages)}${search}${hash}`;
64
+ const strategy = asI18nUrlStrategy(urlStrategy);
65
+ if (strategy) {
66
+ return `${strategy.localizePathname(pathname, language, languages)}${search}${hash}`;
65
67
  }
66
68
  const segments = pathname.split('/').filter(Boolean);
67
69
  if (
@@ -121,7 +123,14 @@ export const shouldIgnoreRedirect = (
121
123
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
122
124
  urlStrategy?: I18nUrlStrategy,
123
125
  ): boolean => {
124
- if (urlStrategy?.shouldSkipRedirect?.(pathname, languages)) {
126
+ // Same native exclusions as the server: federation artifacts are never
127
+ // locale-redirected, with or without a configured strategy.
128
+ if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
129
+ return true;
130
+ }
131
+ if (
132
+ asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(pathname, languages)
133
+ ) {
125
134
  return true;
126
135
  }
127
136
  if (!ignoreRedirectRoutes) {
@@ -0,0 +1,27 @@
1
+ import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
2
+ import type { I18nUrlStrategy } from '../shared/urlStrategy';
3
+ import { createMappedUrlStrategy } from './mappedUrlStrategy';
4
+
5
+ /**
6
+ * Mapped locale URLs work without any extra plugin. `localeDetection.localisedUrls`
7
+ * alone is enough: the server derives the URL policy from the map it is given.
8
+ * An explicit `resolveUrlStrategy` still wins, so an integration can replace the
9
+ * policy wholesale.
10
+ */
11
+ export const createDefaultUrlStrategy = (
12
+ localisedUrls?: LocalisedUrlsOption,
13
+ ): I18nUrlStrategy => createMappedUrlStrategy(localisedUrls);
14
+
15
+ /** A non-empty map is the only configuration that needs a derived policy. */
16
+ export const resolveDefaultUrlStrategy = (
17
+ localisedUrls: unknown,
18
+ ): I18nUrlStrategy | undefined => {
19
+ if (
20
+ !localisedUrls ||
21
+ typeof localisedUrls !== 'object' ||
22
+ Object.keys(localisedUrls).length === 0
23
+ ) {
24
+ return undefined;
25
+ }
26
+ return createDefaultUrlStrategy(localisedUrls as LocalisedUrlsOption);
27
+ };
@@ -4,9 +4,10 @@ const { languageDetector } = honoPkg;
4
4
 
5
5
  import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
6
6
  import type { LocaleDetectionOptions } from '../shared/type';
7
- import type { I18nUrlStrategy } from '../shared/urlStrategy';
7
+ import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
8
8
  import { getLocaleDetectionOptions } from '../shared/utils.js';
9
9
  import { collectApiPrefixes, matchesApiPrefix } from './apiPrefix.js';
10
+ import { resolveDefaultUrlStrategy } from './defaultUrlStrategy.js';
10
11
  import { convertToHonoLanguageDetectorOptions } from './detectorOptions.js';
11
12
  import {
12
13
  buildLocalizedUrl,
@@ -70,17 +71,11 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
70
71
  entryLocaleDetection,
71
72
  'localisedUrls',
72
73
  );
73
- const urlStrategy = options.resolveUrlStrategy?.(entryName);
74
- if (
75
- legacyLocalisedUrls &&
76
- typeof legacyLocalisedUrls === 'object' &&
77
- Object.keys(legacyLocalisedUrls).length > 0 &&
78
- !urlStrategy
79
- ) {
80
- throw new Error(
81
- 'Mapped locale URLs require a URL strategy. Use the i18n integration server plugin or supply resolveUrlStrategy.',
82
- );
83
- }
74
+ // `localeDetection.localisedUrls` is self-sufficient: when no explicit
75
+ // strategy is supplied the plugin derives one from the map itself.
76
+ const urlStrategy =
77
+ asI18nUrlStrategy(options.resolveUrlStrategy?.(entryName)) ??
78
+ resolveDefaultUrlStrategy(legacyLocalisedUrls);
84
79
  const staticRoutePrefixes = options.staticRoutePrefixes;
85
80
  const originUrlPath = route.urlPath;
86
81
  const urlPath = originUrlPath.endsWith('/')
@@ -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';