@bleedingdev/modern-js-i18n-runtime-extensions 3.8.3-ultramodern.2

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/index.js +76 -0
  3. package/dist/cjs/language-sync/controller.js +241 -0
  4. package/dist/cjs/language-sync/react.js +169 -0
  5. package/dist/cjs/localisedUrls/config.js +49 -0
  6. package/dist/cjs/localisedUrls/index.js +91 -0
  7. package/dist/cjs/localisedUrls/normalise.js +87 -0
  8. package/dist/cjs/localisedUrls/pathname.js +68 -0
  9. package/dist/cjs/localisedUrls/patterns.js +214 -0
  10. package/dist/cjs/localisedUrls/redirect.js +103 -0
  11. package/dist/cjs/localisedUrls/resolve.js +78 -0
  12. package/dist/cjs/localisedUrls/routes.js +202 -0
  13. package/dist/cjs/localisedUrls/types.js +18 -0
  14. package/dist/esm/index.mjs +3 -0
  15. package/dist/esm/language-sync/controller.mjs +203 -0
  16. package/dist/esm/language-sync/react.mjs +118 -0
  17. package/dist/esm/localisedUrls/config.mjs +11 -0
  18. package/dist/esm/localisedUrls/index.mjs +7 -0
  19. package/dist/esm/localisedUrls/normalise.mjs +37 -0
  20. package/dist/esm/localisedUrls/pathname.mjs +24 -0
  21. package/dist/esm/localisedUrls/patterns.mjs +152 -0
  22. package/dist/esm/localisedUrls/redirect.mjs +56 -0
  23. package/dist/esm/localisedUrls/resolve.mjs +37 -0
  24. package/dist/esm/localisedUrls/routes.mjs +161 -0
  25. package/dist/esm/localisedUrls/types.mjs +0 -0
  26. package/dist/esm-node/index.mjs +4 -0
  27. package/dist/esm-node/language-sync/controller.mjs +204 -0
  28. package/dist/esm-node/language-sync/react.mjs +119 -0
  29. package/dist/esm-node/localisedUrls/config.mjs +12 -0
  30. package/dist/esm-node/localisedUrls/index.mjs +8 -0
  31. package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
  32. package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
  33. package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
  34. package/dist/esm-node/localisedUrls/redirect.mjs +57 -0
  35. package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
  36. package/dist/esm-node/localisedUrls/routes.mjs +162 -0
  37. package/dist/esm-node/localisedUrls/types.mjs +1 -0
  38. package/dist/types/index.d.ts +3 -0
  39. package/dist/types/language-sync/controller.d.ts +26 -0
  40. package/dist/types/language-sync/react.d.ts +38 -0
  41. package/dist/types/localisedUrls/config.d.ts +9 -0
  42. package/dist/types/localisedUrls/index.d.ts +9 -0
  43. package/dist/types/localisedUrls/normalise.d.ts +10 -0
  44. package/dist/types/localisedUrls/pathname.d.ts +4 -0
  45. package/dist/types/localisedUrls/patterns.d.ts +11 -0
  46. package/dist/types/localisedUrls/redirect.d.ts +11 -0
  47. package/dist/types/localisedUrls/resolve.d.ts +7 -0
  48. package/dist/types/localisedUrls/routes.d.ts +3 -0
  49. package/dist/types/localisedUrls/types.d.ts +14 -0
  50. package/package.json +71 -0
  51. package/rslib.config.mts +4 -0
  52. package/rstest.config.mts +36 -0
  53. package/src/index.ts +3 -0
  54. package/src/language-sync/controller.ts +386 -0
  55. package/src/language-sync/react.ts +227 -0
  56. package/src/localisedUrls/config.ts +18 -0
  57. package/src/localisedUrls/index.ts +24 -0
  58. package/src/localisedUrls/normalise.ts +62 -0
  59. package/src/localisedUrls/pathname.ts +60 -0
  60. package/src/localisedUrls/patterns.ts +283 -0
  61. package/src/localisedUrls/redirect.ts +93 -0
  62. package/src/localisedUrls/resolve.ts +106 -0
  63. package/src/localisedUrls/routes.ts +422 -0
  64. package/src/localisedUrls/types.ts +18 -0
  65. package/tests/clientRedirectIntegration.test.tsx +74 -0
  66. package/tests/languageSyncController.test.ts +213 -0
  67. package/tests/localisedUrls.test.ts +673 -0
  68. package/tests/reactLanguageSync.test.tsx +117 -0
  69. package/tests/tsconfig.json +11 -0
  70. package/tsconfig.json +11 -0
