@bleedingdev/modern-js-plugin-i18n 3.5.0-ultramodern.30 → 3.5.0-ultramodern.31

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 (161) hide show
  1. package/dist/cjs/cli/index.js +2 -1
  2. package/dist/cjs/runtime/Link.js +8 -89
  3. package/dist/cjs/runtime/context.js +18 -82
  4. package/dist/cjs/runtime/contextHelpers.js +137 -0
  5. package/dist/cjs/runtime/core.js +29 -132
  6. package/dist/cjs/runtime/i18n/detection/cache.js +149 -0
  7. package/dist/cjs/runtime/i18n/detection/detector.js +100 -0
  8. package/dist/cjs/runtime/i18n/detection/index.js +8 -263
  9. package/dist/cjs/runtime/i18n/detection/initOptions.js +69 -0
  10. package/dist/cjs/runtime/i18n/detection/language.js +62 -0
  11. package/dist/cjs/runtime/i18n/detection/path.js +49 -0
  12. package/dist/cjs/runtime/i18n/detection/priority.js +66 -0
  13. package/dist/cjs/runtime/i18n/detection/ssr.js +70 -0
  14. package/dist/cjs/runtime/i18n/detection/types.js +18 -0
  15. package/dist/cjs/runtime/linkHelpers.js +148 -0
  16. package/dist/cjs/runtime/pluginSetup.js +102 -0
  17. package/dist/cjs/runtime/providerComposition.js +113 -0
  18. package/dist/cjs/runtime/reactI18next.js +43 -0
  19. package/dist/cjs/server/apiPrefix.js +72 -0
  20. package/dist/cjs/server/detectorOptions.js +97 -0
  21. package/dist/cjs/server/index.js +17 -152
  22. package/dist/cjs/server/redirectPolicy.js +102 -0
  23. package/dist/cjs/shared/localisedUrls/config.js +49 -0
  24. package/dist/cjs/shared/localisedUrls/index.js +75 -0
  25. package/dist/cjs/shared/localisedUrls/normalise.js +87 -0
  26. package/dist/cjs/shared/localisedUrls/pathname.js +65 -0
  27. package/dist/cjs/shared/localisedUrls/patterns.js +130 -0
  28. package/dist/cjs/shared/localisedUrls/resolve.js +91 -0
  29. package/dist/cjs/shared/localisedUrls/routes.js +129 -0
  30. package/dist/cjs/shared/localisedUrls/types.js +18 -0
  31. package/dist/cjs/shared/localisedUrls.js +13 -294
  32. package/dist/esm/cli/index.mjs +2 -1
  33. package/dist/esm/runtime/Link.mjs +1 -82
  34. package/dist/esm/runtime/context.mjs +18 -82
  35. package/dist/esm/runtime/contextHelpers.mjs +84 -0
  36. package/dist/esm/runtime/core.mjs +30 -133
  37. package/dist/esm/runtime/i18n/detection/cache.mjs +102 -0
  38. package/dist/esm/runtime/i18n/detection/detector.mjs +62 -0
  39. package/dist/esm/runtime/i18n/detection/index.mjs +4 -260
  40. package/dist/esm/runtime/i18n/detection/initOptions.mjs +28 -0
  41. package/dist/esm/runtime/i18n/detection/language.mjs +21 -0
  42. package/dist/esm/runtime/i18n/detection/path.mjs +11 -0
  43. package/dist/esm/runtime/i18n/detection/priority.mjs +28 -0
  44. package/dist/esm/runtime/i18n/detection/ssr.mjs +26 -0
  45. package/dist/esm/runtime/i18n/detection/types.mjs +0 -0
  46. package/dist/esm/runtime/linkHelpers.mjs +98 -0
  47. package/dist/esm/runtime/pluginSetup.mjs +64 -0
  48. package/dist/esm/runtime/providerComposition.mjs +75 -0
  49. package/dist/esm/runtime/reactI18next.mjs +5 -0
  50. package/dist/esm/server/apiPrefix.mjs +31 -0
  51. package/dist/esm/server/detectorOptions.mjs +59 -0
  52. package/dist/esm/server/index.mjs +4 -139
  53. package/dist/esm/server/redirectPolicy.mjs +52 -0
  54. package/dist/esm/shared/localisedUrls/config.mjs +11 -0
  55. package/dist/esm/shared/localisedUrls/index.mjs +6 -0
  56. package/dist/esm/shared/localisedUrls/normalise.mjs +37 -0
  57. package/dist/esm/shared/localisedUrls/pathname.mjs +24 -0
  58. package/dist/esm/shared/localisedUrls/patterns.mjs +86 -0
  59. package/dist/esm/shared/localisedUrls/resolve.mjs +50 -0
  60. package/dist/esm/shared/localisedUrls/routes.mjs +88 -0
  61. package/dist/esm/shared/localisedUrls/types.mjs +0 -0
  62. package/dist/esm/shared/localisedUrls.mjs +1 -283
  63. package/dist/esm-node/cli/index.mjs +2 -1
  64. package/dist/esm-node/runtime/Link.mjs +1 -82
  65. package/dist/esm-node/runtime/context.mjs +18 -82
  66. package/dist/esm-node/runtime/contextHelpers.mjs +85 -0
  67. package/dist/esm-node/runtime/core.mjs +30 -133
  68. package/dist/esm-node/runtime/i18n/detection/cache.mjs +103 -0
  69. package/dist/esm-node/runtime/i18n/detection/detector.mjs +63 -0
  70. package/dist/esm-node/runtime/i18n/detection/index.mjs +4 -260
  71. package/dist/esm-node/runtime/i18n/detection/initOptions.mjs +29 -0
  72. package/dist/esm-node/runtime/i18n/detection/language.mjs +22 -0
  73. package/dist/esm-node/runtime/i18n/detection/path.mjs +12 -0
  74. package/dist/esm-node/runtime/i18n/detection/priority.mjs +29 -0
  75. package/dist/esm-node/runtime/i18n/detection/ssr.mjs +27 -0
  76. package/dist/esm-node/runtime/i18n/detection/types.mjs +1 -0
  77. package/dist/esm-node/runtime/linkHelpers.mjs +99 -0
  78. package/dist/esm-node/runtime/pluginSetup.mjs +65 -0
  79. package/dist/esm-node/runtime/providerComposition.mjs +76 -0
  80. package/dist/esm-node/runtime/reactI18next.mjs +6 -0
  81. package/dist/esm-node/server/apiPrefix.mjs +32 -0
  82. package/dist/esm-node/server/detectorOptions.mjs +60 -0
  83. package/dist/esm-node/server/index.mjs +4 -139
  84. package/dist/esm-node/server/redirectPolicy.mjs +53 -0
  85. package/dist/esm-node/shared/localisedUrls/config.mjs +12 -0
  86. package/dist/esm-node/shared/localisedUrls/index.mjs +7 -0
  87. package/dist/esm-node/shared/localisedUrls/normalise.mjs +38 -0
  88. package/dist/esm-node/shared/localisedUrls/pathname.mjs +25 -0
  89. package/dist/esm-node/shared/localisedUrls/patterns.mjs +87 -0
  90. package/dist/esm-node/shared/localisedUrls/resolve.mjs +51 -0
  91. package/dist/esm-node/shared/localisedUrls/routes.mjs +89 -0
  92. package/dist/esm-node/shared/localisedUrls/types.mjs +1 -0
  93. package/dist/esm-node/shared/localisedUrls.mjs +1 -283
  94. package/dist/types/runtime/Link.d.ts +3 -7
  95. package/dist/types/runtime/context.d.ts +2 -2
  96. package/dist/types/runtime/contextHelpers.d.ts +28 -0
  97. package/dist/types/runtime/core.d.ts +3 -7
  98. package/dist/types/runtime/i18n/detection/cache.d.ts +14 -0
  99. package/dist/types/runtime/i18n/detection/detector.d.ts +8 -0
  100. package/dist/types/runtime/i18n/detection/index.d.ts +5 -50
  101. package/dist/types/runtime/i18n/detection/initOptions.d.ts +10 -0
  102. package/dist/types/runtime/i18n/detection/language.d.ts +11 -0
  103. package/dist/types/runtime/i18n/detection/path.d.ts +5 -0
  104. package/dist/types/runtime/i18n/detection/priority.d.ts +10 -0
  105. package/dist/types/runtime/i18n/detection/ssr.d.ts +9 -0
  106. package/dist/types/runtime/i18n/detection/types.d.ts +29 -0
  107. package/dist/types/runtime/i18n/react-i18next.d.ts +1 -6
  108. package/dist/types/runtime/linkHelpers.d.ts +17 -0
  109. package/dist/types/runtime/pluginSetup.d.ts +32 -0
  110. package/dist/types/runtime/providerComposition.d.ts +15 -0
  111. package/dist/types/runtime/reactI18next.d.ts +7 -0
  112. package/dist/types/server/apiPrefix.d.ts +7 -0
  113. package/dist/types/server/detectorOptions.d.ts +21 -0
  114. package/dist/types/server/index.d.ts +1 -6
  115. package/dist/types/server/redirectPolicy.d.ts +16 -0
  116. package/dist/types/shared/localisedUrls/config.d.ts +9 -0
  117. package/dist/types/shared/localisedUrls/index.d.ts +7 -0
  118. package/dist/types/shared/localisedUrls/normalise.d.ts +10 -0
  119. package/dist/types/shared/localisedUrls/pathname.d.ts +3 -0
  120. package/dist/types/shared/localisedUrls/patterns.d.ts +5 -0
  121. package/dist/types/shared/localisedUrls/resolve.d.ts +8 -0
  122. package/dist/types/shared/localisedUrls/routes.d.ts +3 -0
  123. package/dist/types/shared/localisedUrls/types.d.ts +14 -0
  124. package/dist/types/shared/localisedUrls.d.ts +2 -42
  125. package/package.json +10 -10
  126. package/src/cli/index.ts +3 -2
  127. package/src/runtime/Link.tsx +11 -138
  128. package/src/runtime/context.tsx +42 -196
  129. package/src/runtime/contextHelpers.ts +232 -0
  130. package/src/runtime/core.tsx +42 -273
  131. package/src/runtime/i18n/detection/cache.ts +175 -0
  132. package/src/runtime/i18n/detection/detector.ts +157 -0
  133. package/src/runtime/i18n/detection/index.ts +10 -641
  134. package/src/runtime/i18n/detection/initOptions.ts +74 -0
  135. package/src/runtime/i18n/detection/language.ts +64 -0
  136. package/src/runtime/i18n/detection/path.ts +41 -0
  137. package/src/runtime/i18n/detection/priority.ts +82 -0
  138. package/src/runtime/i18n/detection/ssr.ts +47 -0
  139. package/src/runtime/i18n/detection/types.ts +33 -0
  140. package/src/runtime/i18n/react-i18next.ts +1 -6
  141. package/src/runtime/linkHelpers.ts +174 -0
  142. package/src/runtime/pluginSetup.ts +189 -0
  143. package/src/runtime/providerComposition.tsx +142 -0
  144. package/src/runtime/reactI18next.ts +20 -0
  145. package/src/server/apiPrefix.ts +67 -0
  146. package/src/server/detectorOptions.ts +105 -0
  147. package/src/server/index.ts +12 -327
  148. package/src/server/redirectPolicy.ts +152 -0
  149. package/src/shared/localisedUrls/config.ts +18 -0
  150. package/src/shared/localisedUrls/index.ts +13 -0
  151. package/src/shared/localisedUrls/normalise.ts +67 -0
  152. package/src/shared/localisedUrls/pathname.ts +57 -0
  153. package/src/shared/localisedUrls/patterns.ts +160 -0
  154. package/src/shared/localisedUrls/resolve.ts +118 -0
  155. package/src/shared/localisedUrls/routes.ts +226 -0
  156. package/src/shared/localisedUrls/types.ts +18 -0
  157. package/src/shared/localisedUrls.ts +20 -629
  158. package/tests/link.test.tsx +111 -0
  159. package/tests/localisedUrls.test.ts +50 -0
  160. package/tests/reactI18nextRuntimeBoundary.test.ts +5 -2
  161. package/tests/routerAdapter.test.tsx +56 -0
