@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.98 → 3.4.0-ultramodern.0

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 (98) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +17 -64
  3. package/dist/cjs/cli/locales.js +132 -0
  4. package/dist/cjs/runtime/I18nLink.js +17 -20
  5. package/dist/cjs/runtime/Link.js +264 -0
  6. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  7. package/dist/cjs/runtime/context.js +9 -5
  8. package/dist/cjs/runtime/hooks.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  10. package/dist/cjs/runtime/i18n/backend/defaults.js +20 -11
  11. package/dist/cjs/runtime/i18n/backend/defaults.node.js +79 -10
  12. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  15. package/dist/cjs/runtime/i18n/backend/middleware.node.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  17. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  18. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  21. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  22. package/dist/cjs/runtime/i18n/index.js +9 -5
  23. package/dist/cjs/runtime/i18n/instance.js +17 -13
  24. package/dist/cjs/runtime/i18n/react-i18next.js +12 -8
  25. package/dist/cjs/runtime/i18n/utils.js +9 -5
  26. package/dist/cjs/runtime/index.js +32 -5
  27. package/dist/cjs/runtime/localizedPaths.js +102 -0
  28. package/dist/cjs/runtime/routerAdapter.js +11 -7
  29. package/dist/cjs/runtime/utils.js +31 -17
  30. package/dist/cjs/server/index.js +10 -14
  31. package/dist/cjs/shared/deepMerge.js +12 -8
  32. package/dist/cjs/shared/detection.js +9 -5
  33. package/dist/cjs/shared/localisedUrls.js +148 -34
  34. package/dist/cjs/shared/utils.js +15 -11
  35. package/dist/esm/cli/index.mjs +8 -48
  36. package/dist/esm/cli/locales.mjs +80 -0
  37. package/dist/esm/runtime/I18nLink.mjs +7 -14
  38. package/dist/esm/runtime/Link.mjs +221 -0
  39. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +6 -2
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +56 -5
  42. package/dist/esm/runtime/index.mjs +4 -2
  43. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  44. package/dist/esm/runtime/routerAdapter.mjs +3 -3
  45. package/dist/esm/runtime/utils.mjs +19 -12
  46. package/dist/esm/server/index.mjs +2 -10
  47. package/dist/esm/shared/localisedUrls.mjs +115 -23
  48. package/dist/esm-node/cli/index.mjs +8 -48
  49. package/dist/esm-node/cli/locales.mjs +81 -0
  50. package/dist/esm-node/runtime/I18nLink.mjs +7 -14
  51. package/dist/esm-node/runtime/Link.mjs +222 -0
  52. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  53. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +6 -2
  54. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +56 -5
  55. package/dist/esm-node/runtime/index.mjs +4 -2
  56. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  57. package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
  58. package/dist/esm-node/runtime/utils.mjs +19 -12
  59. package/dist/esm-node/server/index.mjs +2 -10
  60. package/dist/esm-node/shared/localisedUrls.mjs +115 -23
  61. package/dist/types/cli/index.d.ts +1 -0
  62. package/dist/types/cli/locales.d.ts +17 -0
  63. package/dist/types/runtime/I18nLink.d.ts +4 -13
  64. package/dist/types/runtime/Link.d.ts +66 -0
  65. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  66. package/dist/types/runtime/i18n/backend/defaults.d.ts +10 -7
  67. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +13 -4
  68. package/dist/types/runtime/index.d.ts +5 -1
  69. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  70. package/dist/types/runtime/types.d.ts +1 -1
  71. package/dist/types/runtime/utils.d.ts +13 -4
  72. package/dist/types/shared/localisedUrls.d.ts +23 -0
  73. package/dist/types/shared/type.d.ts +27 -5
  74. package/package.json +28 -25
  75. package/rstest.config.mts +7 -2
  76. package/src/cli/index.ts +25 -98
  77. package/src/cli/locales.ts +186 -0
  78. package/src/runtime/I18nLink.tsx +13 -44
  79. package/src/runtime/Link.tsx +430 -0
  80. package/src/runtime/canonicalRoutes.ts +93 -0
  81. package/src/runtime/i18n/backend/defaults.node.ts +112 -7
  82. package/src/runtime/i18n/backend/defaults.ts +20 -18
  83. package/src/runtime/index.tsx +24 -2
  84. package/src/runtime/localizedPaths.ts +107 -0
  85. package/src/runtime/routerAdapter.tsx +4 -5
  86. package/src/runtime/types.ts +1 -1
  87. package/src/runtime/utils.ts +33 -26
  88. package/src/server/index.ts +7 -17
  89. package/src/shared/localisedUrls.ts +256 -26
  90. package/src/shared/type.ts +27 -5
  91. package/tests/backendDefaults.test.ts +51 -0
  92. package/tests/i18nUtils.test.ts +10 -3
  93. package/tests/link.test.tsx +525 -0
  94. package/tests/linkTypes.test.ts +28 -0
  95. package/tests/localisedUrls.test.ts +224 -0
  96. package/tests/routerAdapter.test.tsx +86 -12
  97. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  98. package/tests/type-fixture/tsconfig.json +15 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Canonical (language-agnostic) route map.
