@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.120 → 3.2.0-ultramodern.122

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 (41) hide show
  1. package/dist/cjs/runtime/Link.js +33 -21
  2. package/dist/cjs/runtime/i18n/backend/defaults.node.js +42 -8
  3. package/dist/cjs/runtime/localizedPaths.js +1 -4
  4. package/dist/cjs/runtime/routerAdapter.js +2 -2
  5. package/dist/cjs/runtime/utils.js +2 -9
  6. package/dist/cjs/server/index.js +1 -9
  7. package/dist/cjs/shared/localisedUrls.js +107 -27
  8. package/dist/esm/runtime/Link.mjs +33 -21
  9. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
  10. package/dist/esm/runtime/localizedPaths.mjs +2 -5
  11. package/dist/esm/runtime/routerAdapter.mjs +3 -3
  12. package/dist/esm/runtime/utils.mjs +3 -10
  13. package/dist/esm/server/index.mjs +2 -10
  14. package/dist/esm/shared/localisedUrls.mjs +99 -28
  15. package/dist/esm-node/runtime/Link.mjs +33 -21
  16. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
  17. package/dist/esm-node/runtime/localizedPaths.mjs +2 -5
  18. package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
  19. package/dist/esm-node/runtime/utils.mjs +3 -10
  20. package/dist/esm-node/server/index.mjs +2 -10
  21. package/dist/esm-node/shared/localisedUrls.mjs +99 -28
  22. package/dist/types/runtime/Link.d.ts +10 -0
  23. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
  24. package/dist/types/runtime/utils.d.ts +2 -2
  25. package/dist/types/shared/localisedUrls.d.ts +15 -0
  26. package/dist/types/shared/type.d.ts +7 -5
  27. package/package.json +16 -12
  28. package/rstest.config.mts +6 -1
  29. package/src/runtime/Link.tsx +28 -12
  30. package/src/runtime/i18n/backend/defaults.node.ts +40 -2
  31. package/src/runtime/localizedPaths.ts +6 -17
  32. package/src/runtime/routerAdapter.tsx +4 -5
  33. package/src/runtime/utils.ts +11 -23
  34. package/src/server/index.ts +7 -17
  35. package/src/shared/localisedUrls.ts +212 -42
  36. package/src/shared/type.ts +7 -5
  37. package/tests/backendDefaults.test.ts +51 -0
  38. package/tests/i18nUtils.test.ts +10 -3
  39. package/tests/link.test.tsx +51 -1
  40. package/tests/localisedUrls.test.ts +224 -0
  41. package/tests/routerAdapter.test.tsx +12 -8
@@ -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,5 +1,5 @@
1
1
  import { type TInternalRuntimeContext } from '@modern-js/runtime/context';
2
- import type { LocalisedUrlsMap } from '../shared/localisedUrls';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
3
  export declare const getPathname: (context: TInternalRuntimeContext) => string;
4
4
  export declare const getEntryPath: () => string;
5
5
  /**
@@ -26,7 +26,7 @@ export declare const splitUrlTarget: (target: string) => {
26
26
  * @param languages - Array of supported languages
27
27
  * @returns The localized URL path with search and hash re-appended verbatim
28
28
  */
29
- export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: boolean | LocalisedUrlsMap) => string;
29
+ export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
30
30
  export declare const detectLanguageFromPath: (pathname: string, languages: string[], localePathRedirect: boolean) => {
31
31
  detected: boolean;
32
32
  language?: string;
@@ -7,6 +7,19 @@ export interface ResolvedLocalisedUrlsConfig {
7
7
  map: LocalisedUrlsMap;
8
8
  }
9
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
+ */
10
23
  export declare const resolveLocalisedUrlsConfig: (option: LocalisedUrlsOption | undefined) => ResolvedLocalisedUrlsConfig;
11
24
  export declare const validateLocalisedUrls: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => void;
12
25
  export declare const applyLocalisedUrlsToRoutes: (routes: (NestedRouteForCli | PageRoute)[], languages: string[], localisedUrls: LocalisedUrlsMap) => (NestedRouteForCli | PageRoute)[];
@@ -19,3 +32,5 @@ export declare const resolveLocalisedPath: (pathname: string, targetLanguage: st
19
32
  * against every language variant and rebuilt from the canonical map key.
20
33
  */
21
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;
@@ -10,12 +10,14 @@ export interface BaseLocaleDetectionOptions {
10
10
  /**
11
11
  * Enables localised pathnames in addition to the locale prefix.
12
12
  *
13
- * - `false`: keep only locale-prefix behavior (`/en/about`).
14
- * - object: map canonical route paths to every configured language.
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`).
15
18
  *
16
- * Defaults to `true` when `localePathRedirect` is enabled, so route
17
- * generation validates that every localisable route path has entries for all
18
- * configured languages.
19
+ * Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
20
+ * exactly like upstream Modern.js.
19
21
  */
20
22
  localisedUrls?: LocalisedUrlsOption;
21
23
  }
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "modern",
18
18
  "modern.js"
19
19
  ],
20
- "version": "3.2.0-ultramodern.120",
20
+ "version": "3.2.0-ultramodern.122",
21
21
  "engines": {
22
22
  "node": ">=20"
23
23
  },
@@ -87,23 +87,26 @@
87
87
  "i18next-fs-backend": "^2.6.6",
88
88
  "i18next-http-backend": "^4.0.0",
89
89
  "i18next-http-middleware": "^3.9.7",
90
- "react-i18next": "17.0.8",
91
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.120",
92
- "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.120",
93
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.120",
94
- "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.120",
95
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.120",
96
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.120"
90
+ "@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.2.0-ultramodern.122",
91
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.122",
92
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.122",
93
+ "@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.2.0-ultramodern.122",
94
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.122",
95
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.122"
97
96
  },