@@ -0,0 +1,227 @@
1
+ import { getGlobalBasename } from '@modern-js/runtime/context';
2
+ import LanguageDetector, {
3
+ type DetectorOptions,
4
+ } from 'i18next-browser-languagedetector';
5
+ import type React from 'react';
6
+ import { useCallback, useEffect, useRef } from 'react';
7
+ import {
8
+ createLatestLanguageSyncBinding,
9
+ type LanguageSyncFailure,
10
+ type LanguageSyncPolicy,
11
+ } from './controller';
12
+
13
+ export interface LatestLanguageSyncOptions<TTarget extends object> {
14
+ changeLanguage: (
15
+ target: TTarget,
16
+ language: string,
17
+ ) => PromiseLike<unknown> | unknown;
18
+ commitLanguage: (target: TTarget, language: string) => void;
19
+ desiredLanguage?: string;
20
+ enabled?: boolean;
21
+ policy?: LanguageSyncPolicy;
22
+ readLanguage?: (target: TTarget) => string | undefined;
23
+ reportFailure?: (failure: LanguageSyncFailure) => void;
24
+ target?: TTarget;
25
+ }
26
+
27
+ export const useLatestLanguageSync = <TTarget extends object>({
28
+ changeLanguage,
29
+ commitLanguage,
30
+ desiredLanguage,
31
+ enabled = true,
32
+ policy,
33
+ readLanguage,
34
+ reportFailure,
35
+ target,
36
+ }: LatestLanguageSyncOptions<TTarget>) => {
37
+ const bindingRef = useRef<ReturnType<
38
+ typeof createLatestLanguageSyncBinding<TTarget>
39
+ > | null>(null);
40
+ if (bindingRef.current === null) {
41
+ // Binding construction is deliberately inert. Shared coordinator state is
42
+ // touched only by the commit-phase activation effect below.
43
+ bindingRef.current = createLatestLanguageSyncBinding<TTarget>(policy);
44
+ }
45
+ const binding = bindingRef.current;
46
+
47
+ useEffect(() => {
48
+ binding.updateCallbacks({
49
+ changeLanguage,
50
+ commitLanguage,
51
+ readLanguage,
52
+ reportFailure,
53
+ });
54
+ });
55
+
56
+ useEffect(() => {
57
+ if (!enabled || !target) {
58
+ return;
59
+ }
60
+ binding.activate(target);
61
+ return () => binding.deactivate();
62
+ }, [binding, enabled, target]);
63
+
64
+ useEffect(() => {
65
+ if (!enabled || !target || !desiredLanguage) {
66
+ binding.clearRequest();
67
+ return;
68
+ }
69
+ binding.request(desiredLanguage);
70
+ }, [binding, desiredLanguage, enabled, target]);
71
+
72
+ useEffect(() => {
73
+ if (
74
+ !enabled ||
75
+ !target ||
76
+ !desiredLanguage ||
77
+ typeof window === 'undefined'
78
+ ) {
79
+ return;
80
+ }
81
+ const retryCurrentIntent = () => binding.request(desiredLanguage);
82
+ const retryVisibleIntent = () => {
83
+ if (document.visibilityState === 'visible') {
84
+ retryCurrentIntent();
85
+ }
86
+ };
87
+ window.addEventListener('online', retryCurrentIntent);
88
+ document.addEventListener('visibilitychange', retryVisibleIntent);
89
+ return () => {
90
+ window.removeEventListener('online', retryCurrentIntent);
91
+ document.removeEventListener('visibilitychange', retryVisibleIntent);
92
+ };
93
+ }, [binding, desiredLanguage, enabled, target]);
94
+
95
+ return useCallback(
96
+ (language: string) => binding.request(language),
97
+ [binding],
98
+ );
99
+ };
100
+
101
+ interface LanguageDetectorLike {
102
+ cacheUserLanguage?: (language: string) => void;
103
+ }
104
+
105
+ export interface I18nLanguageSyncInstance {
106
+ changeLanguage?: (language?: string) => PromiseLike<unknown> | unknown;
107
+ i18nInstance?: { instance?: I18nLanguageSyncInstance };
108
+ isInitialized?: boolean;
109
+ language: string;
110
+ options?: { detection?: unknown };
111
+ services?: {
112
+ languageDetector?: LanguageDetectorLike;
113
+ };
114
+ setLang?: (language: string) => PromiseLike<unknown> | unknown;
115
+ }
116
+
117
+ const actualInstance = (instance: I18nLanguageSyncInstance) =>
118
+ instance.i18nInstance?.instance ?? instance;
119
+
120
+ const cacheLanguage = (
121
+ instance: I18nLanguageSyncInstance,
122
+ language: string,
123
+ ) => {
124
+ if (typeof window === 'undefined') {
125
+ return;
126
+ }
127
+ const actual = actualInstance(instance);
128
+ const detector =
129
+ actual.services?.languageDetector ?? instance.services?.languageDetector;
130
+ try {
131
+ if (detector?.cacheUserLanguage) {
132
+ detector.cacheUserLanguage(language);
133
+ return;
134
+ }
135
+ if (instance.isInitialized || actual.isInitialized) {
136
+ const services = actual.services ?? instance.services;
137
+ const options = actual.options ?? instance.options;
138
+ if (services && options) {
139
+ const manualDetector = new LanguageDetector();
140
+ manualDetector.init(
141
+ services,
142
+ (instance.options?.detection ?? options.detection) as
143
+ | DetectorOptions
144
+ | undefined,
145
+ );
146
+ manualDetector.cacheUserLanguage?.(language);
147
+ }
148
+ }
149
+ } catch {
150
+ // Caching is a best-effort side effect; synchronization already committed.
151
+ }
152
+ };
153
+
154
+ const detectPathLanguage = (
155
+ pathname: string | undefined,
156
+ languages: string[],
157
+ ) => {
158
+ if (!pathname) {
159
+ return undefined;
160
+ }
161
+ const basename = getGlobalBasename();
162
+ const entryPath = basename && basename !== '/' ? basename : '';
163
+ const relativePath = pathname.startsWith(entryPath)
164
+ ? pathname.slice(entryPath.length)
165
+ : pathname;
166
+ const segments = relativePath.split('/').filter(Boolean);
167
+ const segmentsToCheck =
168
+ !entryPath &&
169
+ segments.length > 1 &&
170
+ segments[0] &&
171
+ !languages.includes(segments[0])
172
+ ? segments.slice(1)
173
+ : segments;
174
+ const language = segmentsToCheck[0];
175
+ return language && languages.includes(language) ? language : undefined;
176
+ };
177
+
178
+ /**
179
+ * Compatibility-shaped adapter for the Modern.js i18n provider. The shared
180
+ * coordinator is fork-owned; the upstream hook remains available to direct
181
+ * internal consumers until the equivalent shrink can be upstreamed.
182
+ */
183
+ export function useLanguageSync<TInstance extends I18nLanguageSyncInstance>(
184
+ i18nInstance: TInstance | undefined,
185
+ localePathRedirect: boolean,
186
+ languages: string[],
187
+ pathname: string | undefined,
188
+ prevLangRef: React.MutableRefObject<string>,
189
+ setLang: (language: string) => void,
190
+ ) {
191
+ const desiredLanguage = localePathRedirect
192
+ ? detectPathLanguage(pathname, languages)
193
+ : undefined;
194
+ const synchronizeLanguage = useLatestLanguageSync({
195
+ target: i18nInstance,
196
+ desiredLanguage,
197
+ enabled: Boolean(i18nInstance && localePathRedirect),
198
+ readLanguage: instance => instance.language,
199
+ changeLanguage: (instance, language) =>
200
+ instance.setLang
201
+ ? instance.setLang(language)
202
+ : instance.changeLanguage?.(language),
203
+ commitLanguage: (instance, language) => {
204
+ prevLangRef.current = language;
205
+ setLang(language);
206
+ cacheLanguage(instance, language);
207
+ },
208
+ reportFailure: ({ error, language }) => {
209
+ console.error(
210
+ `Failed to synchronize i18n language "${language}".`,
211
+ error,
212
+ );
213
+ },
214
+ });
215
+
216
+ useEffect(() => {
217
+ if (!localePathRedirect && i18nInstance?.language) {
218
+ const instanceLanguage = i18nInstance.language;
219
+ if (instanceLanguage !== prevLangRef.current) {
220
+ prevLangRef.current = instanceLanguage;
221
+ setLang(instanceLanguage);
222
+ }
223
+ }
224
+ }, [i18nInstance, localePathRedirect, prevLangRef, setLang]);
225
+
226
+ return synchronizeLanguage;
227
+ }
@@ -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,24 @@
1
+ export { resolveLocalisedUrlsConfig } from './config';
2
+ export { normalisePathname, normalisePathPattern } from './normalise';
3
+ export {
4
+ canonicalTargetPathname,
5
+ localiseTargetPathname,
6
+ stripLanguagePrefix,
7
+ } from './pathname';
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';
16
+ export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
17
+ export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
18
+ export type {
19
+ LocalisedRoute,
20
+ LocalisedUrlPathMap,
21
+ LocalisedUrlsMap,
22
+ LocalisedUrlsOption,
23
+ ResolvedLocalisedUrlsConfig,
24
+ } from './types';
@@ -0,0 +1,62 @@
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
+ };
@@ -0,0 +1,60 @@
1
+ import { resolveLocalisedUrlsConfig } from './config';
2
+
3
+ import { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
4
+
5
+ import type { LocalisedUrlsOption } from './types';
6
+
7
+ export const stripLanguagePrefix = (
8
+ pathname: string,
9
+ languages: string[],
10
+ ): string => {
11
+ const segments = pathname.split('/').filter(Boolean);
12
+ const firstSegment = segments[0]?.toLowerCase();
13
+
14
+ if (
15
+ firstSegment &&
16
+ languages.some(language => language.toLowerCase() === firstSegment)
17
+ ) {
18
+ return `/${segments.slice(1).join('/')}`;
19
+ }
20
+
21
+ return pathname || '/';
22
+ };
23
+
24
+ export const localiseTargetPathname = (
25
+ pathname: string,
26
+ language: string,
27
+ languages: string[],
28
+ localisedUrls?: LocalisedUrlsOption,
29
+ ): string => {
30
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
31
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
32
+ const resolvedPath = localisedUrlsConfig.enabled
33
+ ? resolveLocalisedPath(
34
+ pathWithoutLanguage,
35
+ language,
36
+ languages,
37
+ localisedUrlsConfig.map,
38
+ )
39
+ : pathWithoutLanguage;
40
+ const resolvedSegments = resolvedPath.split('/').filter(Boolean);
41
+
42
+ return `/${[language, ...resolvedSegments].join('/')}`;
43
+ };
44
+
45
+ export const canonicalTargetPathname = (
46
+ pathname: string,
47
+ languages: string[],
48
+ localisedUrls?: LocalisedUrlsOption,
49
+ ): string => {
50
+ const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
51
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
52
+
53
+ return localisedUrlsConfig.enabled
54
+ ? resolveCanonicalLocalisedPath(
55
+ pathWithoutLanguage,
56
+ languages,
57
+ localisedUrlsConfig.map,
58
+ )
59
+ : pathWithoutLanguage;
60
+ };
@@ -0,0 +1,283 @@
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
+ export const getOrderedPathParamSignature = (pattern: string): string[] =>
10
+ normalisePathPattern(pattern)
11
+ .split('/')
12
+ .filter(Boolean)
13
+ .flatMap(segment => {
14
+ if (segment === '*') {
15
+ return ['*'];
16
+ }
17
+ if (!segment.startsWith(':')) {
18
+ return [];
19
+ }
20
+
21
+ return [`:${getParamName(segment)}${segment.endsWith('?') ? '?' : ''}`];
22
+ });
23
+
24
+ export const getPathParamSignature = (pattern: string): string[] =>
25
+ getOrderedPathParamSignature(pattern).sort((left, right) =>
26
+ left < right ? -1 : left > right ? 1 : 0,
27
+ );
28
+
29
+ export const getPathPatternValidationIssue = (
30
+ pattern: string,
31
+ ): string | undefined => {
32
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
33
+ const seenParams = new Set<string>();
34
+
35
+ for (const [index, segment] of segments.entries()) {
36
+ if (segment === '*') {
37
+ if (index !== segments.length - 1) {
38
+ return 'splat parameter "*" must be the final segment';
39
+ }
40
+ if (seenParams.has('*')) {
41
+ return 'duplicate splat parameter "*"';
42
+ }
43
+ seenParams.add('*');
44
+ continue;
45
+ }
46
+ if (!segment.startsWith(':')) {
47
+ continue;
48
+ }
49
+
50
+ const paramName = getParamName(segment);
51
+ if (seenParams.has(paramName)) {
52
+ return `duplicate path parameter ":${paramName}"`;
53
+ }
54
+ seenParams.add(paramName);
55
+ }
56
+
57
+ return undefined;
58
+ };
59
+
60
+ export const getPhysicalPathPattern = (pattern: string): string => {
61
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
62
+ const physicalSegments = segments.map(segment => {
63
+ if (segment === '*') {
64
+ return '*';
65
+ }
66
+ if (segment.startsWith(':')) {
67
+ return segment.endsWith('?') ? ':param?' : ':param';
68
+ }
69
+ return segment;
70
+ });
71
+
72
+ return `/${physicalSegments.join('/')}`;
73
+ };
74
+
75
+ interface CompiledPathPattern {
76
+ names: string[];
77
+ regexp: RegExp;
78
+ }
79
+
80
+ const compiledPathPatternCache = new Map<string, CompiledPathPattern>();
81
+
82
+ const compilePathPattern = (pattern: string): CompiledPathPattern => {
83
+ const normalizedPattern = normalisePathPattern(pattern);
84
+ const cached = compiledPathPatternCache.get(normalizedPattern);
85
+ if (cached) {
86
+ return cached;
87
+ }
88
+
89
+ const names: string[] = [];
90
+ const segments = normalizedPattern.split('/').filter(Boolean);
91
+ const source = segments
92
+ .map(segment => {
93
+ if (segment.startsWith(':')) {
94
+ names.push(getParamName(segment));
95
+ const paramPattern = '([^/]+)';
96
+ return segment.endsWith('?')
97
+ ? `(?:/${paramPattern})?`
98
+ : `/${paramPattern}`;
99
+ }
100
+ if (segment === '*') {
101
+ names.push('*');
102
+ return '(?:/(.*))?';
103
+ }
104
+ return `/${escapeRegExp(segment)}`;
105
+ })
106
+ .join('');
107
+
108
+ const compiled = {
109
+ names,
110
+ regexp: new RegExp(`^${source || '/'}$`),
111
+ };
112
+ compiledPathPatternCache.set(normalizedPattern, compiled);
113
+
114
+ return compiled;
115
+ };
116
+
117
+ const getPatternSpecificity = (pattern: string) => {
118
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
119
+ let staticSegments = 0;
120
+ let dynamicSegments = 0;
121
+ let optionalSegments = 0;
122
+ let splatSegments = 0;
123
+
124
+ for (const segment of segments) {
125
+ if (segment === '*') {
126
+ splatSegments++;
127
+ } else if (segment.startsWith(':')) {
128
+ dynamicSegments++;
129
+ if (segment.endsWith('?')) {
130
+ optionalSegments++;
131
+ }
132
+ } else {
133
+ staticSegments++;
134
+ }
135
+ }
136
+
137
+ return {
138
+ staticSegments,
139
+ dynamicSegments,
140
+ optionalSegments,
141
+ splatSegments,
142
+ totalSegments: segments.length,
143
+ };
144
+ };
145
+
146
+ const comparePatternSpecificity = (left: string, right: string): number => {
147
+ const a = getPatternSpecificity(left);
148
+ const b = getPatternSpecificity(right);
149
+
150
+ return (
151
+ b.staticSegments - a.staticSegments ||
152
+ a.splatSegments - b.splatSegments ||
153
+ a.optionalSegments - b.optionalSegments ||
154
+ b.totalSegments - a.totalSegments ||
155
+ a.dynamicSegments - b.dynamicSegments
156
+ );
157
+ };
158
+
159
+ export const haveEqualPatternSpecificity = (
160
+ left: string,
161
+ right: string,
162
+ ): boolean => comparePatternSpecificity(left, right) === 0;
163
+
164
+ export const doPathPatternsOverlap = (left: string, right: string): boolean => {
165
+ const leftSegments = normalisePathPattern(left).split('/').filter(Boolean);
166
+ const rightSegments = normalisePathPattern(right).split('/').filter(Boolean);
167
+ if (leftSegments.length !== rightSegments.length) {
168
+ return false;
169
+ }
170
+
171
+ return leftSegments.every((leftSegment, index) => {
172
+ const rightSegment = rightSegments[index];
173
+ const leftIsDynamic = leftSegment === '*' || leftSegment.startsWith(':');
174
+ const rightIsDynamic = rightSegment === '*' || rightSegment.startsWith(':');
175
+ return leftIsDynamic || rightIsDynamic || leftSegment === rightSegment;
176
+ });
177
+ };
178
+
179
+ const comparePatternNames = (left: string, right: string): number => {
180
+ const normalizedLeft = normalisePathPattern(left);
181
+ const normalizedRight = normalisePathPattern(right);
182
+
183
+ return normalizedLeft < normalizedRight
184
+ ? -1
185
+ : normalizedLeft > normalizedRight
186
+ ? 1
187
+ : 0;
188
+ };
189
+
190
+ export const sortPatternsBySpecificity = <T extends { pattern: string }>(
191
+ patterns: T[],
192
+ ): T[] =>
193
+ patterns
194
+ .map((pattern, index) => ({ pattern, index }))
195
+ .sort(
196
+ (left, right) =>
197
+ comparePatternSpecificity(
198
+ left.pattern.pattern,
199
+ right.pattern.pattern,
200
+ ) ||
201
+ comparePatternNames(left.pattern.pattern, right.pattern.pattern) ||
202
+ left.index - right.index,
203
+ )
204
+ .map(({ pattern }) => pattern);
205
+
206
+ /**
207
+ * `decodeURIComponent` throws `URIError` on malformed percent-encoding
208
+ * (e.g. `%E0%A4%A`), which attacker-controlled request URLs can carry.
209
+ * Treat such segments as undecodable instead of throwing.
210
+ */
211
+ const decodePathParam = (value: string): string | null => {
212
+ try {
213
+ return decodeURIComponent(value);
214
+ } catch {
215
+ return null;
216
+ }
217
+ };
218
+
219
+ const encodeSplatParam = (value: string): string =>
220
+ value.split('/').map(encodeURIComponent).join('/');
221
+
222
+ export const matchPathPattern = (
223
+ pathname: string,
224
+ pattern: string,
225
+ ): Record<string, string> | null => {
226
+ const { names, regexp } = compilePathPattern(pattern);
227
+ const match = regexp.exec(normalisePathname(pathname));
228
+ if (!match) {
229
+ return null;
230
+ }
231
+
232
+ const params: Record<string, string> = {};
233
+ for (let index = 0; index < names.length; index++) {
234
+ const decoded = decodePathParam(match[index + 1] || '');
235
+ if (decoded === null) {
236
+ // Malformed encoding cannot identify a localised route: no match.
237
+ return null;
238
+ }
239
+ Object.defineProperty(params, names[index], {
240
+ configurable: true,
241
+ enumerable: true,
242
+ value: decoded,
243
+ writable: true,
244
+ });
245
+ }
246
+
247
+ return params;
248
+ };
249
+
250
+ export const buildPathFromPattern = (
251
+ pattern: string,
252
+ params: Record<string, string>,
253
+ ): string => {
254
+ const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
255
+ const path = segments
256
+ .map(segment => {
257
+ if (segment.startsWith(':')) {
258
+ const paramName = getParamName(segment);
259
+ const param = Object.hasOwn(params, paramName)
260
+ ? params[paramName]
261
+ : undefined;
262
+ if (param === undefined || param === '') {
263
+ if (segment.endsWith('?')) {
264
+ return '';
265
+ }
266
+ throw new Error(
267
+ `Missing required path parameter "${paramName}" for pattern "${normalisePathPattern(
268
+ pattern,
269
+ )}".`,
270
+ );
271
+ }
272
+ return encodeURIComponent(param);
273
+ }
274
+ if (segment === '*') {
275
+ return encodeSplatParam(params['*'] || '');
276
+ }
277
+ return segment;
278
+ })
279
+ .filter(Boolean)
280
+ .join('/');
281
+
282
+ return `/${path}`;
283
+ };