@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121

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 (119) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +32 -5
  3. package/dist/cjs/runtime/I18nLink.js +17 -28
  4. package/dist/cjs/runtime/Link.js +264 -0
  5. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  6. package/dist/cjs/runtime/context.js +41 -10
  7. package/dist/cjs/runtime/hooks.js +17 -10
  8. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
  10. package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
  11. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  12. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
  15. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  17. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  18. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  21. package/dist/cjs/runtime/i18n/index.js +9 -5
  22. package/dist/cjs/runtime/i18n/instance.js +17 -37
  23. package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
  24. package/dist/cjs/runtime/i18n/utils.js +9 -17
  25. package/dist/cjs/runtime/index.js +50 -15
  26. package/dist/cjs/runtime/localizedPaths.js +102 -0
  27. package/dist/cjs/runtime/routerAdapter.js +167 -0
  28. package/dist/cjs/runtime/utils.js +80 -97
  29. package/dist/cjs/server/index.js +62 -14
  30. package/dist/cjs/shared/deepMerge.js +12 -8
  31. package/dist/cjs/shared/detection.js +9 -5
  32. package/dist/cjs/shared/localisedUrls.js +351 -0
  33. package/dist/cjs/shared/utils.js +15 -11
  34. package/dist/esm/cli/index.mjs +23 -0
  35. package/dist/esm/runtime/I18nLink.mjs +7 -22
  36. package/dist/esm/runtime/Link.mjs +221 -0
  37. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  38. package/dist/esm/runtime/context.mjs +34 -7
  39. package/dist/esm/runtime/hooks.mjs +9 -6
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
  42. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  43. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  44. package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
  45. package/dist/esm/runtime/i18n/utils.mjs +0 -12
  46. package/dist/esm/runtime/index.mjs +23 -13
  47. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  48. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  49. package/dist/esm/runtime/utils.mjs +19 -31
  50. package/dist/esm/server/index.mjs +46 -8
  51. package/dist/esm/shared/localisedUrls.mjs +283 -0
  52. package/dist/esm-node/cli/index.mjs +23 -0
  53. package/dist/esm-node/runtime/I18nLink.mjs +7 -22
  54. package/dist/esm-node/runtime/Link.mjs +222 -0
  55. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  56. package/dist/esm-node/runtime/context.mjs +34 -7
  57. package/dist/esm-node/runtime/hooks.mjs +9 -6
  58. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  59. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
  60. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  61. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  62. package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
  63. package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
  64. package/dist/esm-node/runtime/index.mjs +23 -13
  65. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  66. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  67. package/dist/esm-node/runtime/utils.mjs +19 -31
  68. package/dist/esm-node/server/index.mjs +46 -8
  69. package/dist/esm-node/shared/localisedUrls.mjs +284 -0
  70. package/dist/types/cli/index.d.ts +1 -0
  71. package/dist/types/runtime/I18nLink.d.ts +6 -0
  72. package/dist/types/runtime/Link.d.ts +66 -0
  73. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  74. package/dist/types/runtime/context.d.ts +3 -0
  75. package/dist/types/runtime/hooks.d.ts +4 -2
  76. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
  77. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
  78. package/dist/types/runtime/i18n/instance.d.ts +4 -6
  79. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  80. package/dist/types/runtime/index.d.ts +6 -1
  81. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  82. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  83. package/dist/types/runtime/types.d.ts +1 -1
  84. package/dist/types/runtime/utils.d.ts +13 -9
  85. package/dist/types/server/index.d.ts +6 -0
  86. package/dist/types/shared/localisedUrls.d.ts +36 -0
  87. package/dist/types/shared/type.d.ts +14 -0
  88. package/package.json +24 -24
  89. package/rstest.config.mts +44 -0
  90. package/src/cli/index.ts +44 -1
  91. package/src/runtime/I18nLink.tsx +14 -51
  92. package/src/runtime/Link.tsx +430 -0
  93. package/src/runtime/canonicalRoutes.ts +93 -0
  94. package/src/runtime/context.tsx +45 -7
  95. package/src/runtime/hooks.ts +13 -4
  96. package/src/runtime/i18n/backend/defaults.node.ts +40 -2
  97. package/src/runtime/i18n/backend/defaults.ts +3 -1
  98. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  99. package/src/runtime/i18n/instance.ts +3 -30
  100. package/src/runtime/i18n/react-i18next.ts +25 -0
  101. package/src/runtime/i18n/utils.ts +4 -26
  102. package/src/runtime/index.tsx +47 -12
  103. package/src/runtime/localizedPaths.ts +107 -0
  104. package/src/runtime/routerAdapter.tsx +332 -0
  105. package/src/runtime/types.ts +1 -1
  106. package/src/runtime/utils.ts +33 -38
  107. package/src/server/index.ts +108 -11
  108. package/src/shared/localisedUrls.ts +623 -0
  109. package/src/shared/type.ts +14 -0
  110. package/tests/backendDefaults.test.ts +51 -0
  111. package/tests/i18nUtils.test.ts +59 -0
  112. package/tests/link.test.tsx +525 -0
  113. package/tests/linkTypes.test.ts +28 -0
  114. package/tests/localisedUrls.test.ts +536 -0
  115. package/tests/routerAdapter.test.tsx +456 -0
  116. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  117. package/tests/type-fixture/tsconfig.json +15 -0
  118. package/dist/esm/rslib-runtime.mjs +0 -18
  119. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -0,0 +1,66 @@