98
97
  "peerDependencies": {
99
- "@modern-js/runtime": "3.2.0-ultramodern.120",
98
+ "@modern-js/runtime": "3.2.0-ultramodern.122",
100
99
  "i18next": ">=25.7.4",
101
100
  "react": "^19.2.7",
102
- "react-dom": "^19.2.7"
101
+ "react-dom": "^19.2.7",
102
+ "react-i18next": "^17.0.0"
103
103
  },
104
104
  "peerDependenciesMeta": {
105
105
  "i18next": {
106
106
  "optional": true
107
+ },
108
+ "react-i18next": {
109
+ "optional": true
107
110
  }
108
111
  },
109
112
  "devDependencies": {
@@ -115,9 +118,10 @@
115
118
  "jest": "^30.4.2",
116
119
  "react": "^19.2.7",
117
120
  "react-dom": "^19.2.7",
121
+ "react-i18next": "17.0.8",
118
122
  "ts-jest": "^29.4.11",
119
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.120",
120
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.120"
123
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.122",
124
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.122"
121
125
  },
122
126
  "sideEffects": false,
123
127
  "publishConfig": {
package/rstest.config.mts CHANGED
@@ -26,7 +26,12 @@ export default {
26
26
  withTestPreset({
27
27
  name: 'plugin-i18n-node',
28
28
  testEnvironment: 'node',
29
- include: ['tests/localisedUrls.test.ts', 'tests/linkTypes.test.ts'],
29
+ include: [
30
+ 'tests/i18nUtils.test.ts',
31
+ 'tests/localisedUrls.test.ts',
32
+ 'tests/linkTypes.test.ts',
33
+ 'tests/backendDefaults.test.ts',
34
+ ],
30
35
  extends: commonConfig,
31
36
  }),
32
37
  withTestPreset({
@@ -119,7 +119,17 @@ export interface LinkBaseProps extends AnchorRest {
119
119
  search?: string | Record<string, unknown>;
120
120
  hashScrollIntoView?: boolean | ScrollIntoViewOptions;
121
121
  replace?: boolean;
122
+ /**
123
+ * Prefetching behavior, forwarded to the underlying router link:
124
+ * react-router gets it verbatim (Modern.js `PrefetchLink` supports it),
125
+ * TanStack receives it as its native `preload` prop (`'none'` -> `false`).
126
+ * Stripped from plain `<a>` fallbacks (external / no-router targets).
127
+ */
122
128
  prefetch?: 'intent' | 'render' | 'viewport' | 'none';
129
+ /**
130
+ * Native preload value of the underlying router link. When set, it wins
131
+ * over `prefetch` on the TanStack branch.
132
+ */
123
133
  preload?: unknown;
124
134
  activeOptions?: LinkActiveOptions;
125
135
  /** Extra anchor props applied when the link is active. */
@@ -215,6 +225,8 @@ export const Link = <TTo extends string = string>(
215
225
  hashScrollIntoView,
216
226
  activeOptions,
217
227
  activeProps,
228
+ prefetch,
229
+ preload,
218
230
  ...rest
219
231
  } = props as LinkBaseProps & { to: string; params?: LinkParams };
220
232
 
@@ -320,12 +332,7 @@ export const Link = <TTo extends string = string>(
320
332
 
321
333
  // External targets and same-page anchors are vanilla links.
322
334
  if (!target) {
323
- const {
324
- prefetch: _prefetch,
325
- preload: _preload,
326
- replace: _replace,
327
- ...anchorProps
328
- } = rest;
335
+ const { replace: _replace, ...anchorProps } = rest;
329
336
 
330
337
  return (
331
338
  <a href={to} {...anchorProps}>
@@ -337,12 +344,7 @@ export const Link = <TTo extends string = string>(
337
344
  const { Link: RouterLink, hasRouter, framework } = adapter;
338
345
 
339
346
  if (!hasRouter || !RouterLink) {
340
- const {
341
- prefetch: _prefetch,
342
- preload: _preload,
343
- replace: _replace,
344
- ...anchorProps
345
- } = rest;
347
+ const { replace: _replace, ...anchorProps } = rest;
346
348
  const {
347
349
  className: activeClassName,
348
350
  style: activeStyle,
@@ -378,6 +380,17 @@ export const Link = <TTo extends string = string>(
378
380
  };
379
381
 
380
382
  if (framework === 'tanstack') {
383
+ // TanStack's prop is `preload`; map our react-router-flavored `prefetch`
384
+ // onto it (`'none'` -> `false`). An explicit native `preload` wins.
385
+ const tanstackPreload =
386
+ preload !== undefined
387
+ ? preload
388
+ : prefetch === undefined
389
+ ? undefined
390
+ : prefetch === 'none'
391
+ ? false
392
+ : prefetch;
393
+
381
394
  // Pass hash/search natively: string-concatenated targets silently break
382
395
  // TanStack navigation.
383
396
  return (
@@ -386,6 +399,7 @@ export const Link = <TTo extends string = string>(
386
399
  {...(target.searchObject ? { search: target.searchObject } : {})}
387
400
  {...(target.hash ? { hash: target.hash } : {})}
388
401
  {...(hashScrollIntoView === undefined ? {} : { hashScrollIntoView })}
402
+ {...(tanstackPreload === undefined ? {} : { preload: tanstackPreload })}
389
403
  {...rest}
390
404
  {...activeRest}
391
405
  {...activeAttributes}
@@ -400,6 +414,8 @@ export const Link = <TTo extends string = string>(
400
414
  return (
401
415
  <RouterLink
402
416
  to={target.href}
417
+ {...(prefetch === undefined ? {} : { prefetch })}
418
+ {...(preload === undefined ? {} : { preload })}
403
419
  {...rest}
404
420
  {...activeRest}
405
421
  {...activeAttributes}
@@ -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 {
@@ -1,9 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
- import {
4
- resolveCanonicalLocalisedPath,
5
- resolveLocalisedUrlsConfig,
6
- } from '../shared/localisedUrls';
3
+ import { canonicalTargetPathname } from '../shared/localisedUrls';
7
4
  import { useModernI18n } from './context';
8
5
  import { useI18nRouterAdapter } from './routerAdapter';
9
6
  import { buildLocalizedUrl, splitUrlTarget } from './utils';
@@ -35,19 +32,11 @@ export const canonicalPath = (
35
32
  config: LocalizedPathsConfig,
36
33
  ): string => {
37
34
  const { pathname, search, hash } = splitUrlTarget(target);
38
- const segments = pathname.split('/').filter(Boolean);
39
- const pathWithoutLanguage =
40
- segments.length > 0 && config.languages.includes(segments[0])
41
- ? `/${segments.slice(1).join('/')}`
42
- : pathname || '/';
43
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(config.localisedUrls);
44
- const resolvedPath = localisedUrlsConfig.enabled
45
- ? resolveCanonicalLocalisedPath(
46
- pathWithoutLanguage,
47
- config.languages,
48
- localisedUrlsConfig.map,
49
- )
50
- : pathWithoutLanguage;
35
+ const resolvedPath = canonicalTargetPathname(
36
+ pathname,
37
+ config.languages,
38
+ config.localisedUrls,
39
+ );
51
40
 
52
41
  return `${resolvedPath}${search}${hash}`;
53
42
  };
@@ -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
  }
@@ -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()) {
@@ -74,26 +71,17 @@ export const buildLocalizedUrl = (
74
71
  target: string,
75
72
  language: string,
76
73
  languages: string[],
77
- localisedUrls?: boolean | LocalisedUrlsMap,
74
+ localisedUrls?: LocalisedUrlsOption,
78
75
  ): string => {
79
76
  const { pathname, search, hash } = splitUrlTarget(target);
80
- const segments = pathname.split('/').filter(Boolean);
81
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
82
- const pathWithoutLanguage =
83
- segments.length > 0 && languages.includes(segments[0])
84
- ? `/${segments.slice(1).join('/')}`
85
- : pathname || '/';
86
- const resolvedPath = localisedUrlsConfig.enabled
87
- ? resolveLocalisedPath(
88
- pathWithoutLanguage,
89
- language,
90
- languages,
91
- localisedUrlsConfig.map,
92
- )
93
- : pathWithoutLanguage;
94
- const resolvedSegments = resolvedPath.split('/').filter(Boolean);
95
-
96
- return `/${[language, ...resolvedSegments].join('/')}${search}${hash}`;
77
+ const localizedPathname = localiseTargetPathname(
78
+ pathname,
79
+ language,
80
+ languages,
81
+ localisedUrls,
82
+ );
83
+
84
+ return `${localizedPathname}${search}${hash}`;
97
85
  };
98
86
 
99
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 =