@bleedingdev/modern-js-plugin-i18n 3.5.0-ultramodern.35 → 3.5.0-ultramodern.37

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 (91) hide show
  1. package/dist/cjs/cli/locales.js +15 -15
  2. package/dist/cjs/runtime/i18n/backend/defaults.node.js +7 -7
  3. package/dist/cjs/runtime/i18n/detection/cache.js +3 -2
  4. package/dist/cjs/runtime/i18n/instance.js +7 -7
  5. package/dist/cjs/runtime/utils.js +1 -9
  6. package/dist/cjs/server/redirectPolicy.js +14 -20
  7. package/dist/cjs/shared/detection.js +2 -104
  8. package/dist/cjs/shared/localisedUrls/index.js +16 -0
  9. package/dist/cjs/shared/localisedUrls/pathname.js +5 -2
  10. package/dist/cjs/shared/localisedUrls/redirect.js +95 -0
  11. package/dist/cjs/shared/localisedUrls/resolve.js +29 -32
  12. package/dist/cjs/shared/localisedUrls.js +15 -0
  13. package/dist/cjs/shared/utils.js +2 -8
  14. package/dist/esm/cli/locales.mjs +13 -13
  15. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +5 -5
  16. package/dist/esm/runtime/i18n/detection/cache.mjs +3 -2
  17. package/dist/esm/runtime/i18n/instance.mjs +8 -5
  18. package/dist/esm/runtime/utils.mjs +2 -10
  19. package/dist/esm/server/index.mjs +2 -2
  20. package/dist/esm/server/redirectPolicy.mjs +15 -21
  21. package/dist/esm/shared/detection.mjs +1 -71
  22. package/dist/esm/shared/localisedUrls/index.mjs +2 -1
  23. package/dist/esm/shared/localisedUrls/pathname.mjs +1 -1
  24. package/dist/esm/shared/localisedUrls/redirect.mjs +48 -0
  25. package/dist/esm/shared/localisedUrls/resolve.mjs +29 -32
  26. package/dist/esm/shared/localisedUrls.mjs +1 -1
  27. package/dist/esm/shared/utils.mjs +1 -1
  28. package/dist/esm-node/cli/locales.mjs +13 -13
  29. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +5 -5
  30. package/dist/esm-node/runtime/i18n/detection/cache.mjs +3 -2
  31. package/dist/esm-node/runtime/i18n/instance.mjs +8 -5
  32. package/dist/esm-node/runtime/utils.mjs +2 -10
  33. package/dist/esm-node/server/index.mjs +2 -2
  34. package/dist/esm-node/server/redirectPolicy.mjs +15 -21
  35. package/dist/esm-node/shared/detection.mjs +1 -71
  36. package/dist/esm-node/shared/localisedUrls/index.mjs +2 -1
  37. package/dist/esm-node/shared/localisedUrls/pathname.mjs +1 -1
  38. package/dist/esm-node/shared/localisedUrls/redirect.mjs +49 -0
  39. package/dist/esm-node/shared/localisedUrls/resolve.mjs +29 -32
  40. package/dist/esm-node/shared/localisedUrls.mjs +1 -1
  41. package/dist/esm-node/shared/utils.mjs +1 -1
  42. package/dist/types/cli/locales.d.ts +1 -1
  43. package/dist/types/runtime/context.d.ts +4 -3
  44. package/dist/types/runtime/contextHelpers.d.ts +2 -2
  45. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +2 -1
  46. package/dist/types/runtime/i18n/detection/cache.d.ts +1 -1
  47. package/dist/types/runtime/i18n/detection/detector.d.ts +2 -2
  48. package/dist/types/runtime/i18n/detection/middleware.d.ts +4 -4
  49. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +4 -4
  50. package/dist/types/runtime/i18n/detection/types.d.ts +8 -5
  51. package/dist/types/runtime/i18n/instance.d.ts +28 -26
  52. package/dist/types/runtime/i18n/utils.d.ts +14 -5
  53. package/dist/types/runtime/pluginSetup.d.ts +1 -1
  54. package/dist/types/runtime/providerComposition.d.ts +2 -1
  55. package/dist/types/runtime/routerAdapter.d.ts +7 -6
  56. package/dist/types/server/redirectPolicy.d.ts +14 -8
  57. package/dist/types/shared/detection.d.ts +1 -11
  58. package/dist/types/shared/localisedUrls/index.d.ts +3 -1
  59. package/dist/types/shared/localisedUrls/pathname.d.ts +1 -0
  60. package/dist/types/shared/localisedUrls/redirect.d.ts +11 -0
  61. package/dist/types/shared/localisedUrls/resolve.d.ts +2 -3
  62. package/dist/types/shared/localisedUrls.d.ts +2 -2
  63. package/dist/types/shared/utils.d.ts +0 -2
  64. package/package.json +16 -18
  65. package/src/cli/index.ts +2 -2
  66. package/src/cli/locales.ts +3 -3
  67. package/src/runtime/context.tsx +3 -3
  68. package/src/runtime/contextHelpers.ts +3 -3
  69. package/src/runtime/hooks.ts +10 -2
  70. package/src/runtime/i18n/backend/defaults.node.ts +2 -2
  71. package/src/runtime/i18n/backend/middleware.common.ts +5 -4
  72. package/src/runtime/i18n/detection/cache.ts +23 -9
  73. package/src/runtime/i18n/detection/detector.ts +15 -6
  74. package/src/runtime/i18n/detection/middleware.node.ts +26 -7
  75. package/src/runtime/i18n/detection/middleware.ts +15 -7
  76. package/src/runtime/i18n/detection/types.ts +13 -5
  77. package/src/runtime/i18n/instance.ts +56 -31
  78. package/src/runtime/i18n/utils.ts +18 -9
  79. package/src/runtime/pluginSetup.ts +1 -1
  80. package/src/runtime/providerComposition.tsx +1 -1
  81. package/src/runtime/routerAdapter.tsx +6 -6
  82. package/src/runtime/utils.ts +5 -29
  83. package/src/server/redirectPolicy.ts +51 -76
  84. package/src/shared/detection.ts +1 -1
  85. package/src/shared/localisedUrls/index.ts +12 -1
  86. package/src/shared/localisedUrls/pathname.ts +4 -1
  87. package/src/shared/localisedUrls/redirect.ts +91 -0
  88. package/src/shared/localisedUrls/resolve.ts +91 -74
  89. package/src/shared/localisedUrls.ts +6 -0
  90. package/src/shared/utils.ts +2 -2
  91. package/tests/redirectPolicy.test.ts +43 -0