1
+ import type React from 'react';
2
+ import type { LinkParamsProp, LinkTargetPathname, ValidateLinkTo } from './canonicalRoutes';
3
+ export type LinkParams = Record<string, string | number | undefined>;
4
+ /**
5
+ * Interpolate `$param`, `:param`, optional (`{-$param}` / `:param?`) and splat
6
+ * (`$` / `*`) segments with concrete values before localization, so
7
+ * pattern-mapped slugs localize correctly.
8
+ */
9
+ export declare const interpolateRouteParams: (pathname: string, params?: LinkParams) => string;
10
+ export interface LinkActiveOptions {
11
+ /**
12
+ * `true`: active only when the location matches the target exactly.
13
+ * `false`: also active when the location is nested under the target.
14
+ * Defaults to prefix matching, except for `/` which defaults to exact.
15
+ */
16
+ exact?: boolean;
17
+ }
18
+ type AnchorRest = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children'>;
19
+ export interface LinkBaseProps extends AnchorRest {
20
+ children?: React.ReactNode;
21
+ /** Hash fragment without the leading `#`. Overrides a `#hash` inside `to`. */
22
+ hash?: string;
23
+ /** Search params. Object form is passed natively to TanStack Link. */
24
+ search?: string | Record<string, unknown>;
25
+ hashScrollIntoView?: boolean | ScrollIntoViewOptions;
26
+ replace?: boolean;
27
+ /**
28
+ * Prefetching behavior, forwarded to the underlying router link:
29
+ * react-router gets it verbatim (Modern.js `PrefetchLink` supports it),
30
+ * TanStack receives it as its native `preload` prop (`'none'` -> `false`).
31
+ * Stripped from plain `<a>` fallbacks (external / no-router targets).
32
+ */
33
+ prefetch?: 'intent' | 'render' | 'viewport' | 'none';
34
+ /**
35
+ * Native preload value of the underlying router link. When set, it wins
36
+ * over `prefetch` on the TanStack branch.
37
+ */
38
+ preload?: unknown;
39
+ activeOptions?: LinkActiveOptions;
40
+ /** Extra anchor props applied when the link is active. */
41
+ activeProps?: AnchorRest & Record<string, unknown>;
42
+ [key: string]: unknown;
43
+ }
44
+ export type LinkProps<TTo extends string = string> = LinkBaseProps & {
45
+ to: TTo;
46
+ } & ValidateLinkTo<TTo> & LinkParamsProp<LinkTargetPathname<TTo>>;
47
+ /**
48
+ * The standard UltraModern link: a vanilla link in every respect except that
49
+ * it localizes canonical, language-agnostic paths automatically.
50
+ *
51
+ * - `to` accepts canonical routes (`/talks/$slug`), optionally with `#hash`
52
+ * and `?query` suffixes; both survive localization.
53
+ * - External URLs and bare `#hash` targets render a plain `<a>`.
54
+ * - Active state is language-invariant: a canonical `to` is active when the
55
+ * current location matches any localized variant of that route.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * <Link to="/talks/$slug" params={{ slug: talk.slug }} hash="abstract" />
60
+ * <Link to="/platform" /> // -> /cs/platforma under cs
61
+ * <Link to="/#work-with-me" /> // cross-page hash, SPA navigation
62
+ * <Link to="https://ai.bleeding.dev" /> // external -> plain <a>
63
+ * ```
64
+ */
65
+ export declare const Link: <TTo extends string = string>(props: LinkProps<TTo>) => React.ReactElement;
66
+ export default Link;
@@ -0,0 +1,60 @@
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
+ export interface UltramodernCanonicalRoutes {
21
+ }
22
+ export type CanonicalRoutePath = keyof UltramodernCanonicalRoutes & string;
23
+ type HasCanonicalRoutes = [keyof UltramodernCanonicalRoutes] extends [never] ? false : true;
24
+ /**
25
+ * Targets that bypass canonical-route validation: external URLs, same-page
26
+ * hash anchors, and canonical paths with a `?search` and/or `#hash` suffix
27
+ * (the pathname part of suffixed targets is still validated).
28
+ */
29
+ type ExternalLinkTarget = `http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `//${string}`;
30
+ type SuffixedCanonicalTarget = `${CanonicalRoutePath}?${string}` | `${CanonicalRoutePath}#${string}`;
31
+ export type AllowedLinkTarget = CanonicalRoutePath | SuffixedCanonicalTarget | ExternalLinkTarget | `#${string}`;
32
+ /**
33
+ * Validates a literal `to` against the canonical route map. Computed strings
34
+ * (type `string`) always pass — the escape hatch for dynamic values. When no
35
+ * canonical map has been generated, everything passes.
36
+ */
37
+ export type ValidateLinkTo<TTo extends string> = HasCanonicalRoutes extends false ? unknown : string extends TTo ? unknown : TTo extends AllowedLinkTarget ? unknown : {
38
+ to: {
39
+ error: 'Not a canonical route. Authors must write language-agnostic paths; see UltramodernCanonicalRoutes.';
40
+ received: TTo;
41
+ };
42
+ };
43
+ /** Strip `?search`/`#hash` suffixes from a link target type. */
44
+ export type LinkTargetPathname<TTo extends string> = TTo extends `${infer TPath}#${string}` ? TPath extends `${infer TPure}?${string}` ? TPure : TPath : TTo extends `${infer TPath}?${string}` ? TPath : TTo;
45
+ /**
46
+ * `params` prop contract for a canonical target: required when the route has
47
+ * required params, optional when all params are optional, forbidden when the
48
+ * route has none. Non-canonical (computed/external) targets accept a loose
49
+ * record.
50
+ */
51
+ export type LinkParamsProp<TPath extends string> = TPath extends CanonicalRoutePath ? UltramodernCanonicalRoutes[TPath] extends Record<string, never> ? {
52
+ params?: undefined;
53
+ } : Record<string, never> extends UltramodernCanonicalRoutes[TPath] ? {
54
+ params?: UltramodernCanonicalRoutes[TPath];
55
+ } : {
56
+ params: UltramodernCanonicalRoutes[TPath];
57
+ } : {
58
+ params?: Record<string, string | number | undefined>;
59
+ };
60
+ export {};
@@ -1,4 +1,5 @@
1
1
  import type { FC, ReactNode } from 'react';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
