@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 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
@@ -1,20 +1,71 @@
1
1
  import "node:module";
2
+ import fs from "fs";
3
+ import path_0 from "path";
4
+ const CONVENTIONAL_LOCALES_DIRS = [
5
+ './locales',
6
+ './config/public/locales'
7
+ ];
8
+ const isDirectory = (dirPath)=>{
9
+ try {
10
+ return fs.statSync(dirPath).isDirectory();
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+ const resolveDefaultLocalesDir = (cwd = process.cwd())=>{
16
+ for (const dir of CONVENTIONAL_LOCALES_DIRS)if (isDirectory(path_0.resolve(cwd, dir))) return dir;
17
+ return CONVENTIONAL_LOCALES_DIRS[0];
18
+ };
2
19
  const DEFAULT_I18NEXT_BACKEND_OPTIONS = {
3
- loadPath: './config/public/locales/{{lng}}/{{ns}}.json',
4
- addPath: './config/public/locales/{{lng}}/{{ns}}.json'
20
+ get loadPath () {
21
+ return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
22
+ },
23
+ get addPath () {
24
+ return `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`;
25
+ }
5
26
  };
6
27
  function convertPath(path) {
7
28
  if (!path) return path;
8
29
  if (path.startsWith('/')) return `.${path}`;
9
30
  return path;
10
31
  }
32
+ function shouldUseServerPath(currentPath, detectedPath) {
33
+ return !detectedPath || currentPath === detectedPath;
34
+ }
35
+ function getResourceBasePath(resourcePath) {
36
+ const markerIndex = resourcePath.indexOf('{{lng}}');
37
+ if (markerIndex < 0) return resourcePath;
38
+ return resourcePath.slice(0, markerIndex).replace(/[\\/]+$/, '');
39
+ }
40
+ function pathExists(resourcePath) {
41
+ try {
42
+ return fs.existsSync(getResourceBasePath(resourcePath));
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ function getServerPath(pathCandidates, fallbackPath) {
48
+ const candidates = Array.from(new Set([
49
+ ...pathCandidates || [],
50
+ fallbackPath
51
+ ].filter(Boolean)));
52
+ return candidates.find(pathExists) || candidates[0];
53
+ }
11
54
  function convertBackendOptions(options) {
12
55
  if (!options) return options;
13
56
  const converted = {
14
57
  ...options
15
58
  };
16
- if (converted.loadPath) converted.loadPath = convertPath(converted.loadPath);
17
- if (converted.addPath) converted.addPath = convertPath(converted.addPath);
59
+ if ((converted.serverLoadPath || converted.serverLoadPaths) && shouldUseServerPath(converted.loadPath, converted._detectedLoadPath)) converted.loadPath = getServerPath(converted.serverLoadPaths, converted.serverLoadPath);
60
+ else if (converted.loadPath) converted.loadPath = convertPath(converted.loadPath);
61
+ if ((converted.serverAddPath || converted.serverAddPaths) && shouldUseServerPath(converted.addPath, converted._detectedAddPath)) converted.addPath = getServerPath(converted.serverAddPaths, converted.serverAddPath);
62
+ else if (converted.addPath) converted.addPath = convertPath(converted.addPath);
63
+ delete converted.serverLoadPath;
64
+ delete converted.serverAddPath;
65
+ delete converted.serverLoadPaths;
66
+ delete converted.serverAddPaths;
67
+ delete converted._detectedLoadPath;
68
+ delete converted._detectedAddPath;
18
69
  return converted;
19
70
  }
20
- export { DEFAULT_I18NEXT_BACKEND_OPTIONS, convertBackendOptions };
71
+ export { DEFAULT_I18NEXT_BACKEND_OPTIONS, convertBackendOptions, resolveDefaultLocalesDir };
@@ -13,7 +13,7 @@ import { detectLanguageWithPriority, exportServerLngToWindow, mergeDetectionOpti
13
13
  import { useI18nextLanguageDetector } from "./i18n/detection/middleware.mjs";
14
14
  import { getI18nextInstanceForProvider } from "./i18n/instance.mjs";
15
15
  import { changeI18nLanguage, ensureLanguageMatch, initializeI18nInstance, setupClonedInstance } from "./i18n/utils.mjs";
16
- import { getPathname } from "./utils.mjs";
16
+ import { buildLocalizedUrl, getPathname, splitUrlTarget } from "./utils.mjs";
17
17
  import "./types.mjs";
18
18
  const i18nPlugin = (options)=>({
19
19
  name: '@modern-js/plugin-i18n',
@@ -137,5 +137,7 @@ const i18nPlugin = (options)=>({
137
137
  });
138
138
  const runtime = i18nPlugin;
139
139
  export { I18nLink } from "./I18nLink.mjs";
140
+ export { Link } from "./Link.mjs";
141
+ export { canonicalPath, localizePath, useLocalizedLocation, useLocalizedPaths } from "./localizedPaths.mjs";
140
142
  export default runtime;
141
- export { i18nPlugin, useModernI18n };
143
+ export { buildLocalizedUrl, i18nPlugin, splitUrlTarget, useModernI18n };
@@ -0,0 +1,56 @@
1
+ import "node:module";
2
+ import { useMemo } from "react";
3
+ import { canonicalTargetPathname } from "../shared/localisedUrls.mjs";
4
+ import { useModernI18n } from "./context.mjs";
5
+ import { useI18nRouterAdapter } from "./routerAdapter.mjs";
6
+ import { buildLocalizedUrl, splitUrlTarget } from "./utils.mjs";
7
+ const localizePath = (pathname, language, config)=>buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
8
+ const canonicalPath = (target, config)=>{
9
+ const { pathname, search, hash } = splitUrlTarget(target);
10
+ const resolvedPath = canonicalTargetPathname(pathname, config.languages, config.localisedUrls);
11
+ return `${resolvedPath}${search}${hash}`;
12
+ };
13
+ const useLocalizedPaths = ()=>{
14
+ const { supportedLanguages, localisedUrls } = useModernI18n();
15
+ return useMemo(()=>{
16
+ const config = {
17
+ languages: supportedLanguages,
18
+ localisedUrls
19
+ };
20
+ return {
21
+ localizePath: (pathname, language)=>localizePath(pathname, language, config),
22
+ canonicalPath: (pathname)=>canonicalPath(pathname, config)
23
+ };
24
+ }, [
25
+ supportedLanguages,
26
+ localisedUrls
27
+ ]);
28
+ };
29
+ const useLocalizedLocation = ()=>{
30
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
31
+ const { location } = useI18nRouterAdapter();
32
+ const pathname = location?.pathname ?? '/';
33
+ const search = location?.search ?? '';
34
+ const hash = location?.hash ?? '';
35
+ return useMemo(()=>{
36
+ const config = {
37
+ languages: supportedLanguages,
38
+ localisedUrls
39
+ };
40
+ const alternates = {};
41
+ for (const supportedLanguage of supportedLanguages)alternates[supportedLanguage] = `${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
42
+ return {
43
+ language,
44
+ canonical: canonicalPath(pathname, config),
45
+ alternates
46
+ };
47
+ }, [
48
+ language,
49
+ supportedLanguages,
50
+ localisedUrls,
51
+ pathname,
52
+ search,
53
+ hash
54
+ ]);
55
+ };
56
+ export { canonicalPath, localizePath, useLocalizedLocation, useLocalizedPaths };
@@ -1,6 +1,6 @@
1
1
  import "node:module";
2
2
  import { RuntimeContext, isBrowser } from "@modern-js/runtime";
3
- import { InternalRuntimeContext } from "@modern-js/runtime/context";
3
+ import { InternalRuntimeContext, getRouterRuntimeState } from "@modern-js/runtime/context";
4
4
  import { Link as router_Link, useInRouterContext, useLocation, useNavigate, useParams } from "@modern-js/runtime/router";
5
5
  import { useCallback, useContext, useEffect, useState } from "react";
6
6
  const normalizeUrlPart = (value, prefix)=>{
@@ -26,7 +26,7 @@ const getWindowLocation = ()=>{
26
26
  };
27
27
  };
28
28
  const getRouterFramework = (runtimeContext, internalContext, inReactRouter)=>{
29
- const framework = internalContext.routerFramework || internalContext.routerRuntime?.framework || runtimeContext.routerFramework;
29
+ const framework = getRouterRuntimeState(internalContext)?.framework || getRouterRuntimeState(runtimeContext)?.framework;
30
30
  if (framework) return framework;
31
31
  if (internalContext.router?.useRouter || runtimeContext.router?.useRouter) return 'tanstack';
32
32
  if (internalContext.router?.useLocation || internalContext.router?.useHref || runtimeContext.router?.useLocation || runtimeContext.router?.useHref) return 'react-router';
@@ -34,7 +34,7 @@ const getRouterFramework = (runtimeContext, internalContext, inReactRouter)=>{
34
34
  };
35
35
  const getRouterInstance = (internalContext, contextRouter)=>{
36
36
  if (contextRouter) return contextRouter;
37
- const router = internalContext.routerInstance || internalContext.routerRuntime?.instance;
37
+ const router = getRouterRuntimeState(internalContext)?.instance;
38
38
  if (!router || 'object' != typeof router) return null;
39
39
  return router;
40
40
  };
@@ -1,7 +1,7 @@
1
1
  import "node:module";
2
2
  import { isBrowser } from "@modern-js/runtime";
3
3
  import { getGlobalBasename } from "@modern-js/runtime/context";
4
- import { resolveLocalisedPath, resolveLocalisedUrlsConfig } from "../shared/localisedUrls.mjs";
4
+ import { localiseTargetPathname } from "../shared/localisedUrls.mjs";
5
5
  const getPathname = (context)=>{
6
6
  if (isBrowser()) return window.location.pathname;
7
7
  return context.ssrContext?.request?.pathname || '/';
@@ -17,16 +17,23 @@ const getLanguageFromPath = (pathname, languages, fallbackLanguage)=>{
17
17
  if (languages.includes(firstSegment)) return firstSegment;
18
18
  return fallbackLanguage;
19
19
  };
20
- const buildLocalizedUrl = (pathname, language, languages, localisedUrls)=>{
21
- const segments = pathname.split('/').filter(Boolean);
22
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
23
- const pathWithoutLanguage = segments.length > 0 && languages.includes(segments[0]) ? `/${segments.slice(1).join('/')}` : pathname;
24
- const resolvedPath = localisedUrlsConfig.enabled ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
25
- const resolvedSegments = resolvedPath.split('/').filter(Boolean);
26
- return `/${[
27
- language,
28
- ...resolvedSegments
29
- ].join('/')}`;
20
+ const splitUrlTarget = (target)=>{
21
+ const hashIndex = target.indexOf('#');
22
+ const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
23
+ const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
24
+ const searchIndex = beforeHash.indexOf('?');
25
+ const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
26
+ const pathname = searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
27
+ return {
28
+ pathname,
29
+ search,
30
+ hash
31
+ };
32
+ };
33
+ const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
34
+ const { pathname, search, hash } = splitUrlTarget(target);
35
+ const localizedPathname = localiseTargetPathname(pathname, language, languages, localisedUrls);
36
+ return `${localizedPathname}${search}${hash}`;
30
37
  };
31
38
  const detectLanguageFromPath = (pathname, languages, localePathRedirect)=>{
32
39
  if (!localePathRedirect) return {
@@ -54,4 +61,4 @@ const shouldIgnoreRedirect = (pathname, languages, ignoreRedirectRoutes)=>{
54
61
  if ('function' == typeof ignoreRedirectRoutes) return ignoreRedirectRoutes(normalizedPath);
55
62
  return ignoreRedirectRoutes.some((pattern)=>normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`));
56
63
  };
57
- export { buildLocalizedUrl, detectLanguageFromPath, getEntryPath, getLanguageFromPath, getPathname, shouldIgnoreRedirect };
64
+ export { buildLocalizedUrl, detectLanguageFromPath, getEntryPath, getLanguageFromPath, getPathname, shouldIgnoreRedirect, splitUrlTarget };
@@ -1,6 +1,6 @@
1
1
  import "node:module";
2
2
  import { DEFAULT_I18NEXT_DETECTION_OPTIONS, mergeDetectionOptions } from "../runtime/i18n/detection/config.mjs";
3
- import { resolveLocalisedPath, resolveLocalisedUrlsConfig } from "../shared/localisedUrls.mjs";
3
+ import { localiseTargetPathname, resolveLocalisedUrlsConfig } from "../shared/localisedUrls.mjs";
4
4
  import { getLocaleDetectionOptions } from "../shared/utils.mjs";
5
5
  import * as __rspack_external__modern_js_server_core_hono_a76ca254 from "@modern-js/server-core/hono";
6
6
  const { languageDetector: languageDetector } = __rspack_external__modern_js_server_core_hono_a76ca254;
@@ -128,15 +128,7 @@ const buildLocalizedUrl = (req, urlPath, language, languages, localisedUrls)=>{
128
128
  const pathname = url.pathname;
129
129
  const basePath = urlPath.replace('/*', '');
130
130
  const remainingPath = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
131
- const segments = remainingPath.split('/').filter(Boolean);
132
- const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
133
- const pathWithoutLanguage = segments.length > 0 && languages.includes(segments[0]) ? `/${segments.slice(1).join('/')}` : remainingPath;
134
- const resolvedPath = localisedUrlsConfig.enabled ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
135
- const resolvedSegments = resolvedPath.split('/').filter(Boolean);
136
- const newPathname = `/${[
137
- language,
138
- ...resolvedSegments
139
- ].join('/')}`;
131
+ const newPathname = localiseTargetPathname(remainingPath, language, languages, localisedUrls);
140
132
  const suffix = `${url.search}${url.hash}`;
141
133
  const localizedUrl = '/' === basePath ? newPathname + suffix : basePath + newPathname + suffix;
142
134
  return localizedUrl;
@@ -4,12 +4,13 @@ const LOCALE_PARAM_NAMES = new Set([
4
4
  'locale',
5
5
  'language'
6
6
  ]);
7
- const normalisePathPattern = (path)=>{
7
+ const normaliseSlashes = (path)=>{
8
8
  const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
9
9
  const withLeadingSlash = withoutDuplicateSlashes.startsWith('/') ? withoutDuplicateSlashes : `/${withoutDuplicateSlashes}`;
10
- const withoutTrailingSlash = withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, '') : withLeadingSlash;
11
- return withoutTrailingSlash.replace(/\[(.+?)\]/g, ':$1');
10
+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, '') : withLeadingSlash;
12
11
  };
12
+ const normalisePathPattern = (path)=>normaliseSlashes(path).replace(/\[(.+?)\]/g, ':$1');
13
+ const normalisePathname = (pathname)=>normaliseSlashes(pathname);
13
14
  const normaliseRoutePath = (path)=>{
14
15
  const normalized = normalisePathPattern(path);
15
16
  return '/' === normalized ? '' : normalized.slice(1);
@@ -35,16 +36,12 @@ const getLeadingLocaleParam = (path)=>{
35
36
  return getLocaleParamSegment(segments[0] || '');
36
37
  };
37
38
  const resolveLocalisedUrlsConfig = (option)=>{
38
- if (false === option) return {
39
- enabled: false,
40
- map: {}
41
- };
42
- if (option && 'object' == typeof option) return {
39
+ if (option && 'object' == typeof option && Object.keys(option).length > 0) return {
43
40
  enabled: true,
44
41
  map: option
45
42
  };
46
43
  return {
47
- enabled: true,
44
+ enabled: false,
48
45
  map: {}
49
46
  };
50
47
  };
@@ -101,7 +98,7 @@ const transformLocalisedRoute = (route, parentCanonicalPath, parentLocalisedPath
101
98
  if (!localisedUrlEntry) return [
102
99
  baseRoute
103
100
  ];
104
- return getLocalisedRoutePaths(canonicalPath, parentLocalisedPaths, languages, localisedUrlEntry).map((localisedPath, index)=>cloneRouteWithLocalisedPath(baseRoute, localisedPath, index));
101
+ return getLocalisedRoutePaths(canonicalPath, parentLocalisedPaths, languages, localisedUrlEntry).map((localisedPath, index)=>cloneRouteWithLocalisedPath(baseRoute, localisedPath, index, canonicalPath));
105
102
  };
106
103
  const legalRouteIdPart = (value)=>value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
107
104
  const suffixRouteIds = (route, suffix)=>{
@@ -116,13 +113,14 @@ const suffixRouteIds = (route, suffix)=>{
116
113
  } : {}
117
114
  };
118
115
  };
119
- const cloneRouteWithLocalisedPath = (route, path, index)=>{
116
+ const cloneRouteWithLocalisedPath = (route, path, index, canonicalPath)=>{
120
117
  const leadingLocaleParam = getLeadingLocaleParam(route.path);
121
118
  const localisedPath = leadingLocaleParam ? normaliseRoutePath(`${leadingLocaleParam}/${path}`) : path;
122
119
  const routeWithPath = {
123
120
  ...route,
124
121
  path: localisedPath
125
122
  };
123
+ routeWithPath.modernCanonicalPath = canonicalPath;
126
124
  return 0 === index ? routeWithPath : suffixRouteIds(routeWithPath, legalRouteIdPart(localisedPath));
127
125
  };
128
126
  const applyLocalisedUrlsToRoutes = (routes, languages, localisedUrls)=>{
@@ -135,9 +133,13 @@ const applyLocalisedUrlsToRoutes = (routes, languages, localisedUrls)=>{
135
133
  };
136
134
  const escapeRegExp = (value)=>value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
135
  const getParamName = (segment)=>segment.slice(1).replace(/\?$/, '');
136
+ const compiledPathPatternCache = new Map();
138
137
  const compilePathPattern = (pattern)=>{
138
+ const normalizedPattern = normalisePathPattern(pattern);
139
+ const cached = compiledPathPatternCache.get(normalizedPattern);
140
+ if (cached) return cached;
139
141
  const names = [];
140
- const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
142
+ const segments = normalizedPattern.split('/').filter(Boolean);
141
143
  const source = segments.map((segment)=>{
142
144
  if (segment.startsWith(':')) {
143
145
  names.push(getParamName(segment));
@@ -150,19 +152,55 @@ const compilePathPattern = (pattern)=>{
150
152
  }
151
153
  return `/${escapeRegExp(segment)}`;
152
154
  }).join('');
153
- return {
155
+ const compiled = {
154
156
  names,
155
157
  regexp: new RegExp(`^${source || '/'}$`)
156
158
  };
159
+ compiledPathPatternCache.set(normalizedPattern, compiled);
160
+ return compiled;
161
+ };
162
+ const getPatternSpecificity = (pattern)=>{
163
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
164
+ let staticSegments = 0;
165
+ let dynamicSegments = 0;
166
+ let splatSegments = 0;
167
+ for (const segment of segments)if ('*' === segment) splatSegments++;
168
+ else if (segment.startsWith(':')) dynamicSegments++;
169
+ else staticSegments++;
170
+ return {
171
+ staticSegments,
172
+ dynamicSegments,
173
+ splatSegments,
174
+ totalSegments: segments.length
175
+ };
176
+ };
177
+ const comparePatternSpecificity = (left, right)=>{
178
+ const a = getPatternSpecificity(left);
179
+ const b = getPatternSpecificity(right);
180
+ return b.staticSegments - a.staticSegments || b.totalSegments - a.totalSegments || a.splatSegments - b.splatSegments || a.dynamicSegments - b.dynamicSegments;
181
+ };
182
+ const sortPatternsBySpecificity = (patterns)=>patterns.map((pattern, index)=>({
183
+ pattern,
184
+ index
185
+ })).sort((left, right)=>comparePatternSpecificity(left.pattern.pattern, right.pattern.pattern) || left.index - right.index).map(({ pattern })=>pattern);
186
+ const decodePathParam = (value)=>{
187
+ try {
188
+ return decodeURIComponent(value);
189
+ } catch {
190
+ return null;
191
+ }
157
192
  };
158
193
  const matchPathPattern = (pathname, pattern)=>{
159
194
  const { names, regexp } = compilePathPattern(pattern);
160
- const match = regexp.exec(normalisePathPattern(pathname));
195
+ const match = regexp.exec(normalisePathname(pathname));
161
196
  if (!match) return null;
162
- return names.reduce((params, name, index)=>{
163
- params[name] = decodeURIComponent(match[index + 1] || '');
164
- return params;
165
- }, {});
197
+ const params = {};
198
+ for(let index = 0; index < names.length; index++){
199
+ const decoded = decodePathParam(match[index + 1] || '');
200
+ if (null === decoded) return null;
201
+ params[names[index]] = decoded;
202
+ }
203
+ return params;
166
204
  };
167
205
  const buildPathFromPattern = (pattern, params)=>{
168
206
  const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
@@ -177,16 +215,70 @@ const buildPathFromPattern = (pattern, params)=>{
177
215
  return `/${path}`;
178
216
  };
179
217
  const resolveLocalisedPath = (pathname, targetLanguage, languages, localisedUrls)=>{
180
- const normalizedPathname = normalisePathPattern(pathname);
181
- for (const localisedUrlEntry of Object.values(localisedUrls)){
218
+ const normalizedPathname = normalisePathname(pathname);
219
+ const canonicalCandidates = sortPatternsBySpecificity(Object.entries(localisedUrls).map(([canonicalPattern, localisedUrlEntry])=>({
220
+ pattern: canonicalPattern,
221
+ canonicalPattern,
222
+ localisedUrlEntry
223
+ })));
224
+ for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates){
225
+ const targetPattern = localisedUrlEntry[targetLanguage];
226
+ if (!targetPattern) continue;
227
+ const params = matchPathPattern(normalizedPathname, canonicalPattern);
228
+ if (params) return buildPathFromPattern(targetPattern, params);
229
+ }
230
+ const localisedCandidates = sortPatternsBySpecificity(Object.values(localisedUrls).flatMap((localisedUrlEntry)=>{
182
231
  const targetPattern = localisedUrlEntry[targetLanguage];
183
- if (targetPattern) for (const language of languages){
232
+ if (!targetPattern) return [];
233
+ return languages.map((language)=>localisedUrlEntry[language]).filter((sourcePattern)=>Boolean(sourcePattern)).map((sourcePattern)=>({
234
+ pattern: sourcePattern,
235
+ sourcePattern,
236
+ targetPattern
237
+ }));
238
+ }));
239
+ for (const { sourcePattern, targetPattern } of localisedCandidates){
240
+ const params = matchPathPattern(normalizedPathname, sourcePattern);
241
+ if (params) return buildPathFromPattern(targetPattern, params);
242
+ }
243
+ return normalizedPathname;
244
+ };
245
+ const resolveCanonicalLocalisedPath = (pathname, languages, localisedUrls)=>{
246
+ const normalizedPathname = normalisePathname(pathname);
247
+ const canonicalCandidates = sortPatternsBySpecificity(Object.entries(localisedUrls).map(([canonicalPattern, localisedUrlEntry])=>({
248
+ pattern: canonicalPattern,
249
+ canonicalPattern,
250
+ localisedUrlEntry
251
+ })));
252
+ for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates){
253
+ const canonicalParams = matchPathPattern(normalizedPathname, canonicalPattern);
254
+ if (canonicalParams) return buildPathFromPattern(canonicalPattern, canonicalParams);
255
+ for (const language of languages){
184
256
  const sourcePattern = localisedUrlEntry[language];
185
257
  if (!sourcePattern) continue;
186
258
  const params = matchPathPattern(normalizedPathname, sourcePattern);
187
- if (params) return buildPathFromPattern(targetPattern, params);
259
+ if (params) return buildPathFromPattern(canonicalPattern, params);
188
260
  }
189
261
  }
190
262
  return normalizedPathname;
191
263
  };
192
- export { applyLocalisedUrlsToRoutes, normalisePathPattern, resolveLocalisedPath, resolveLocalisedUrlsConfig, validateLocalisedUrls };
264
+ const stripLanguagePrefix = (pathname, languages)=>{
265
+ const segments = pathname.split('/').filter(Boolean);
266
+ if (segments.length > 0 && languages.includes(segments[0])) return `/${segments.slice(1).join('/')}`;
267
+ return pathname || '/';
268
+ };
269
+ const localiseTargetPathname = (pathname, language, languages, localisedUrls)=>{
270
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
271
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
272
+ const resolvedPath = localisedUrlsConfig.enabled ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
273
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
274
+ return `/${[
275
+ language,
276
+ ...resolvedSegments
277
+ ].join('/')}`;
278
+ };
279
+ const canonicalTargetPathname = (pathname, languages, localisedUrls)=>{
280
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
281
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
282
+ return localisedUrlsConfig.enabled ? resolveCanonicalLocalisedPath(pathWithoutLanguage, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
283
+ };
284
+ export { applyLocalisedUrlsToRoutes, buildPathFromPattern, canonicalTargetPathname, localiseTargetPathname, matchPathPattern, normalisePathPattern, normalisePathname, resolveCanonicalLocalisedPath, resolveLocalisedPath, resolveLocalisedUrlsConfig, validateLocalisedUrls };
@@ -1,6 +1,7 @@
1
1
  import type { AppTools, CliPlugin } from '@modern-js/app-tools';
2
2
  import type { Entrypoint } from '@modern-js/types';
3
3
  import type { BackendOptions, LocaleDetectionOptions } from '../shared/type';
4
+ import '../runtime/types';
4
5
  export type TransformRuntimeConfigFn = (extendedConfig: Record<string, any>, entrypoint: Entrypoint) => Record<string, any>;
5
6
  export interface I18nPluginOptions {
6
7
  localeDetection?: LocaleDetectionOptions;
@@ -0,0 +1,17 @@
1
+ import type { BackendOptions } from '../shared/type';
2
+ interface NormalizedConfigForLocales {
3
+ server?: {
4
+ publicDir?: string | string[];
5
+ };
6
+ }
7
+ export interface DetectedLocalesDirectory {
8
+ loadPath: string;
9
+ addPath: string;
10
+ serverLoadPath: string;
11
+ serverAddPath: string;
12
+ serverLoadPaths?: string[];
13
+ serverAddPaths?: string[];
14
+ }
15
+ export declare function detectLocalesDirectory(appDirectory: string, normalizedConfig?: NormalizedConfigForLocales): DetectedLocalesDirectory | undefined;
16
+ export declare function applyDetectedBackendPaths(backendOptions: BackendOptions, detectedLocales: DetectedLocalesDirectory | undefined): BackendOptions;
17
+ export {};
@@ -5,19 +5,10 @@ export interface I18nLinkProps {
5
5
  [key: string]: any;
6
6
  }
7
7
  /**
8
- * I18nLink component that automatically adds language prefix to navigation links.
9
- * This component should be used within a :lang dynamic route context.
10
- *
11
- * @example
12
- * ```tsx
13
- * // When current language is 'en' and to="/about"
14
- * // The actual link will be "/en/about"
15
- * <I18nLink to="/about">About</I18nLink>
16
- *
17
- * // When current language is 'zh' and to="/"
18
- * // The actual link will be "/zh"
19
- * <I18nLink to="/">Home</I18nLink>
20
- * ```
8
+ * @deprecated Use {@link Link} from `@modern-js/plugin-i18n/runtime` instead.
9
+ * `Link` accepts the same language-agnostic `to` values and additionally
10
+ * supports `#hash`/`?query` targets, typed canonical routes, `params`
11
+ * interpolation and language-invariant active state.
21
12
  */
22
13
  export declare const I18nLink: React.FC<I18nLinkProps>;
23
14
  export default I18nLink;
@@ -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 {};