3
+ *
4
+ * Empty by default; populated via declaration merging by the generated
5
+ * `register.gen.d.ts` that `@modern-js/plugin-tanstack` emits:
6
+ *
7
+ * ```ts
8
+ * declare module '@modern-js/plugin-i18n/runtime' {
9
+ * interface UltramodernCanonicalRoutes {
10
+ * '/': Record<string, never>;
11
+ * '/talks': Record<string, never>;
12
+ * '/talks/$slug': { slug: string };
13
+ * }
14
+ * }
15
+ * ```
16
+ *
17
+ * Keys are canonical route patterns in TanStack notation (`$param`,
18
+ * `{-$param}`); values describe the route's path params.
19
+ */
20
+ // biome-ignore lint/suspicious/noEmptyInterface: augmented by generated code
21
+ export interface UltramodernCanonicalRoutes {}
22
+
23
+ export type CanonicalRoutePath = keyof UltramodernCanonicalRoutes & string;
24
+
25
+ type HasCanonicalRoutes = [keyof UltramodernCanonicalRoutes] extends [never]
26
+ ? false
27
+ : true;
28
+
29
+ /**
30
+ * Targets that bypass canonical-route validation: external URLs, same-page
31
+ * hash anchors, and canonical paths with a `?search` and/or `#hash` suffix
32
+ * (the pathname part of suffixed targets is still validated).
33
+ */
34
+ type ExternalLinkTarget =
35
+ | `http://${string}`
36
+ | `https://${string}`
37
+ | `mailto:${string}`
38
+ | `tel:${string}`
39
+ | `//${string}`;
40
+
41
+ type SuffixedCanonicalTarget =
42
+ | `${CanonicalRoutePath}?${string}`
43
+ | `${CanonicalRoutePath}#${string}`;
44
+
45
+ export type AllowedLinkTarget =
46
+ | CanonicalRoutePath
47
+ | SuffixedCanonicalTarget
48
+ | ExternalLinkTarget
49
+ | `#${string}`;
50
+
51
+ /**
52
+ * Validates a literal `to` against the canonical route map. Computed strings
53
+ * (type `string`) always pass — the escape hatch for dynamic values. When no
54
+ * canonical map has been generated, everything passes.
55
+ */
56
+ export type ValidateLinkTo<TTo extends string> =
57
+ HasCanonicalRoutes extends false
58
+ ? unknown
59
+ : string extends TTo
60
+ ? unknown
61
+ : TTo extends AllowedLinkTarget
62
+ ? unknown
63
+ : {
64
+ to: {
65
+ error: 'Not a canonical route. Authors must write language-agnostic paths; see UltramodernCanonicalRoutes.';
66
+ received: TTo;
67
+ };
68
+ };
69
+
70
+ /** Strip `?search`/`#hash` suffixes from a link target type. */
71
+ export type LinkTargetPathname<TTo extends string> =
72
+ TTo extends `${infer TPath}#${string}`
73
+ ? TPath extends `${infer TPure}?${string}`
74
+ ? TPure
75
+ : TPath
76
+ : TTo extends `${infer TPath}?${string}`
77
+ ? TPath
78
+ : TTo;
79
+
80
+ /**
81
+ * `params` prop contract for a canonical target: required when the route has
82
+ * required params, optional when all params are optional, forbidden when the
83
+ * route has none. Non-canonical (computed/external) targets accept a loose
84
+ * record.
85
+ */
86
+ export type LinkParamsProp<TPath extends string> =
87
+ TPath extends CanonicalRoutePath
88
+ ? UltramodernCanonicalRoutes[TPath] extends Record<string, never>
89
+ ? { params?: undefined }
90
+ : Record<string, never> extends UltramodernCanonicalRoutes[TPath]
91
+ ? { params?: UltramodernCanonicalRoutes[TPath] }
92
+ : { params: UltramodernCanonicalRoutes[TPath] }
93
+ : { params?: Record<string, string | number | undefined> };
@@ -1,6 +1,44 @@
1
+ import fs from 'fs';
2
+ import nodePath from 'path';
3
+
4
+ /**
5
+ * Conventional locales roots, in the same priority order as the CLI plugin's
6
+ * `detectLocalesDirectory` auto-detection (project-root `./locales` first —
7
+ * the upstream convention — then the scaffold's `./config/public/locales`).
8
+ * The fs-backend default must read from the same directory whose existence
9
+ * enabled the backend in the first place.
10
+ */
11
+ const CONVENTIONAL_LOCALES_DIRS = [
12
+ './locales',
13
+ './config/public/locales',
14
+ ] as const;
15
+
16
+ const isDirectory = (dirPath: string): boolean => {
17
+ try {
18
+ return fs.statSync(dirPath).isDirectory();
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+
24
+ export const resolveDefaultLocalesDir = (
25
+ cwd: string = process.cwd(),
26
+ ): string => {
27
+ for (const dir of CONVENTIONAL_LOCALES_DIRS) {
28
+ if (isDirectory(nodePath.resolve(cwd, dir))) {
29
+ return dir;
30
+ }
31
+ }
32
+ return CONVENTIONAL_LOCALES_DIRS[0];
33
+ };
34
+
1
35
  export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
2
- loadPath: './config/public/locales/{{lng}}/{{ns}}.json',
3
- addPath: './config/public/locales/{{lng}}/{{ns}}.json',
36
+ get loadPath(): string {
37
+ return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
38
+ },
39
+ get addPath(): string {
40
+ return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
41
+ },
4
42
  };
5
43
 
6
44
  function convertPath(path: string | undefined): string | undefined {
@@ -14,18 +52,85 @@ function convertPath(path: string | undefined): string | undefined {
14
52
  return path;
15
53
  }
16
54
 
17
- export function convertBackendOptions<
18
- T extends { loadPath?: string; addPath?: string },
19
- >(options: T): T {
55
+ interface InternalBackendPathOptions {
56
+ loadPath?: string;
57
+ addPath?: string;
58
+ serverLoadPath?: string;
59
+ serverAddPath?: string;
60
+ serverLoadPaths?: string[];
61
+ serverAddPaths?: string[];
62
+ _detectedLoadPath?: string;
63
+ _detectedAddPath?: string;
64
+ }
65
+
66
+ function shouldUseServerPath(
67
+ currentPath: string | undefined,
68
+ detectedPath: string | undefined,
69
+ ): boolean {
70
+ return !detectedPath || currentPath === detectedPath;
71
+ }
72
+
73
+ function getResourceBasePath(resourcePath: string): string {
74
+ const markerIndex = resourcePath.indexOf('{{lng}}');
75
+ if (markerIndex < 0) {
76
+ return resourcePath;
77
+ }
78
+ return resourcePath.slice(0, markerIndex).replace(/[\\/]+$/, '');
79
+ }
80
+
81
+ function pathExists(resourcePath: string): boolean {
82
+ try {
83
+ return fs.existsSync(getResourceBasePath(resourcePath));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function getServerPath(
90
+ pathCandidates: string[] | undefined,
91
+ fallbackPath: string | undefined,
92
+ ): string | undefined {
93
+ const candidates = Array.from(
94
+ new Set([...(pathCandidates || []), fallbackPath].filter(Boolean)),
95
+ ) as string[];
96
+
97
+ return candidates.find(pathExists) || candidates[0];
98
+ }
99
+
100
+ export function convertBackendOptions<T extends InternalBackendPathOptions>(
101
+ options: T,
102
+ ): T {
20
103
  if (!options) {
21
104
  return options;
22
105
  }
23
106
  const converted = { ...options };
24
- if (converted.loadPath) {
107
+ if (
108
+ (converted.serverLoadPath || converted.serverLoadPaths) &&
109
+ shouldUseServerPath(converted.loadPath, converted._detectedLoadPath)
110
+ ) {
111
+ converted.loadPath = getServerPath(
112
+ converted.serverLoadPaths,
113
+ converted.serverLoadPath,
114
+ );
115
+ } else if (converted.loadPath) {
25
116
  converted.loadPath = convertPath(converted.loadPath);
26
117
  }
27
- if (converted.addPath) {
118
+ if (
119
+ (converted.serverAddPath || converted.serverAddPaths) &&
120
+ shouldUseServerPath(converted.addPath, converted._detectedAddPath)
121
+ ) {
122
+ converted.addPath = getServerPath(
123
+ converted.serverAddPaths,
124
+ converted.serverAddPath,
125
+ );
126
+ } else if (converted.addPath) {
28
127
  converted.addPath = convertPath(converted.addPath);
29
128
  }
129
+ delete converted.serverLoadPath;
130
+ delete converted.serverAddPath;
131
+ delete converted.serverLoadPaths;
132
+ delete converted.serverAddPaths;
133
+ delete converted._detectedLoadPath;
134
+ delete converted._detectedAddPath;
30
135
  return converted;
31
136
  }
@@ -3,32 +3,34 @@ export const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
3
3
  addPath: '/locales/{{lng}}/{{ns}}.json',
4
4
  };
5
5
 
6
- declare global {
7
- interface Window {
8
- __assetPrefix__?: string;
9
- }
10
- }
11
-
12
6
  function convertPath(path: string | undefined): string | undefined {
13
- if (!path) {
14
- return path;
15
- }
16
- // If it's an absolute path (starts with /), convert to relative path
17
- if (path.startsWith('/')) {
18
- return typeof window === 'undefined'
19
- ? path
20
- : `${window.__assetPrefix__ || ''}${path}`;
21
- }
22
7
  return path;
23
8
  }
24
9
 
25
- export function convertBackendOptions<
26
- T extends { loadPath?: string; addPath?: string },
27
- >(options: T): T {
10
+ interface InternalBackendPathOptions {
11
+ loadPath?: string;
12
+ addPath?: string;
13
+ serverLoadPath?: string;
14
+ serverAddPath?: string;
15
+ serverLoadPaths?: string[];
16
+ serverAddPaths?: string[];
17
+ _detectedLoadPath?: string;
18
+ _detectedAddPath?: string;
19
+ }
20
+
21
+ export function convertBackendOptions<T extends InternalBackendPathOptions>(
22
+ options: T,
23
+ ): T {
28
24
  if (!options) {
29
25
  return options;
30
26
  }
31
27
  const converted = { ...options };
28
+ delete converted.serverLoadPath;
29
+ delete converted.serverAddPath;
30
+ delete converted.serverLoadPaths;
31
+ delete converted.serverAddPaths;
32
+ delete converted._detectedLoadPath;
33
+ delete converted._detectedAddPath;
32
34
  if (converted.loadPath) {
33
35
  converted.loadPath = convertPath(converted.loadPath);
34
36
  }
@@ -82,7 +82,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
82
82
  } = localeDetection || {};
83
83
  const { enabled: backendEnabled = false } = backend || {};
84
84
  let latestI18nInstance: I18nInstance | undefined;
85
- let I18nextProvider: React.FunctionComponent<any> | null;
85
+ let I18nextProvider: React.ComponentType<any> | null;
86
86
 
87
87
  const loadReactI18nextIntegration = async () => {
88
88
  if (!reactI18next) {
@@ -293,6 +293,28 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
293
293
  },
294
294
  });
295
295
 
296
+ export type {
297
+ AllowedLinkTarget,
298
+ CanonicalRoutePath,
299
+ UltramodernCanonicalRoutes,
300
+ } from './canonicalRoutes';
296
301
  export { useModernI18n } from './context';
297
- export { I18nLink } from './I18nLink';
302
+ export { I18nLink, type I18nLinkProps } from './I18nLink';
303
+ export {
304
+ Link,
305
+ type LinkActiveOptions,
306
+ type LinkBaseProps,
307
+ type LinkParams,
308
+ type LinkProps,
309
+ } from './Link';
310
+ export {
311
+ canonicalPath,
312
+ type LocalizedPathsConfig,
313
+ localizePath,
314
+ type UseLocalizedLocationReturn,
315
+ type UseLocalizedPathsReturn,
316
+ useLocalizedLocation,
317
+ useLocalizedPaths,
318
+ } from './localizedPaths';
319
+ export { buildLocalizedUrl, splitUrlTarget } from './utils';
298
320
  export default i18nPlugin;
@@ -0,0 +1,107 @@
1
+ import { useMemo } from 'react';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
+ import { canonicalTargetPathname } from '../shared/localisedUrls';
4
+ import { useModernI18n } from './context';
5
+ import { useI18nRouterAdapter } from './routerAdapter';
6
+ import { buildLocalizedUrl, splitUrlTarget } from './utils';
7
+
8
+ export interface LocalizedPathsConfig {
9
+ languages: string[];
10
+ localisedUrls?: LocalisedUrlsOption;
11
+ }
12
+
13
+ /**
14
+ * Localize a canonical, language-agnostic target for the given language:
15
+ * adds the language prefix and applies `localisedUrls` pattern mapping.
16
+ * `?search`/`#hash` suffixes are preserved verbatim.
17
+ */
18
+ export const localizePath = (
19
+ pathname: string,
20
+ language: string,
21
+ config: LocalizedPathsConfig,
22
+ ): string =>
23
+ buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
24
+
25
+ /**
26
+ * Reverse of {@link localizePath}: strip the language prefix and map localized
27
+ * slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
28
+ * preserved verbatim.
29
+ */
30
+ export const canonicalPath = (
31
+ target: string,
32
+ config: LocalizedPathsConfig,
33
+ ): string => {
34
+ const { pathname, search, hash } = splitUrlTarget(target);
35
+ const resolvedPath = canonicalTargetPathname(
36
+ pathname,
37
+ config.languages,
38
+ config.localisedUrls,
39
+ );
40
+
41
+ return `${resolvedPath}${search}${hash}`;
42
+ };
43
+
44
+ export interface UseLocalizedPathsReturn {
45
+ localizePath: (pathname: string, language: string) => string;
46
+ canonicalPath: (pathname: string) => string;
47
+ }
48
+
49
+ /**
50
+ * Context-bound versions of {@link localizePath} and {@link canonicalPath} —
51
+ * the plugin configuration (languages, localisedUrls) is read from the i18n
52
+ * provider, so apps never copy pattern-matching helpers again.
53
+ */
54
+ export const useLocalizedPaths = (): UseLocalizedPathsReturn => {
55
+ const { supportedLanguages, localisedUrls } = useModernI18n();
56
+
57
+ return useMemo(() => {
58
+ const config: LocalizedPathsConfig = {
59
+ languages: supportedLanguages,
60
+ localisedUrls,
61
+ };
62
+
63
+ return {
64
+ localizePath: (pathname: string, language: string) =>
65
+ localizePath(pathname, language, config),
66
+ canonicalPath: (pathname: string) => canonicalPath(pathname, config),
67
+ };
68
+ }, [supportedLanguages, localisedUrls]);
69
+ };
70
+
71
+ export interface UseLocalizedLocationReturn {
72
+ language: string;
73
+ /** Canonical (language-agnostic) path of the current location. */
74
+ canonical: string;
75
+ /** Per-language hrefs for the current location, search+hash preserved. */
76
+ alternates: Record<string, string>;
77
+ }
78
+
79
+ /**
80
+ * Per-language hrefs for the current location — for hreflang `<link>` tags and
81
+ * language switchers. SSR-safe: the location comes from the router adapter.
82
+ */
83
+ export const useLocalizedLocation = (): UseLocalizedLocationReturn => {
84
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
85
+ const { location } = useI18nRouterAdapter();
86
+ const pathname = location?.pathname ?? '/';
87
+ const search = location?.search ?? '';
88
+ const hash = location?.hash ?? '';
89
+
90
+ return useMemo(() => {
91
+ const config: LocalizedPathsConfig = {
92
+ languages: supportedLanguages,
93
+ localisedUrls,
94
+ };
95
+ const alternates: Record<string, string> = {};
96
+ for (const supportedLanguage of supportedLanguages) {
97
+ alternates[supportedLanguage] =
98
+ `${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
99
+ }
100
+
101
+ return {
102
+ language,
103
+ canonical: canonicalPath(pathname, config),
104
+ alternates,
105
+ };
106
+ }, [language, supportedLanguages, localisedUrls, pathname, search, hash]);
107
+ };
@@ -1,5 +1,6 @@
1
1
  import { isBrowser, RuntimeContext } from '@modern-js/runtime';
2
2
  import {
3
+ getRouterRuntimeState,
3
4
  InternalRuntimeContext,
4
5
  type TInternalRuntimeContext,
5
6
  type TRuntimeContext,
@@ -131,9 +132,8 @@ const getRouterFramework = (
131
132
  inReactRouter: boolean,
132
133
  ): I18nRouterFramework | undefined => {
133
134
  const framework =
134
- internalContext.routerFramework ||
135
- internalContext.routerRuntime?.framework ||
136
- runtimeContext.routerFramework;
135
+ getRouterRuntimeState(internalContext)?.framework ||
136
+ getRouterRuntimeState(runtimeContext)?.framework;
137
137
 
138
138
  if (framework) {
139
139
  return framework;
@@ -167,8 +167,7 @@ const getRouterInstance = (
167
167
  return contextRouter;
168
168
  }
169
169
 
170
- const router =
171
- internalContext.routerInstance || internalContext.routerRuntime?.instance;
170
+ const router = getRouterRuntimeState(internalContext)?.instance;
172
171
  if (!router || typeof router !== 'object') {
173
172
  return null;
174
173
  }
@@ -10,7 +10,7 @@ declare module '@modern-js/runtime' {
10
10
  };
11
11
  }
12
12
 
13
- interface TInternalRuntimeContext {
13
+ interface TRuntimeContext {
14
14
  i18nInstance?: I18nInstance;
15
15
  changeLanguage?: (lang: string) => Promise<void>;
16
16
  }
@@ -3,11 +3,8 @@ import {
3
3
  getGlobalBasename,
4
4
  type TInternalRuntimeContext,
5
5
  } from '@modern-js/runtime/context';
6
- import type { LocalisedUrlsMap } from '../shared/localisedUrls';
7
- import {
8
- resolveLocalisedPath,
9
- resolveLocalisedUrlsConfig,
10
- } from '../shared/localisedUrls';
6
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
7
+ import { localiseTargetPathname } from '../shared/localisedUrls';
11
8
 
12
9
  export const getPathname = (context: TInternalRuntimeContext): string => {
13
10
  if (isBrowser()) {
@@ -45,36 +42,46 @@ export const getLanguageFromPath = (
45
42
  return fallbackLanguage;
46
43
  };
47
44
 
45
+ /**
46
+ * Split a link target into its pathname, search and hash parts without
47
+ * relying on `new URL` (SSR-hot path; targets are relative).
48
+ */
49
+ export const splitUrlTarget = (
50
+ target: string,
51
+ ): { pathname: string; search: string; hash: string } => {
52
+ const hashIndex = target.indexOf('#');
53
+ const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
54
+ const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
55
+ const searchIndex = beforeHash.indexOf('?');
56
+ const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
57
+ const pathname =
58
+ searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
59
+
60
+ return { pathname, search, hash };
61
+ };
62
+
48
63
  /**
49
64
  * Helper function to build localized URL
50
- * @param pathname - The current pathname
65
+ * @param target - The language-agnostic target; may include `?search` and `#hash`
51
66
  * @param language - The target language
52
67
  * @param languages - Array of supported languages
53
- * @returns The localized URL path
68
+ * @returns The localized URL path with search and hash re-appended verbatim
54
69
  */
55
70
  export const buildLocalizedUrl = (
56
- pathname: string,
71
+ target: string,
57
72
  language: string,
58
73
  languages: string[],
59
- localisedUrls?: boolean | LocalisedUrlsMap,
74
+ localisedUrls?: LocalisedUrlsOption,
60
75
  ): string => {
61
- const segments = pathname.split('/').filter(Boolean);
62
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
63
- const pathWithoutLanguage =
64
- segments.length > 0 && languages.includes(segments[0])
65
- ? `/${segments.slice(1).join('/')}`
66
- : pathname;
67
- const resolvedPath = localisedUrlsConfig.enabled
68
- ? resolveLocalisedPath(
69
- pathWithoutLanguage,
70
- language,
71
- languages,
72
- localisedUrlsConfig.map,
73
- )
74
- : pathWithoutLanguage;
75
- const resolvedSegments = resolvedPath.split('/').filter(Boolean);
76
-
77
- return `/${[language, ...resolvedSegments].join('/')}`;
76
+ const { pathname, search, hash } = splitUrlTarget(target);
77
+ const localizedPathname = localiseTargetPathname(
78
+ pathname,
79
+ language,
80
+ languages,
81
+ localisedUrls,
82
+ );
83
+
84
+ return `${localizedPathname}${search}${hash}`;
78
85
  };
79
86
 
80
87
  export const detectLanguageFromPath = (
@@ -9,7 +9,7 @@ import {
9
9
  } from '../runtime/i18n/detection/config.js';
10
10
  import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
11
11
  import {
12
- resolveLocalisedPath,
12
+ localiseTargetPathname,
13
13
  resolveLocalisedUrlsConfig,
14
14
  } from '../shared/localisedUrls.js';
15
15
  import type { LocaleDetectionOptions } from '../shared/type';
@@ -314,22 +314,12 @@ const buildLocalizedUrl = (
314
314
  ? pathname.slice(basePath.length)
315
315
  : pathname;
316
316
 
317
- const segments = remainingPath.split('/').filter(Boolean);
318
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
319
- const pathWithoutLanguage =
320
- segments.length > 0 && languages.includes(segments[0])
321
- ? `/${segments.slice(1).join('/')}`
322
- : remainingPath;
323
- const resolvedPath = localisedUrlsConfig.enabled
324
- ? resolveLocalisedPath(
325
- pathWithoutLanguage,
326
- language,
327
- languages,
328
- localisedUrlsConfig.map,
329
- )
330
- : pathWithoutLanguage;
331
- const resolvedSegments = resolvedPath.split('/').filter(Boolean);
332
- const newPathname = `/${[language, ...resolvedSegments].join('/')}`;
317
+ const newPathname = localiseTargetPathname(
318
+ remainingPath,
319
+ language,
320
+ languages,
321
+ localisedUrls,
322
+ );
333
323
  // Handle root path case to avoid double slashes like //en
334
324
  const suffix = `${url.search}${url.hash}`;
335
325
  const localizedUrl =