2
3
  import type { I18nInstance } from './i18n';
3
4
  export interface ModernI18nContextValue {
4
5
  language: string;
@@ -7,6 +8,7 @@ export interface ModernI18nContextValue {
7
8
  languages?: string[];
8
9
  localePathRedirect?: boolean;
9
10
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
11
+ localisedUrls?: LocalisedUrlsOption;
10
12
  updateLanguage?: (newLang: string) => void;
11
13
  }
12
14
  export interface ModernI18nProviderProps {
@@ -19,6 +21,7 @@ export interface UseModernI18nReturn {
19
21
  changeLanguage: (newLang: string) => Promise<void>;
20
22
  i18nInstance: I18nInstance;
21
23
  supportedLanguages: string[];
24
+ localisedUrls?: LocalisedUrlsOption;
22
25
  isLanguageSupported: (lang: string) => boolean;
23
26
  isResourcesReady: boolean;
24
27
  }
@@ -1,16 +1,18 @@
1
1
  import type { TRuntimeContext } from '@modern-js/runtime';
2
2
  import type React from 'react';
3
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
4
  import type { I18nInstance } from './i18n';
4
5
  interface RuntimeContextWithI18n extends TRuntimeContext {
5
6
  i18nInstance?: I18nInstance;
6
7
  }
7
- export declare function createContextValue(lang: string, i18nInstance: I18nInstance | undefined, entryName: string | undefined, languages: string[], localePathRedirect: boolean, ignoreRedirectRoutes: string[] | ((pathname: string) => boolean) | undefined, setLang: (lang: string) => void): {
8
+ export declare function createContextValue(lang: string, i18nInstance: I18nInstance | undefined, entryName: string | undefined, languages: string[], localePathRedirect: boolean, ignoreRedirectRoutes: string[] | ((pathname: string) => boolean) | undefined, localisedUrls: LocalisedUrlsOption | undefined, setLang: (lang: string) => void): {
8
9
  language: string;
9
10
  i18nInstance: I18nInstance;
10
11
  entryName: string | undefined;
11
12
  languages: string[];
12
13
  localePathRedirect: boolean;
13
14
  ignoreRedirectRoutes: string[] | ((pathname: string) => boolean) | undefined;
15
+ localisedUrls: LocalisedUrlsOption | undefined;
14
16
  updateLanguage: (lang: string) => void;
15
17
  };
16
18
  export declare function useSdkResourcesLoader(i18nInstance: I18nInstance | undefined, setForceUpdate: React.Dispatch<React.SetStateAction<number>>): void;
@@ -23,6 +25,6 @@ export declare function useSdkResourcesLoader(i18nInstance: I18nInstance | undef
23
25
  * In SSR/SSG scenarios, server-side middleware handles redirects, so this hook is skipped.
24
26
  * We use process.env.MODERN_TARGET to ensure this code is only included in browser bundles.
25
27
  */
26
- export declare function useClientSideRedirect(i18nInstance: I18nInstance | undefined, localePathRedirect: boolean, languages: string[], fallbackLanguage: string, ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)): void;
28
+ export declare function useClientSideRedirect(i18nInstance: I18nInstance | undefined, localePathRedirect: boolean, languages: string[], fallbackLanguage: string, ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean), localisedUrls?: LocalisedUrlsOption): void;
27
29
  export declare function useLanguageSync(i18nInstance: I18nInstance | undefined, localePathRedirect: boolean, languages: string[], runtimeContextRef: React.MutableRefObject<RuntimeContextWithI18n>, prevLangRef: React.MutableRefObject<string>, setLang: (lang: string) => void): void;
28
30
  export {};
@@ -1,6 +1,7 @@
1
+ export declare const resolveDefaultLocalesDir: (cwd?: string) => string;
1
2
  export declare const DEFAULT_I18NEXT_BACKEND_OPTIONS: {
2
- loadPath: string;
3
- addPath: string;
3
+ readonly loadPath: string;
4
+ readonly addPath: string;
4
5
  };
5
6
  export declare function convertBackendOptions<T extends {
6
7
  loadPath?: string;
@@ -1,4 +1,4 @@
1
- import Backend from 'i18next-fs-backend';
1
+ import Backend from 'i18next-fs-backend/cjs';
2
2
  import type { ExtendedBackendOptions } from '../../../shared/type';
3
3
  import type { I18nInstance } from '../instance';
4
4
  /**
@@ -1,7 +1,4 @@
1
1
  import type { BaseBackendOptions } from '../../shared/type';
2
- type ReactI18nextModule = typeof import('react-i18next');
3
- type InitReactI18next = ReactI18nextModule['initReactI18next'];
4
- type I18nextProviderComponent = ReactI18nextModule['I18nextProvider'];
5
2
  export interface I18nResourceStore {
6
3
  data?: {
7
4
  [language: string]: {
@@ -64,9 +61,12 @@ export interface BackendOptions extends Omit<BaseBackendOptions, 'enabled'> {
64
61
  stringify?: (data: any) => string;
65
62
  [key: string]: any;
66
63
  }
64
+ export type ResourceValue = string | {
65
+ [key: string]: ResourceValue;
66
+ };
67
67
  export interface Resources {
68
68
  [lng: string]: {
69
- [source: string]: string | Record<string, string>;
69
+ [source: string]: ResourceValue;
70
70
  };
71
71
  }
72
72
  export type I18nInitOptions = {
@@ -91,6 +91,4 @@ export type I18nInitOptions = {
91
91
  export declare function isI18nInstance(obj: any): obj is I18nInstance;
92
92
  export declare function getI18nextInstanceForProvider(instance: I18nInstance | any): any;
93
93
  export declare function getI18nInstance(userInstance?: I18nInstance | any): Promise<I18nInstance>;
94
- export declare function getInitReactI18next(): Promise<InitReactI18next | null>;
95
- export declare function getI18nextProvider(): Promise<I18nextProviderComponent | null>;
96
94
  export {};
@@ -0,0 +1,7 @@
1
+ import type React from 'react';
2
+ interface ReactI18nextIntegration {
3
+ I18nextProvider: React.ComponentType<any> | null;
4
+ initReactI18next: any | null;
5
+ }
6
+ export declare function getReactI18nextIntegration(): Promise<ReactI18nextIntegration>;
7
+ export {};
@@ -12,9 +12,14 @@ export interface I18nPluginOptions {
12
12
  changeLanguage?: (lang: string) => void;
13
13
  initOptions?: I18nInitOptions;
14
14
  htmlLangAttr?: boolean;
15
+ reactI18next?: boolean;
15
16
  [key: string]: any;
16
17
  }
17
18
  export declare const i18nPlugin: (options: I18nPluginOptions) => RuntimePlugin;
19
+ export type { AllowedLinkTarget, CanonicalRoutePath, UltramodernCanonicalRoutes, } from './canonicalRoutes';
18
20
  export { useModernI18n } from './context';
19
- export { I18nLink } from './I18nLink';
21
+ export { I18nLink, type I18nLinkProps } from './I18nLink';
22
+ export { Link, type LinkActiveOptions, type LinkBaseProps, type LinkParams, type LinkProps, } from './Link';
23
+ export { canonicalPath, type LocalizedPathsConfig, localizePath, type UseLocalizedLocationReturn, type UseLocalizedPathsReturn, useLocalizedLocation, useLocalizedPaths, } from './localizedPaths';
24
+ export { buildLocalizedUrl, splitUrlTarget } from './utils';
20
25
  export default i18nPlugin;
@@ -0,0 +1,39 @@
1
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
2
+ export interface LocalizedPathsConfig {
3
+ languages: string[];
4
+ localisedUrls?: LocalisedUrlsOption;
5
+ }
6
+ /**
7
+ * Localize a canonical, language-agnostic target for the given language:
8
+ * adds the language prefix and applies `localisedUrls` pattern mapping.
9
+ * `?search`/`#hash` suffixes are preserved verbatim.
10
+ */
11
+ export declare const localizePath: (pathname: string, language: string, config: LocalizedPathsConfig) => string;
12
+ /**
13
+ * Reverse of {@link localizePath}: strip the language prefix and map localized
14
+ * slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
15
+ * preserved verbatim.
16
+ */
17
+ export declare const canonicalPath: (target: string, config: LocalizedPathsConfig) => string;
18
+ export interface UseLocalizedPathsReturn {
19
+ localizePath: (pathname: string, language: string) => string;
20
+ canonicalPath: (pathname: string) => string;
21
+ }
22
+ /**
23
+ * Context-bound versions of {@link localizePath} and {@link canonicalPath} —
24
+ * the plugin configuration (languages, localisedUrls) is read from the i18n
25
+ * provider, so apps never copy pattern-matching helpers again.
26
+ */
27
+ export declare const useLocalizedPaths: () => UseLocalizedPathsReturn;
28
+ export interface UseLocalizedLocationReturn {
29
+ language: string;
30
+ /** Canonical (language-agnostic) path of the current location. */
31
+ canonical: string;
32
+ /** Per-language hrefs for the current location, search+hash preserved. */
33
+ alternates: Record<string, string>;
34
+ }
35
+ /**
36
+ * Per-language hrefs for the current location — for hreflang `<link>` tags and
37
+ * language switchers. SSR-safe: the location comes from the router adapter.
38
+ */
39
+ export declare const useLocalizedLocation: () => UseLocalizedLocationReturn;
@@ -0,0 +1,26 @@
1
+ import type React from 'react';
2
+ export type I18nRouterFramework = 'react-router' | 'tanstack' | string;
3
+ export interface I18nRouterLocation {
4
+ pathname: string;
5
+ search: string;
6
+ hash: string;
7
+ }
8
+ export interface I18nRouterNavigateOptions {
9
+ replace?: boolean;
10
+ state?: unknown;
11
+ }
12
+ export type I18nRouterNavigate = (href: string, options?: I18nRouterNavigateOptions) => void | Promise<void>;
13
+ export type I18nRouterLink = React.ComponentType<{
14
+ to: string;
15
+ children?: React.ReactNode;
16
+ [key: string]: unknown;
17
+ }>;
18
+ export interface I18nRouterAdapter {
19
+ framework?: I18nRouterFramework;
20
+ hasRouter: boolean;
21
+ location: I18nRouterLocation | null;
22
+ navigate: I18nRouterNavigate | null;
23
+ Link: I18nRouterLink | null;
24
+ params: Record<string, string>;
25
+ }
26
+ export declare const useI18nRouterAdapter: () => I18nRouterAdapter;
@@ -8,7 +8,7 @@ declare module '@modern-js/runtime' {
8
8
  initOptions?: I18nInitOptions;
9
9
  };
10
10
  }
11
- interface TInternalRuntimeContext {
11
+ interface TRuntimeContext {
12
12
  i18nInstance?: I18nInstance;
13
13
  changeLanguage?: (lang: string) => Promise<void>;
14
14
  }
@@ -1,4 +1,5 @@
1
1
  import { type TInternalRuntimeContext } from '@modern-js/runtime/context';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
2
3
  export declare const getPathname: (context: TInternalRuntimeContext) => string;
3
4
  export declare const getEntryPath: () => string;
4
5
  /**
@@ -9,14 +10,23 @@ export declare const getEntryPath: () => string;
9
10
  * @returns The detected language or fallback language
10
11
  */
11
12
  export declare const getLanguageFromPath: (pathname: string, languages: string[], fallbackLanguage: string) => string;
13
+ /**
14
+ * Split a link target into its pathname, search and hash parts without
15
+ * relying on `new URL` (SSR-hot path; targets are relative).
16
+ */
17
+ export declare const splitUrlTarget: (target: string) => {
18
+ pathname: string;
19
+ search: string;
20
+ hash: string;
21
+ };
12
22
  /**
13
23
  * Helper function to build localized URL
14
- * @param pathname - The current pathname
24
+ * @param target - The language-agnostic target; may include `?search` and `#hash`
15
25
  * @param language - The target language
16
26
  * @param languages - Array of supported languages
17
- * @returns The localized URL path
27
+ * @returns The localized URL path with search and hash re-appended verbatim
18
28
  */
19
- export declare const buildLocalizedUrl: (pathname: string, language: string, languages: string[]) => string;
29
+ export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
20
30
  export declare const detectLanguageFromPath: (pathname: string, languages: string[], localePathRedirect: boolean) => {
21
31
  detected: boolean;
22
32
  language?: string;
@@ -25,9 +35,3 @@ export declare const detectLanguageFromPath: (pathname: string, languages: strin
25
35
  * Check if the given pathname should ignore automatic locale redirect
26
36
  */
27
37
  export declare const shouldIgnoreRedirect: (pathname: string, languages: string[], ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)) => boolean;
28
- export declare const useRouterHooks: () => {
29
- navigate: any;
30
- location: any;
31
- params: any;
32
- hasRouter: boolean;
33
- };
@@ -4,5 +4,11 @@ export interface I18nPluginOptions {
4
4
  localeDetection: LocaleDetectionOptions;
5
5
  staticRoutePrefixes: string[];
6
6
  }
7
+ type ApiPrefixInput = string | string[] | undefined;
8
+ export declare const collectApiPrefixes: (routes: Array<{
9
+ isApi?: boolean;
10
+ urlPath?: string;
11
+ }>, bffPrefix?: ApiPrefixInput) => string[];
12
+ export declare const matchesApiPrefix: (pathname: string, apiPrefixes: string[]) => boolean;
7
13
  export declare const i18nServerPlugin: (options: I18nPluginOptions) => ServerPlugin;
8
14
  export default i18nServerPlugin;
@@ -0,0 +1,36 @@
1
+ import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
2
+ export type LocalisedUrlPathMap = Record<string, string>;
3
+ export type LocalisedUrlsMap = Record<string, LocalisedUrlPathMap>;
4
+ export type LocalisedUrlsOption = boolean | LocalisedUrlsMap;
5
+ export interface ResolvedLocalisedUrlsConfig {
6
+ enabled: boolean;
7
+ map: LocalisedUrlsMap;
8
+ }
9
+ export declare const normalisePathPattern: (path: string) => string;
10
+ /**
11
+ * Normalise a concrete request pathname: slash cleanup only. Unlike
12
+ * {@link normalisePathPattern} it must not rewrite literal `[x]` segments to
13
+ * `:x` params — pathnames are values, not patterns.
14
+ */
15
+ export declare const normalisePathname: (pathname: string) => string;
16
+ /**
17
+ * Localised URLs are strictly opt-in: only an explicit, non-empty map enables
18
+ * route expansion and validation. `true`, `false`, an empty map and absence
19
+ * all resolve to disabled, so upstream-style configs (`localePathRedirect` +
20
+ * `languages` without a map) keep plain locale-prefix behavior instead of
21
+ * failing the build for every route missing from a map they never wrote.
22
+ */
23
+ export declare const resolveLocalisedUrlsConfig: (option: LocalisedUrlsOption | undefined) => ResolvedLocalisedUrlsConfig;
24
+ export declare const validateLocalisedUrls: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => void;
25
+ export declare const applyLocalisedUrlsToRoutes: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => (NestedRouteForCli | PageRoute)[];
26
+ export declare const matchPathPattern: (pathname: string, pattern: string) => Record<string, string> | null;
27
+ export declare const buildPathFromPattern: (pattern: string, params: Record<string, string>) => string;
28
+ export declare const resolveLocalisedPath: (pathname: string, targetLanguage: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
29
+ /**
30
+ * Reverse-map a language-specific pathname (without language prefix) back to
31
+ * the canonical, language-agnostic path: localized slug patterns are matched
32
+ * against every language variant and rebuilt from the canonical map key.
33
+ */
34
+ export declare const resolveCanonicalLocalisedPath: (pathname: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
35
+ export declare const localiseTargetPathname: (pathname: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
36
+ export declare const canonicalTargetPathname: (pathname: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
@@ -1,4 +1,5 @@
1
1
  import type { LanguageDetectorOptions, Resources } from '../runtime/i18n/instance';
2
+ import type { LocalisedUrlsOption } from './localisedUrls';
2
3
  export interface BaseLocaleDetectionOptions {
3
4
  localePathRedirect?: boolean;
4
5
  i18nextDetector?: boolean;
@@ -6,6 +7,19 @@ export interface BaseLocaleDetectionOptions {
6
7
  fallbackLanguage?: string;
7
8
  detection?: LanguageDetectorOptions;
8
9
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
10
+ /**
11
+ * Enables localised pathnames in addition to the locale prefix.
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.
21
+ */
22
+ localisedUrls?: LocalisedUrlsOption;
9
23
  }
10
24
  export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
11
25
  localeDetectionByEntry?: Record<string, BaseLocaleDetectionOptions>;
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "modern",
18
18
  "modern.js"
19
19
  ],
20
- "version": "3.2.0-ultramodern.12",
20
+ "version": "3.2.0-ultramodern.121",
21
21
  "engines": {
22
22
  "node": ">=20"
23
23
  },
@@ -81,25 +81,25 @@
81
81
  }
82
82
  },
83
83
  "dependencies": {
84
- "@swc/helpers": "^0.5.21",
84
+ "@swc/helpers": "^0.5.23",
85
85
  "i18next-browser-languagedetector": "^8.2.1",
86
86
  "i18next-chained-backend": "^5.0.4",
87
- "i18next-fs-backend": "^2.6.5",
87
+ "i18next-fs-backend": "^2.6.6",
88
88
  "i18next-http-backend": "^4.0.0",
89
- "i18next-http-middleware": "^3.9.6",
90
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.12",
91
- "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.12",
92
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.12",
93
- "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.12",
94
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.12",
95
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.12"
89
+ "i18next-http-middleware": "^3.9.7",
90
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.121",
91
+ "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.121",
92
+ "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.121",
93
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.121",
94
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.121",
95
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.121"
96
96
  },
97
97
  "peerDependencies": {
98
- "@modern-js/runtime": "3.2.0-ultramodern.12",
98
+ "@modern-js/runtime": "3.2.0-ultramodern.121",
99
99
  "i18next": ">=25.7.4",
100
- "react": "^19.2.6",
101
- "react-dom": "^19.2.6",
102
- "react-i18next": ">=15.7.4"
100
+ "react": "^19.2.7",
101
+ "react-dom": "^19.2.7",
102
+ "react-i18next": "^17.0.0"
103
103
  },
104
104
  "peerDependenciesMeta": {
105
105
  "i18next": {
@@ -110,18 +110,18 @@
110
110
  }
111
111
  },
112
112
  "devDependencies": {
113
- "@rslib/core": "0.21.5",
113
+ "@rslib/core": "0.22.0",
114
114
  "@types/jest": "^30.0.0",
115
- "@types/node": "^25.8.0",
116
- "@typescript/native-preview": "7.0.0-dev.20260516.1",
117
- "i18next": "26.2.0",
115
+ "@types/node": "^25.9.3",
116
+ "@typescript/native-preview": "7.0.0-dev.20260610.1",
117
+ "i18next": "26.3.1",
118
118
  "jest": "^30.4.2",
119
- "react": "^19.2.6",
120
- "react-dom": "^19.2.6",
119
+ "react": "^19.2.7",
120
+ "react-dom": "^19.2.7",
121
121
  "react-i18next": "17.0.8",
122
- "ts-jest": "^29.4.9",
123
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.12",
124
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.12"
122
+ "ts-jest": "^29.4.11",
123
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.121",
124
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.121"
125
125
  },
126
126
  "sideEffects": false,
127
127
  "publishConfig": {
@@ -130,7 +130,7 @@
130
130
  },
131
131
  "scripts": {
132
132
  "dev": "rslib build --watch",
133
- "build": "rslib build",
133
+ "build": "rslib build && pnpm -w tsgo:dts \"$PWD\"",
134
134
  "test": "rstest --passWithNoTests"
135
135
  }
136
136
  }
@@ -0,0 +1,44 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ProjectConfig } from '@rstest/core';
4
+ import { withTestPreset } from '@scripts/rstest-config';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ const commonConfig: ProjectConfig = {
9
+ setupFiles: [resolve(__dirname, '../../../scripts/rstest-config/setup.ts')],
10
+ globals: true,
11
+ tools: {
12
+ swc: {
13
+ jsc: {
14
+ transform: {
15
+ react: {
16
+ runtime: 'automatic',
17
+ },
18
+ },
19
+ },
20
+ },
21
+ },
22
+ };
23
+
24
+ export default {
25
+ projects: [
26
+ withTestPreset({
27
+ name: 'plugin-i18n-node',
28
+ testEnvironment: 'node',
29
+ include: [
30
+ 'tests/i18nUtils.test.ts',
31
+ 'tests/localisedUrls.test.ts',
32
+ 'tests/linkTypes.test.ts',
33
+ 'tests/backendDefaults.test.ts',
34
+ ],
35
+ extends: commonConfig,
36
+ }),
37
+ withTestPreset({
38
+ name: 'plugin-i18n-client',
39
+ testEnvironment: 'happy-dom',
40
+ include: ['tests/routerAdapter.test.tsx', 'tests/link.test.tsx'],
41
+ extends: commonConfig,
42
+ }),
43
+ ],
44
+ };
package/src/cli/index.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import type { AppTools, CliPlugin } from '@modern-js/app-tools';
2
2
  import { getPublicDirRoutePrefixes } from '@modern-js/server-core';
3
- import type { Entrypoint } from '@modern-js/types';
3
+ import type {
4
+ Entrypoint,
5
+ NestedRouteForCli,
6
+ PageRoute,
7
+ } from '@modern-js/types';
4
8
  import fs from 'fs';
5
9
  import path from 'path';
10
+ import {
11
+ applyLocalisedUrlsToRoutes,
12
+ resolveLocalisedUrlsConfig,
13
+ } from '../shared/localisedUrls';
6
14
  import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
7
15
  import { getBackendOptions, getLocaleDetectionOptions } from '../shared/utils';
16
+ import '../runtime/types';
8
17
 
9
18
  export type TransformRuntimeConfigFn = (
10
19
  extendedConfig: Record<string, any>,
@@ -203,6 +212,40 @@ export const i18nPlugin = (
203
212
  };
204
213
  });
205
214
 
215
+ api.modifyFileSystemRoutes(({ entrypoint, routes }) => {
216
+ if (!localeDetection) {
217
+ return { entrypoint, routes };
218
+ }
219
+
220
+ const localeDetectionOptions = getLocaleDetectionOptions(
221
+ entrypoint.entryName,
222
+ localeDetection,
223
+ );
224
+ const {
225
+ localePathRedirect,
226
+ languages = [],
227
+ localisedUrls,
228
+ } = localeDetectionOptions;
229
+
230
+ if (!localePathRedirect || languages.length === 0) {
231
+ return { entrypoint, routes };
232
+ }
233
+
234
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
235
+ if (!localisedUrlsConfig.enabled) {
236
+ return { entrypoint, routes };
237
+ }
238
+
239
+ return {
240
+ entrypoint,
241
+ routes: applyLocalisedUrlsToRoutes(
242
+ routes as (NestedRouteForCli | PageRoute)[],
243
+ languages,
244
+ localisedUrlsConfig.map,
245
+ ),
246
+ };
247
+ });
248
+
206
249
  api._internalServerPlugins(({ plugins }) => {
207
250
  const { serverRoutes, metaName } = api.getAppContext();
208
251
  const normalizedConfig = api.getNormalizedConfig();