@@ -1,106 +1,86 @@
1
- import { localiseTargetPathname } from '../shared/localisedUrls.js';
1
+ import {
2
+ isDefaultLocaleRedirectSkipPath,
3
+ localiseTargetPathname,
4
+ matchesPathPrefix,
5
+ shouldSkipLocaleRedirect,
6
+ stripLanguagePrefix,
7
+ } from '../shared/localisedUrls.js';
2
8
  import type { LocaleDetectionOptions } from '../shared/type';
3
9
 
4
- /**
5
- * Check if the given pathname should ignore automatic locale redirect
6
- */
7
- export const shouldIgnoreRedirect = (
8
- pathname: string,
9
- urlPath: string,
10
- ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
11
- ): boolean => {
12
- if (!ignoreRedirectRoutes) {
13
- return false;
14
- }
10
+ interface LocaleRedirectRequest {
11
+ url: string;
12
+ header: () => {
13
+ host?: string;
14
+ };
15
+ }
15
16
 
16
- // Remove urlPath prefix to get remaining path for matching
17
+ const stripUrlPathPrefix = (pathname: string, urlPath: string): string => {
17
18
  const basePath = urlPath.replace('/*', '');
19
+
20
+ if (!basePath || basePath === '/') {
21
+ return pathname;
22
+ }
23
+
18
24
  const remainingPath = pathname.startsWith(basePath)
19
25
  ? pathname.slice(basePath.length)
20
26
  : pathname;
21
27
 
22
- // Normalize path (ensure it starts with /)
23
- const normalizedPath = remainingPath.startsWith('/')
24
- ? remainingPath
25
- : `/${remainingPath}`;
28
+ return remainingPath || '/';
29
+ };
26
30
 
27
- if (typeof ignoreRedirectRoutes === 'function') {
28
- return ignoreRedirectRoutes(normalizedPath);
29
- }
31
+ /**
32
+ * Check if pathname should ignore automatic locale redirect.
33
+ */
34
+ export const shouldIgnoreRedirect = (
35
+ pathname: string,
36
+ urlPath: string,
37
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
38
+ ): boolean => {
39
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
30
40
 
31
- // Check if pathname matches any of the ignore patterns
32
- return ignoreRedirectRoutes.some(pattern => {
33
- // Support both exact match and prefix match
34
- return (
35
- normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
36
- );
37
- });
41
+ return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
38
42
  };
39
43
 
40
44
  /**
41
- * Check if the given pathname is a static resource request
42
- * This includes:
43
- * 1. Paths matching staticRoutePrefixes (from public directories)
44
- * 2. Standard static resource paths like /static/, /upload/
45
- * 3. Paths with language prefix like /en/static/, /zh/static/
45
+ * Check if pathname is a static or federation resource request.
46
+ *
47
+ * This includes configured staticRoutePrefixes, the shared default skip policy
48
+ * from ADR-0002, and language-prefixed variants such as /en/static/app.js.
46
49
  */
47
50
  export const isStaticResourceRequest = (
48
51
  pathname: string,
49
52
  staticRoutePrefixes: string[],
50
53
  languages: string[] = [],
51
54
  ): boolean => {
52
- // Check against staticRoutePrefixes (from public directories)
53
- if (
54
- staticRoutePrefixes.some(
55
- prefix => pathname.startsWith(`${prefix}/`) || pathname === prefix,
56
- )
57
- ) {
55
+ if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
58
56
  return true;
59
57
  }
60
58
 
61
- // Check standard static resource paths
62
- const standardStaticPrefixes = ['/static/', '/upload/'];
63
- if (standardStaticPrefixes.some(prefix => pathname.startsWith(prefix))) {
59
+ const matchesStaticRoutePrefix = (targetPathname: string): boolean =>
60
+ staticRoutePrefixes.some(prefix =>
61
+ matchesPathPrefix(targetPathname, prefix),
62
+ );
63
+
64
+ if (matchesStaticRoutePrefix(pathname)) {
64
65
  return true;
65
66
  }
66
67
 
67
- // Check paths with language prefix (e.g., /en/static/, /zh/static/)
68
- // Remove language prefix if present and check again
69
- const pathSegments = pathname.split('/').filter(Boolean);
70
- if (pathSegments.length > 0 && languages.includes(pathSegments[0])) {
71
- const pathWithoutLang = '/' + pathSegments.slice(1).join('/');
72
- if (
73
- standardStaticPrefixes.some(prefix =>
74
- pathWithoutLang.startsWith(prefix),
75
- ) ||
76
- staticRoutePrefixes.some(
77
- prefix =>
78
- pathWithoutLang.startsWith(`${prefix}/`) ||
79
- pathWithoutLang === prefix,
80
- )
81
- ) {
82
- return true;
83
- }
84
- }
68
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
85
69
 
86
- return false;
70
+ return (
71
+ pathWithoutLanguage !== pathname &&
72
+ matchesStaticRoutePrefix(pathWithoutLanguage)
73
+ );
87
74
  };
88
75
 
89
76
  export const getLanguageFromPath = (
90
- req: any,
77
+ req: LocaleRedirectRequest,
91
78
  urlPath: string,
92
79
  languages: string[],
93
80
  ): string | null => {
94
81
  const url = new URL(req.url, `http://${req.header().host}`);
95
82
  const pathname = url.pathname;
96
-
97
- // Remove urlPath prefix to get remaining path
98
- // urlPath format is /lang/*, need to remove /lang part
99
- const basePath = urlPath.replace('/*', '');
100
- const remainingPath = pathname.startsWith(basePath)
101
- ? pathname.slice(basePath.length)
102
- : pathname;
103
-
83
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
104
84
  const segments = remainingPath.split('/').filter(Boolean);
105
85
  const firstSegment = segments[0];
106
86
 
@@ -112,7 +92,7 @@ export const getLanguageFromPath = (
112
92
  };
113
93
 
114
94
  export const buildLocalizedUrl = (
115
- req: any,
95
+ req: LocaleRedirectRequest,
116
96
  urlPath: string,
117
97
  language: string,
118
98
  languages: string[],
@@ -120,12 +100,8 @@ export const buildLocalizedUrl = (
120
100
  ): string => {
121
101
  const url = new URL(req.url);
122
102
  const pathname = url.pathname;
123
-
124
- // Remove urlPath prefix to get remaining path
125
103
  const basePath = urlPath.replace('/*', '');
126
- const remainingPath = pathname.startsWith(basePath)
127
- ? pathname.slice(basePath.length)
128
- : pathname;
104
+ const remainingPath = stripUrlPathPrefix(pathname, urlPath);
129
105
 
130
106
  const newPathname = localiseTargetPathname(
131
107
  remainingPath,
@@ -133,7 +109,6 @@ export const buildLocalizedUrl = (
133
109
  languages,
134
110
  localisedUrls,
135
111
  );
136
- // Handle root path case to avoid double slashes like //en
137
112
  const suffix = `${url.search}${url.hash}`;
138
113
  const localizedUrl =
139
114
  basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
@@ -8,7 +8,7 @@ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
8
8
  * Detect language from request using the same detection logic as i18next
9
9
  * This ensures consistency between server-side and client-side detection
10
10
  */
11
- export function detectLanguageFromRequest(
11
+ function detectLanguageFromRequest(
12
12
  req: {
13
13
  url: string;
14
14
  headers:
@@ -1,7 +1,18 @@
1
1
  export { resolveLocalisedUrlsConfig } from './config';
2
2
  export { normalisePathname, normalisePathPattern } from './normalise';
3
- export { canonicalTargetPathname, localiseTargetPathname } from './pathname';
3
+ export {
4
+ canonicalTargetPathname,
5
+ localiseTargetPathname,
6
+ stripLanguagePrefix,
7
+ } from './pathname';
4
8
  export { buildPathFromPattern, matchPathPattern } from './patterns';
9
+ export type { LocaleRedirectSkipRule } from './redirect';
10
+ export {
11
+ DEFAULT_LOCALE_REDIRECT_SKIP_RULES,
12
+ isDefaultLocaleRedirectSkipPath,
13
+ matchesPathPrefix,
14
+ shouldSkipLocaleRedirect,
15
+ } from './redirect';
5
16
  export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
6
17
  export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
7
18
  export type {
@@ -4,7 +4,10 @@ import { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
4
4
 
5
5
  import type { LocalisedUrlsOption } from './types';
6
6
 
7
- const stripLanguagePrefix = (pathname: string, languages: string[]): string => {
7
+ export const stripLanguagePrefix = (
8
+ pathname: string,
9
+ languages: string[],
10
+ ): string => {
8
11
  const segments = pathname.split('/').filter(Boolean);
9
12
  const firstSegment = segments[0]?.toLowerCase();
10
13
 
@@ -0,0 +1,91 @@
1
+ import { normalisePathname } from './normalise';
2
+ import { stripLanguagePrefix } from './pathname';
3
+
4
+ export type LocaleRedirectSkipRule =
5
+ | {
6
+ type: 'exact';
7
+ path: string;
8
+ }
9
+ | {
10
+ type: 'prefix';
11
+ path: string;
12
+ };
13
+
14
+ export const DEFAULT_LOCALE_REDIRECT_SKIP_RULES: readonly LocaleRedirectSkipRule[] =
15
+ [
16
+ { type: 'exact', path: '/mf-manifest.json' },
17
+ { type: 'exact', path: '/mf-stats.json' },
18
+ { type: 'exact', path: '/remoteEntry.js' },
19
+ { type: 'prefix', path: '/static/' },
20
+ { type: 'prefix', path: '/upload/' },
21
+ ];
22
+
23
+ const stripTrailingSlash = (pathname: string): string =>
24
+ pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
25
+
26
+ export const matchesPathPrefix = (
27
+ pathname: string,
28
+ prefix: string,
29
+ ): boolean => {
30
+ const normalizedPathname = normalisePathname(pathname);
31
+ const normalizedPrefix = stripTrailingSlash(normalisePathname(prefix));
32
+
33
+ return (
34
+ normalizedPathname === normalizedPrefix ||
35
+ normalizedPathname.startsWith(`${normalizedPrefix}/`)
36
+ );
37
+ };
38
+
39
+ const matchesSkipRule = (
40
+ pathname: string,
41
+ rule: LocaleRedirectSkipRule,
42
+ ): boolean => {
43
+ if (rule.type === 'exact') {
44
+ return normalisePathname(pathname) === normalisePathname(rule.path);
45
+ }
46
+
47
+ return matchesPathPrefix(pathname, rule.path);
48
+ };
49
+
50
+ export const isDefaultLocaleRedirectSkipPath = (
51
+ pathname: string,
52
+ languages: string[] = [],
53
+ ): boolean => {
54
+ const normalizedPathname = normalisePathname(pathname);
55
+ const pathWithoutLanguage = stripLanguagePrefix(
56
+ normalizedPathname,
57
+ languages,
58
+ );
59
+
60
+ return DEFAULT_LOCALE_REDIRECT_SKIP_RULES.some(rule =>
61
+ matchesSkipRule(pathWithoutLanguage, rule),
62
+ );
63
+ };
64
+
65
+ export const shouldSkipLocaleRedirect = (
66
+ pathname: string,
67
+ languages: string[] = [],
68
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
69
+ ): boolean => {
70
+ const normalizedPathname = normalisePathname(pathname);
71
+ const pathWithoutLanguage = stripLanguagePrefix(
72
+ normalizedPathname,
73
+ languages,
74
+ );
75
+
76
+ if (isDefaultLocaleRedirectSkipPath(pathWithoutLanguage)) {
77
+ return true;
78
+ }
79
+
80
+ if (!ignoreRedirectRoutes) {
81
+ return false;
82
+ }
83
+
84
+ if (typeof ignoreRedirectRoutes === 'function') {
85
+ return ignoreRedirectRoutes(pathWithoutLanguage);
86
+ }
87
+
88
+ return ignoreRedirectRoutes.some(pattern =>
89
+ matchesPathPrefix(pathWithoutLanguage, pattern),
90
+ );
91
+ };
@@ -8,17 +8,25 @@ import {
8
8
 
9
9
  import type { LocalisedUrlsMap } from './types';
10
10
 
11
- export const resolveLocalisedPath = (
12
- pathname: string,
13
- targetLanguage: string,
14
- languages: string[],
15
- localisedUrls: LocalisedUrlsMap,
16
- ): string => {
17
- const normalizedPathname = normalisePathname(pathname);
11
+ type CanonicalCandidate = {
12
+ pattern: string;
13
+ canonicalPattern: string;
14
+ localisedUrlEntry: LocalisedUrlsMap[string];
15
+ };
18
16
 
19
- // Canonical keys take precedence: authors write language-agnostic paths,
20
- // which are the map keys, even when no language pattern equals the key.
21
- const canonicalCandidates = sortPatternsBySpecificity(
17
+ type ResolveLocalisedUrlPathOptions =
18
+ | {
19
+ mode: 'localised';
20
+ targetLanguage: string;
21
+ }
22
+ | {
23
+ mode: 'canonical';
24
+ };
25
+
26
+ const getCanonicalCandidates = (
27
+ localisedUrls: LocalisedUrlsMap,
28
+ ): CanonicalCandidate[] =>
29
+ sortPatternsBySpecificity(
22
30
  Object.entries(localisedUrls).map(
23
31
  ([canonicalPattern, localisedUrlEntry]) => ({
24
32
  pattern: canonicalPattern,
@@ -28,91 +36,100 @@ export const resolveLocalisedPath = (
28
36
  ),
29
37
  );
30
38
 
31
- for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
32
- const targetPattern = localisedUrlEntry[targetLanguage];
33
- if (!targetPattern) {
34
- continue;
35
- }
36
-
37
- const params = matchPathPattern(normalizedPathname, canonicalPattern);
38
- if (params) {
39
- return buildPathFromPattern(targetPattern, params);
40
- }
41
- }
42
-
43
- const localisedCandidates = sortPatternsBySpecificity(
44
- Object.values(localisedUrls).flatMap(localisedUrlEntry => {
45
- const targetPattern = localisedUrlEntry[targetLanguage];
46
- if (!targetPattern) {
47
- return [];
48
- }
49
-
50
- return languages
51
- .map(language => localisedUrlEntry[language])
52
- .filter((sourcePattern): sourcePattern is string =>
53
- Boolean(sourcePattern),
54
- )
55
- .map(sourcePattern => ({
56
- pattern: sourcePattern,
57
- sourcePattern,
58
- targetPattern,
59
- }));
60
- }),
61
- );
62
-
63
- for (const { sourcePattern, targetPattern } of localisedCandidates) {
64
- const params = matchPathPattern(normalizedPathname, sourcePattern);
65
- if (params) {
66
- return buildPathFromPattern(targetPattern, params);
67
- }
68
- }
69
-
70
- return normalizedPathname;
71
- };
72
-
73
- /**
74
- * Reverse-map a language-specific pathname (without language prefix) back to
75
- * the canonical, language-agnostic path: localized slug patterns are matched
76
- * against every language variant and rebuilt from the canonical map key.
77
- */
78
- export const resolveCanonicalLocalisedPath = (
39
+ const resolveLocalisedUrlPath = (
79
40
  pathname: string,
80
41
  languages: string[],
81
42
  localisedUrls: LocalisedUrlsMap,
43
+ options: ResolveLocalisedUrlPathOptions,
82
44
  ): string => {
83
45
  const normalizedPathname = normalisePathname(pathname);
84
-
85
- const canonicalCandidates = sortPatternsBySpecificity(
86
- Object.entries(localisedUrls).map(
87
- ([canonicalPattern, localisedUrlEntry]) => ({
88
- pattern: canonicalPattern,
89
- canonicalPattern,
90
- localisedUrlEntry,
91
- }),
92
- ),
93
- );
46
+ const canonicalCandidates = getCanonicalCandidates(localisedUrls);
94
47
 
95
48
  for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
49
+ const targetPattern =
50
+ options.mode === 'localised'
51
+ ? localisedUrlEntry[options.targetLanguage]
52
+ : canonicalPattern;
53
+
54
+ if (!targetPattern) {
55
+ continue;
56
+ }
57
+
96
58
  const canonicalParams = matchPathPattern(
97
59
  normalizedPathname,
98
60
  canonicalPattern,
99
61
  );
100
62
  if (canonicalParams) {
101
- return buildPathFromPattern(canonicalPattern, canonicalParams);
63
+ return buildPathFromPattern(targetPattern, canonicalParams);
102
64
  }
103
65
 
104
- for (const language of languages) {
105
- const sourcePattern = localisedUrlEntry[language];
106
- if (!sourcePattern) {
107
- continue;
66
+ if (options.mode === 'canonical') {
67
+ for (const language of languages) {
68
+ const sourcePattern = localisedUrlEntry[language];
69
+ if (!sourcePattern) {
70
+ continue;
71
+ }
72
+
73
+ const params = matchPathPattern(normalizedPathname, sourcePattern);
74
+ if (params) {
75
+ return buildPathFromPattern(canonicalPattern, params);
76
+ }
108
77
  }
78
+ }
79
+ }
109
80
 
81
+ if (options.mode === 'localised') {
82
+ const localisedCandidates = sortPatternsBySpecificity(
83
+ Object.values(localisedUrls).flatMap(localisedUrlEntry => {
84
+ const targetPattern = localisedUrlEntry[options.targetLanguage];
85
+ if (!targetPattern) {
86
+ return [];
87
+ }
88
+
89
+ return languages
90
+ .map(language => localisedUrlEntry[language])
91
+ .filter((sourcePattern): sourcePattern is string =>
92
+ Boolean(sourcePattern),
93
+ )
94
+ .map(sourcePattern => ({
95
+ pattern: sourcePattern,
96
+ sourcePattern,
97
+ targetPattern,
98
+ }));
99
+ }),
100
+ );
101
+
102
+ for (const { sourcePattern, targetPattern } of localisedCandidates) {
110
103
  const params = matchPathPattern(normalizedPathname, sourcePattern);
111
104
  if (params) {
112
- return buildPathFromPattern(canonicalPattern, params);
105
+ return buildPathFromPattern(targetPattern, params);
113
106
  }
114
107
  }
115
108
  }
116
109
 
117
110
  return normalizedPathname;
118
111
  };
112
+
113
+ export const resolveLocalisedPath = (
114
+ pathname: string,
115
+ targetLanguage: string,
116
+ languages: string[],
117
+ localisedUrls: LocalisedUrlsMap,
118
+ ): string =>
119
+ resolveLocalisedUrlPath(pathname, languages, localisedUrls, {
120
+ mode: 'localised',
121
+ targetLanguage,
122
+ });
123
+
124
+ /**
125
+ * Reverse-map language-specific pathnames back to language-agnostic
126
+ * LocalisedUrlsMap keys.
127
+ */
128
+ export const resolveCanonicalLocalisedPath = (
129
+ pathname: string,
130
+ languages: string[],
131
+ localisedUrls: LocalisedUrlsMap,
132
+ ): string =>
133
+ resolveLocalisedUrlPath(pathname, languages, localisedUrls, {
134
+ mode: 'canonical',
135
+ });
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ LocaleRedirectSkipRule,
2
3
  LocalisedRoute,
3
4
  LocalisedUrlPathMap,
4
5
  LocalisedUrlsMap,
@@ -9,12 +10,17 @@ export {
9
10
  applyLocalisedUrlsToRoutes,
10
11
  buildPathFromPattern,
11
12
  canonicalTargetPathname,
13
+ DEFAULT_LOCALE_REDIRECT_SKIP_RULES,
14
+ isDefaultLocaleRedirectSkipPath,
12
15
  localiseTargetPathname,
16
+ matchesPathPrefix,
13
17
  matchPathPattern,
14
18
  normalisePathname,
15
19
  normalisePathPattern,
16
20
  resolveCanonicalLocalisedPath,
17
21
  resolveLocalisedPath,
18
22
  resolveLocalisedUrlsConfig,
23
+ shouldSkipLocaleRedirect,
24
+ stripLanguagePrefix,
19
25
  validateLocalisedUrls,
20
26
  } from './localisedUrls/index';
@@ -5,7 +5,7 @@ import type {
5
5
  LocaleDetectionOptions,
6
6
  } from './type';
7
7
 
8
- export function getEntryConfig<T extends Record<string, any>>(
8
+ function getEntryConfig<T extends Record<string, any>>(
9
9
  entryName: string,
10
10
  config: T,
11
11
  entryKey: string,
@@ -16,7 +16,7 @@ export function getEntryConfig<T extends Record<string, any>>(
16
16
  return entryConfigMap?.[entryName];
17
17
  }
18
18
 
19
- export function removeEntryConfigKey<T extends Record<string, any>>(
19
+ function removeEntryConfigKey<T extends Record<string, any>>(
20
20
  config: T,
21
21
  entryKey: string,
22
22
  ): Omit<T, typeof entryKey> {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import { shouldIgnoreRedirect as shouldIgnoreRuntimeRedirect } from '../src/runtime/utils';
3
+ import {
4
+ isStaticResourceRequest,
5
+ shouldIgnoreRedirect as shouldIgnoreServerRedirect,
6
+ } from '../src/server/redirectPolicy';
7
+
8
+ const languages = ['en', 'cs'];
9
+
10
+ const defaultSkippedPaths = [
11
+ '/mf-manifest.json',
12
+ '/mf-stats.json',
13
+ '/remoteEntry.js',
14
+ '/static/app.js',
15
+ '/upload/avatar.png',
16
+ ];
17
+
18
+ describe('locale redirect default skip policy', () => {
19
+ test('skips ADR-0002 Module Federation and static endpoints on the server', () => {
20
+ for (const pathname of defaultSkippedPaths) {
21
+ expect(isStaticResourceRequest(pathname, [], languages)).toBe(true);
22
+ expect(shouldIgnoreServerRedirect(pathname, '/', undefined)).toBe(true);
23
+ }
24
+ });
25
+
26
+ test('skips language-prefixed static and upload endpoints', () => {
27
+ expect(isStaticResourceRequest('/cs/static/app.js', [], languages)).toBe(
28
+ true,
29
+ );
30
+ expect(
31
+ isStaticResourceRequest('/en/upload/avatar.png', [], languages),
32
+ ).toBe(true);
33
+ });
34
+
35
+ test('uses the same default skip policy in runtime redirects', () => {
36
+ for (const pathname of defaultSkippedPaths) {
37
+ expect(shouldIgnoreRuntimeRedirect(pathname, languages)).toBe(true);
38
+ expect(shouldIgnoreRuntimeRedirect(`/cs${pathname}`, languages)).toBe(
39
+ true,
40
+ );
41
+ }
42
+ });
43
+ });