@@ -0,0 +1,152 @@
1
+ import { localiseTargetPathname } from '../shared/localisedUrls.js';
2
+ import type { LocaleDetectionOptions } from '../shared/type';
3
+
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
+ }
15
+
16
+ // Remove urlPath prefix to get remaining path for matching
17
+ const basePath = urlPath.replace('/*', '');
18
+ const remainingPath = pathname.startsWith(basePath)
19
+ ? pathname.slice(basePath.length)
20
+ : pathname;
21
+
22
+ // Normalize path (ensure it starts with /)
23
+ const normalizedPath = remainingPath.startsWith('/')
24
+ ? remainingPath
25
+ : `/${remainingPath}`;
26
+
27
+ if (typeof ignoreRedirectRoutes === 'function') {
28
+ return ignoreRedirectRoutes(normalizedPath);
29
+ }
30
+
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
+ });
38
+ };
39
+
40
+ /**
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/
46
+ */
47
+ export const isStaticResourceRequest = (
48
+ pathname: string,
49
+ staticRoutePrefixes: string[],
50
+ languages: string[] = [],
51
+ ): boolean => {
52
+ // Check against staticRoutePrefixes (from public directories)
53
+ if (
54
+ staticRoutePrefixes.some(
55
+ prefix => pathname.startsWith(`${prefix}/`) || pathname === prefix,
56
+ )
57
+ ) {
58
+ return true;
59
+ }
60
+
61
+ // Check standard static resource paths
62
+ const standardStaticPrefixes = ['/static/', '/upload/'];
63
+ if (standardStaticPrefixes.some(prefix => pathname.startsWith(prefix))) {
64
+ return true;
65
+ }
66
+
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
+ }
85
+
86
+ return false;
87
+ };
88
+
89
+ export const getLanguageFromPath = (
90
+ req: any,
91
+ urlPath: string,
92
+ languages: string[],
93
+ ): string | null => {
94
+ const url = new URL(req.url, `http://${req.header().host}`);
95
+ 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
+
104
+ const segments = remainingPath.split('/').filter(Boolean);
105
+ const firstSegment = segments[0];
106
+
107
+ if (languages.includes(firstSegment)) {
108
+ return firstSegment;
109
+ }
110
+
111
+ return null;
112
+ };
113
+
114
+ export const buildLocalizedUrl = (
115
+ req: any,
116
+ urlPath: string,
117
+ language: string,
118
+ languages: string[],
119
+ localisedUrls?: LocaleDetectionOptions['localisedUrls'],
120
+ ): string => {
121
+ const url = new URL(req.url);
122
+ const pathname = url.pathname;
123
+
124
+ // Remove urlPath prefix to get remaining path
125
+ const basePath = urlPath.replace('/*', '');
126
+ const remainingPath = pathname.startsWith(basePath)
127
+ ? pathname.slice(basePath.length)
128
+ : pathname;
129
+
130
+ const newPathname = localiseTargetPathname(
131
+ remainingPath,
132
+ language,
133
+ languages,
134
+ localisedUrls,
135
+ );
136
+ // Handle root path case to avoid double slashes like //en
137
+ const suffix = `${url.search}${url.hash}`;
138
+ const localizedUrl =
139
+ basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
140
+
141
+ return localizedUrl;
142
+ };
143
+
144
+ export const createLocaleRedirectResponse = (location: string): Response =>
145
+ new Response(null, {
146
+ status: 302,
147
+ headers: {
148
+ 'Cache-Control': 'private, no-store',
149
+ Location: location,
150
+ Vary: 'Accept-Language, Cookie',
151
+ },
152
+ });
@@ -0,0 +1,18 @@
1
+ import type { LocalisedUrlsOption, ResolvedLocalisedUrlsConfig } from './types';
2
+
3
+ /**
4
+ * Localised URLs are strictly opt-in: only an explicit, non-empty map enables
5
+ * route expansion and validation. `true`, `false`, an empty map and absence
6
+ * all resolve to disabled, so upstream-style configs (`localePathRedirect` +
7
+ * `languages` without a map) keep plain locale-prefix behavior instead of
8
+ * failing the build for every route missing from a map they never wrote.
9
+ */
10
+ export const resolveLocalisedUrlsConfig = (
11
+ option: LocalisedUrlsOption | undefined,
12
+ ): ResolvedLocalisedUrlsConfig => {
13
+ if (option && typeof option === 'object' && Object.keys(option).length > 0) {
14
+ return { enabled: true, map: option };
15
+ }
16
+
17
+ return { enabled: false, map: {} };
18
+ };
@@ -0,0 +1,13 @@
1
+ export { resolveLocalisedUrlsConfig } from './config';
2
+ export { normalisePathname, normalisePathPattern } from './normalise';
3
+ export { canonicalTargetPathname, localiseTargetPathname } from './pathname';
4
+ export { buildPathFromPattern, matchPathPattern } from './patterns';
5
+ export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
6
+ export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
7
+ export type {
8
+ LocalisedRoute,
9
+ LocalisedUrlPathMap,
10
+ LocalisedUrlsMap,
11
+ LocalisedUrlsOption,
12
+ ResolvedLocalisedUrlsConfig,
13
+ } from './types';
@@ -0,0 +1,67 @@
1
+ const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
2
+
3
+ const normaliseSlashes = (path: string): string => {
4
+ const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
5
+ const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
6
+ ? withoutDuplicateSlashes
7
+ : `/${withoutDuplicateSlashes}`;
8
+
9
+ return withLeadingSlash.length > 1
10
+ ? withLeadingSlash.replace(/\/+$/, '')
11
+ : withLeadingSlash;
12
+ };
13
+
14
+ export const normalisePathPattern = (path: string): string =>
15
+ normaliseSlashes(path).replace(/\[(.+?)\]/g, ':$1');
16
+
17
+ /**
18
+ * Normalise a concrete request pathname: slash cleanup only. Unlike
19
+ * {@link normalisePathPattern} it must not rewrite literal `[x]` segments to
20
+ * `:x` params — pathnames are values, not patterns.
21
+ */
22
+ export const normalisePathname = (pathname: string): string =>
23
+ normaliseSlashes(pathname);
24
+
25
+ export const normaliseRoutePath = (path: string): string => {
26
+ const normalized = normalisePathPattern(path);
27
+ return normalized === '/' ? '' : normalized.slice(1);
28
+ };
29
+
30
+ const getLocaleParamSegment = (segment: string): string | null => {
31
+ if (!segment.startsWith(':')) {
32
+ return null;
33
+ }
34
+
35
+ const paramName = segment.slice(1).replace(/\?$/, '');
36
+ return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
37
+ };
38
+
39
+ const splitPathSegments = (path?: string): string[] => {
40
+ if (!path) {
41
+ return [];
42
+ }
43
+
44
+ return normalisePathPattern(path).split('/').filter(Boolean);
45
+ };
46
+
47
+ export const stripLeadingLocaleParam = (path?: string): string | undefined => {
48
+ const segments = splitPathSegments(path);
49
+ const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
50
+
51
+ if (!leadingLocaleParam) {
52
+ return path;
53
+ }
54
+
55
+ const remainingPath = segments.slice(1).join('/');
56
+ return remainingPath ? `/${remainingPath}` : undefined;
57
+ };
58
+
59
+ export const getLeadingLocaleParam = (path?: string): string | null => {
60
+ const segments = splitPathSegments(path);
61
+ return getLocaleParamSegment(segments[0] || '');
62
+ };
63
+
64
+ const isLocaleParamPath = (path?: string): boolean => {
65
+ const segments = splitPathSegments(path);
66
+ return segments.length === 1 && Boolean(getLocaleParamSegment(segments[0]));
67
+ };
@@ -0,0 +1,57 @@
1
+ import { resolveLocalisedUrlsConfig } from './config';
2
+
3
+ import { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
4
+
5
+ import type { LocalisedUrlsOption } from './types';
6
+
7
+ const stripLanguagePrefix = (pathname: string, languages: string[]): string => {
8
+ const segments = pathname.split('/').filter(Boolean);
9
+ const firstSegment = segments[0]?.toLowerCase();
10
+
11
+ if (
12
+ firstSegment &&
13
+ languages.some(language => language.toLowerCase() === firstSegment)
14
+ ) {
15
+ return `/${segments.slice(1).join('/')}`;
16
+ }
17
+
18
+ return pathname || '/';
19
+ };
20
+
21
+ export const localiseTargetPathname = (
22
+ pathname: string,
23
+ language: string,
24
+ languages: string[],
25
+ localisedUrls?: LocalisedUrlsOption,
26
+ ): string => {
27
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
28
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
29
+ const resolvedPath = localisedUrlsConfig.enabled
30
+ ? resolveLocalisedPath(
31
+ pathWithoutLanguage,
32
+ language,
33
+ languages,
34
+ localisedUrlsConfig.map,
35
+ )
36
+ : pathWithoutLanguage;
37
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
38
+
39
+ return `/${[language, ...resolvedSegments].join('/')}`;
40
+ };
41
+
42
+ export const canonicalTargetPathname = (
43
+ pathname: string,
44
+ languages: string[],
45
+ localisedUrls?: LocalisedUrlsOption,
46
+ ): string => {
47
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
48
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
49
+
50
+ return localisedUrlsConfig.enabled
51
+ ? resolveCanonicalLocalisedPath(
52
+ pathWithoutLanguage,
53
+ languages,
54
+ localisedUrlsConfig.map,
55
+ )
56
+ : pathWithoutLanguage;
57
+ };
@@ -0,0 +1,160 @@
1
+ import { normalisePathname, normalisePathPattern } from './normalise';
2
+
3
+ const escapeRegExp = (value: string): string =>
4
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5
+
6
+ const getParamName = (segment: string): string =>
7
+ segment.slice(1).replace(/\?$/, '');
8
+
9
+ interface CompiledPathPattern {
10
+ names: string[];
11
+ regexp: RegExp;
12
+ }
13
+
14
+ const compiledPathPatternCache = new Map<string, CompiledPathPattern>();
15
+
16
+ const compilePathPattern = (pattern: string): CompiledPathPattern => {
17
+ const normalizedPattern = normalisePathPattern(pattern);
18
+ const cached = compiledPathPatternCache.get(normalizedPattern);
19
+ if (cached) {
20
+ return cached;
21
+ }
22
+
23
+ const names: string[] = [];
24
+ const segments = normalizedPattern.split('/').filter(Boolean);
25
+ const source = segments
26
+ .map(segment => {
27
+ if (segment.startsWith(':')) {
28
+ names.push(getParamName(segment));
29
+ const paramPattern = '([^/]+)';
30
+ return segment.endsWith('?')
31
+ ? `(?:/${paramPattern})?`
32
+ : `/${paramPattern}`;
33
+ }
34
+ if (segment === '*') {
35
+ names.push('*');
36
+ return '/(.*)';
37
+ }
38
+ return `/${escapeRegExp(segment)}`;
39
+ })
40
+ .join('');
41
+
42
+ const compiled = {
43
+ names,
44
+ regexp: new RegExp(`^${source || '/'}$`),
45
+ };
46
+ compiledPathPatternCache.set(normalizedPattern, compiled);
47
+
48
+ return compiled;
49
+ };
50
+
51
+ const getPatternSpecificity = (pattern: string) => {
52
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
53
+ let staticSegments = 0;
54
+ let dynamicSegments = 0;
55
+ let splatSegments = 0;
56
+
57
+ for (const segment of segments) {
58
+ if (segment === '*') {
59
+ splatSegments++;
60
+ } else if (segment.startsWith(':')) {
61
+ dynamicSegments++;
62
+ } else {
63
+ staticSegments++;
64
+ }
65
+ }
66
+
67
+ return {
68
+ staticSegments,
69
+ dynamicSegments,
70
+ splatSegments,
71
+ totalSegments: segments.length,
72
+ };
73
+ };
74
+
75
+ const comparePatternSpecificity = (left: string, right: string): number => {
76
+ const a = getPatternSpecificity(left);
77
+ const b = getPatternSpecificity(right);
78
+
79
+ return (
80
+ b.staticSegments - a.staticSegments ||
81
+ b.totalSegments - a.totalSegments ||
82
+ a.splatSegments - b.splatSegments ||
83
+ a.dynamicSegments - b.dynamicSegments
84
+ );
85
+ };
86
+
87
+ export const sortPatternsBySpecificity = <T extends { pattern: string }>(
88
+ patterns: T[],
89
+ ): T[] =>
90
+ patterns
91
+ .map((pattern, index) => ({ pattern, index }))
92
+ .sort(
93
+ (left, right) =>
94
+ comparePatternSpecificity(
95
+ left.pattern.pattern,
96
+ right.pattern.pattern,
97
+ ) || left.index - right.index,
98
+ )
99
+ .map(({ pattern }) => pattern);
100
+
101
+ /**
102
+ * `decodeURIComponent` throws `URIError` on malformed percent-encoding
103
+ * (e.g. `%E0%A4%A`), which attacker-controlled request URLs can carry.
104
+ * Treat such segments as undecodable instead of throwing.
105
+ */
106
+ const decodePathParam = (value: string): string | null => {
107
+ try {
108
+ return decodeURIComponent(value);
109
+ } catch {
110
+ return null;
111
+ }
112
+ };
113
+
114
+ const encodeSplatParam = (value: string): string =>
115
+ value.split('/').map(encodeURIComponent).join('/');
116
+
117
+ export const matchPathPattern = (
118
+ pathname: string,
119
+ pattern: string,
120
+ ): Record<string, string> | null => {
121
+ const { names, regexp } = compilePathPattern(pattern);
122
+ const match = regexp.exec(normalisePathname(pathname));
123
+ if (!match) {
124
+ return null;
125
+ }
126
+
127
+ const params: Record<string, string> = {};
128
+ for (let index = 0; index < names.length; index++) {
129
+ const decoded = decodePathParam(match[index + 1] || '');
130
+ if (decoded === null) {
131
+ // Malformed encoding cannot identify a localised route: no match.
132
+ return null;
133
+ }
134
+ params[names[index]] = decoded;
135
+ }
136
+
137
+ return params;
138
+ };
139
+
140
+ export const buildPathFromPattern = (
141
+ pattern: string,
142
+ params: Record<string, string>,
143
+ ): string => {
144
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
145
+ const path = segments
146
+ .map(segment => {
147
+ if (segment.startsWith(':')) {
148
+ const param = params[getParamName(segment)];
149
+ return param ? encodeURIComponent(param) : '';
150
+ }
151
+ if (segment === '*') {
152
+ return encodeSplatParam(params['*'] || '');
153
+ }
154
+ return segment;
155
+ })
156
+ .filter(Boolean)
157
+ .join('/');
158
+
159
+ return `/${path}`;
160
+ };
@@ -0,0 +1,118 @@
1
+ import { normalisePathname } from './normalise';
2
+
3
+ import {
4
+ buildPathFromPattern,
5
+ matchPathPattern,
6
+ sortPatternsBySpecificity,
7
+ } from './patterns';
8
+
9
+ import type { LocalisedUrlsMap } from './types';
10
+
11
+ export const resolveLocalisedPath = (
12
+ pathname: string,
13
+ targetLanguage: string,
14
+ languages: string[],
15
+ localisedUrls: LocalisedUrlsMap,
16
+ ): string => {
17
+ const normalizedPathname = normalisePathname(pathname);
18
+
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(
22
+ Object.entries(localisedUrls).map(
23
+ ([canonicalPattern, localisedUrlEntry]) => ({
24
+ pattern: canonicalPattern,
25
+ canonicalPattern,
26
+ localisedUrlEntry,
27
+ }),
28
+ ),
29
+ );
30
+
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 = (
79
+ pathname: string,
80
+ languages: string[],
81
+ localisedUrls: LocalisedUrlsMap,
82
+ ): string => {
83
+ 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
+ );
94
+
95
+ for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
96
+ const canonicalParams = matchPathPattern(
97
+ normalizedPathname,
98
+ canonicalPattern,
99
+ );
100
+ if (canonicalParams) {
101
+ return buildPathFromPattern(canonicalPattern, canonicalParams);
102
+ }
103
+
104
+ for (const language of languages) {
105
+ const sourcePattern = localisedUrlEntry[language];
106
+ if (!sourcePattern) {
107
+ continue;
108
+ }
109
+
110
+ const params = matchPathPattern(normalizedPathname, sourcePattern);
111
+ if (params) {
112
+ return buildPathFromPattern(canonicalPattern, params);
113
+ }
114
+ }
115
+ }
116
+
117
+ return normalizedPathname;
118
+ };