@devp0nt/route0 1.0.0-next.77 → 1.0.0-next.78

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.
package/dist/esm/index.js CHANGED
@@ -1,112 +1,57 @@
1
1
  import { parse as parseSearchQuery, stringify as stringifySearchQuery } from "@devp0nt/flat0";
2
2
  const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
- const getRouteSegments = (definition) => {
4
- if (definition === "" || definition === "/") return [];
5
- return definition.split("/").filter(Boolean);
6
- };
7
- const getRouteTokens = (definition) => {
8
- const segments = getRouteSegments(definition);
9
- return segments.map((segment) => {
10
- const param = segment.match(/^:([A-Za-z0-9_]+)(\?)?$/);
11
- if (param) {
12
- return { kind: "param", name: param[1], optional: param[2] === "?" };
13
- }
14
- if (segment === "*" || segment === "*?") {
15
- return { kind: "wildcard", prefix: "", optional: segment.endsWith("?") };
16
- }
17
- const wildcard = segment.match(/^(.*)\*(\?)?$/);
18
- if (wildcard && !segment.includes("\\*")) {
19
- return { kind: "wildcard", prefix: wildcard[1], optional: wildcard[2] === "?" };
20
- }
21
- return { kind: "static", value: segment };
22
- });
23
- };
24
- const getRouteRegexBaseString = (definition) => {
25
- const tokens = getRouteTokens(definition);
26
- if (tokens.length === 0) return "";
27
- let pattern = "";
28
- for (const token of tokens) {
29
- if (token.kind === "static") {
30
- pattern += `/${escapeRegex(token.value)}`;
31
- continue;
32
- }
33
- if (token.kind === "param") {
34
- pattern += token.optional ? "(?:/([^/]+))?" : "/([^/]+)";
35
- continue;
36
- }
37
- if (token.prefix.length > 0) {
38
- pattern += `/${escapeRegex(token.prefix)}(.*)`;
39
- } else {
40
- pattern += "(?:/(.*))?";
41
- }
42
- }
43
- return pattern;
44
- };
45
- const getRouteCaptureKeys = (definition) => {
46
- const keys = [];
47
- for (const token of getRouteTokens(definition)) {
48
- if (token.kind === "param") keys.push(token.name);
49
- if (token.kind === "wildcard") keys.push("*");
50
- }
51
- return keys;
52
- };
53
- const getPathParamsDefinition = (definition) => {
54
- const entries = getRouteTokens(definition).filter((t) => t.kind !== "static").map((t) => t.kind === "param" ? [t.name, !t.optional] : ["*", !t.optional]);
55
- return Object.fromEntries(entries);
56
- };
57
- const validateRouteDefinition = (definition) => {
58
- const segments = getRouteSegments(definition);
59
- const wildcardSegments = segments.filter((segment) => segment.includes("*"));
60
- if (wildcardSegments.length === 0) return;
61
- if (wildcardSegments.length > 1) {
62
- throw new Error(`Invalid route definition "${definition}": only one wildcard segment is allowed`);
63
- }
64
- const wildcardSegmentIndex = segments.findIndex((segment) => segment.includes("*"));
65
- const wildcardSegment = segments[wildcardSegmentIndex];
66
- if (!wildcardSegment.match(/^(?:\*|\*\?|[^*]+\*|\S+\*\?)$/)) {
67
- throw new Error(`Invalid route definition "${definition}": wildcard must be trailing in its segment`);
68
- }
69
- if (wildcardSegmentIndex !== segments.length - 1) {
70
- throw new Error(`Invalid route definition "${definition}": wildcard segment is allowed only at the end`);
71
- }
72
- };
73
- const stripTrailingWildcard = (definition) => definition.replace(/\*\??$/, "");
74
- const normalizeRouteDefinition = (definition) => {
75
- const value = definition.replace(/\/{2,}/g, "/");
76
- if (value === "" || value === "/") return "/";
77
- const withLeadingSlash = value.startsWith("/") ? value : `/${value}`;
3
+ const collapseDuplicateSlashes = (value) => value.replace(/\/{2,}/g, "/");
4
+ const normalizeSlashPath = (value) => {
5
+ const collapsed = collapseDuplicateSlashes(value);
6
+ if (collapsed === "" || collapsed === "/") return "/";
7
+ const withLeadingSlash = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
78
8
  return withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
79
9
  };
80
- const normalizePathname = (pathname) => {
81
- return normalizeRouteDefinition(pathname);
82
- };
83
- const getNormalizedPathnameFromInput = (hrefOrHrefRelOrLocation) => {
84
- if (hrefOrHrefRelOrLocation instanceof URL) {
85
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
86
- }
87
- if (typeof hrefOrHrefRelOrLocation !== "string") {
88
- if (typeof hrefOrHrefRelOrLocation.pathname === "string") {
89
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
90
- }
91
- hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
92
- }
93
- const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
94
- const base = abs ? void 0 : "http://example.com";
95
- const url = new URL(hrefOrHrefRelOrLocation, base);
96
- return normalizePathname(url.pathname);
97
- };
98
10
  class Route0 {
99
11
  definition;
100
12
  params;
101
13
  _origin;
102
14
  _callable;
15
+ _routeSegments;
16
+ _routeTokens;
17
+ _routePatternCandidates;
18
+ _pathParamsDefinition;
19
+ _definitionWithoutTrailingWildcard;
20
+ _routeRegexBaseStringRaw;
103
21
  _regexBaseString;
104
22
  _regexString;
105
23
  _regex;
106
24
  _regexAncestor;
25
+ _regexDescendantMatchers;
107
26
  _captureKeys;
108
27
  _normalizedDefinition;
109
28
  _definitionParts;
29
+ static _getRouteSegments(definition) {
30
+ if (definition === "" || definition === "/") return [];
31
+ return definition.split("/").filter(Boolean);
32
+ }
33
+ static _normalizeRouteDefinition(definition) {
34
+ return normalizeSlashPath(definition);
35
+ }
36
+ static _normalizePathname(pathname) {
37
+ return Route0._normalizeRouteDefinition(pathname);
38
+ }
39
+ static _validateRouteDefinition(definition) {
40
+ const segments = Route0._getRouteSegments(definition);
41
+ const wildcardSegments = segments.filter((segment) => segment.includes("*"));
42
+ if (wildcardSegments.length === 0) return;
43
+ if (wildcardSegments.length > 1) {
44
+ throw new Error(`Invalid route definition "${definition}": only one wildcard segment is allowed`);
45
+ }
46
+ const wildcardSegmentIndex = segments.findIndex((segment) => segment.includes("*"));
47
+ const wildcardSegment = segments[wildcardSegmentIndex];
48
+ if (!wildcardSegment.match(/^(?:\*|\*\?|[^*]+\*|\S+\*\?)$/)) {
49
+ throw new Error(`Invalid route definition "${definition}": wildcard must be trailing in its segment`);
50
+ }
51
+ if (wildcardSegmentIndex !== segments.length - 1) {
52
+ throw new Error(`Invalid route definition "${definition}": wildcard segment is allowed only at the end`);
53
+ }
54
+ }
110
55
  Infer = null;
111
56
  /** Base URL used when generating absolute URLs (`abs: true`). */
112
57
  get origin() {
@@ -121,10 +66,10 @@ class Route0 {
121
66
  this._origin = origin;
122
67
  }
123
68
  constructor(definition, config = {}) {
124
- const normalizedDefinition = normalizeRouteDefinition(definition);
125
- validateRouteDefinition(normalizedDefinition);
69
+ const normalizedDefinition = Route0._normalizeRouteDefinition(definition);
70
+ Route0._validateRouteDefinition(normalizedDefinition);
126
71
  this.definition = normalizedDefinition;
127
- this.params = Route0._getParamsDefinitionByDefinition(normalizedDefinition);
72
+ this.params = this.pathParamsDefinition;
128
73
  const { origin } = config;
129
74
  if (origin && typeof origin === "string" && origin.length) {
130
75
  this._origin = origin;
@@ -153,7 +98,7 @@ class Route0 {
153
98
  return definition.clone(config);
154
99
  }
155
100
  const original = new Route0(
156
- normalizeRouteDefinition(definition),
101
+ Route0._normalizeRouteDefinition(definition),
157
102
  config
158
103
  );
159
104
  return original._callable;
@@ -168,23 +113,19 @@ class Route0 {
168
113
  return definition;
169
114
  }
170
115
  const original = typeof definition === "object" ? definition : new Route0(
171
- normalizeRouteDefinition(definition)
116
+ Route0._normalizeRouteDefinition(definition)
172
117
  );
173
118
  return original._callable;
174
119
  }
175
120
  static _getAbsPath(origin, url) {
176
121
  return new URL(url, origin).toString().replace(/\/$/, "");
177
122
  }
178
- static _getParamsDefinitionByDefinition(definition) {
179
- return getPathParamsDefinition(definition);
180
- }
181
123
  search() {
182
124
  return this._callable;
183
125
  }
184
126
  /** Extends the current route definition by appending a suffix route. */
185
127
  extend(suffixDefinition) {
186
- const sourceDefinitionWithoutWildcard = stripTrailingWildcard(this.definition);
187
- const definition = normalizeRouteDefinition(`${sourceDefinitionWithoutWildcard}/${suffixDefinition}`);
128
+ const definition = Route0._normalizeRouteDefinition(`${this.definitionWithoutTrailingWildcard}/${suffixDefinition}`);
188
129
  return Route0.create(
189
130
  definition,
190
131
  {
@@ -261,7 +202,7 @@ class Route0 {
261
202
  url = url.replace(/\*/g, () => String(paramsInput["*"] ?? ""));
262
203
  const searchString = stringifySearchQuery(searchInput);
263
204
  url = [url, searchString].filter(Boolean).join("?");
264
- url = url.replace(/\/{2,}/g, "/");
205
+ url = collapseDuplicateSlashes(url);
265
206
  url = absInput ? Route0._getAbsPath(absOriginInput || this.origin, url) : url;
266
207
  if (hashInput !== void 0) {
267
208
  url = `${url}#${hashInput}`;
@@ -273,7 +214,7 @@ class Route0 {
273
214
  return Object.keys(this.params);
274
215
  }
275
216
  getTokens() {
276
- return getRouteTokens(this.definition);
217
+ return this.routeTokens.map((token) => ({ ...token }));
277
218
  }
278
219
  /** Clones route with optional config override. */
279
220
  clone(config) {
@@ -281,7 +222,7 @@ class Route0 {
281
222
  }
282
223
  get regexBaseString() {
283
224
  if (this._regexBaseString === void 0) {
284
- this._regexBaseString = getRouteRegexBaseString(this.definition).replace(/\/+$/, "") + "/?";
225
+ this._regexBaseString = this.routeRegexBaseStringRaw.replace(/\/+$/, "") + "/?";
285
226
  }
286
227
  return this._regexBaseString;
287
228
  }
@@ -303,12 +244,132 @@ class Route0 {
303
244
  }
304
245
  return this._regexAncestor;
305
246
  }
247
+ get regexDescendantMatchers() {
248
+ if (this._regexDescendantMatchers === void 0) {
249
+ const matchers = [];
250
+ if (this.definitionParts[0] !== "/") {
251
+ let pattern = "";
252
+ const captureKeys = [];
253
+ for (const part of this.definitionParts) {
254
+ if (part.startsWith(":")) {
255
+ pattern += "/([^/]+)";
256
+ captureKeys.push(part.replace(/^:/, "").replace(/\?$/, ""));
257
+ } else if (part.includes("*")) {
258
+ const prefix = part.replace(/\*\??$/, "");
259
+ pattern += `/${escapeRegex(prefix)}[^/]*`;
260
+ captureKeys.push("*");
261
+ } else {
262
+ pattern += `/${escapeRegex(part)}`;
263
+ }
264
+ matchers.push({
265
+ regex: new RegExp(`^${pattern}/?$`),
266
+ captureKeys: [...captureKeys]
267
+ });
268
+ }
269
+ }
270
+ this._regexDescendantMatchers = matchers;
271
+ }
272
+ return this._regexDescendantMatchers;
273
+ }
306
274
  get captureKeys() {
307
275
  if (this._captureKeys === void 0) {
308
- this._captureKeys = getRouteCaptureKeys(this.definition);
276
+ this._captureKeys = this.routeTokens.filter((token) => token.kind !== "static").map((token) => token.kind === "param" ? token.name : "*");
309
277
  }
310
278
  return this._captureKeys;
311
279
  }
280
+ get routeSegments() {
281
+ if (this._routeSegments === void 0) {
282
+ this._routeSegments = Route0._getRouteSegments(this.definition);
283
+ }
284
+ return this._routeSegments;
285
+ }
286
+ get routeTokens() {
287
+ if (this._routeTokens === void 0) {
288
+ this._routeTokens = this.routeSegments.map((segment) => {
289
+ const param = segment.match(/^:([A-Za-z0-9_]+)(\?)?$/);
290
+ if (param) {
291
+ return { kind: "param", name: param[1], optional: param[2] === "?" };
292
+ }
293
+ if (segment === "*" || segment === "*?") {
294
+ return { kind: "wildcard", prefix: "", optional: segment.endsWith("?") };
295
+ }
296
+ const wildcard = segment.match(/^(.*)\*(\?)?$/);
297
+ if (wildcard && !segment.includes("\\*")) {
298
+ return { kind: "wildcard", prefix: wildcard[1], optional: wildcard[2] === "?" };
299
+ }
300
+ return { kind: "static", value: segment };
301
+ });
302
+ }
303
+ return this._routeTokens;
304
+ }
305
+ get routePatternCandidates() {
306
+ if (this._routePatternCandidates === void 0) {
307
+ const values = (token) => {
308
+ if (token.kind === "static") return [token.value];
309
+ if (token.kind === "param") return token.optional ? ["", "x", "y"] : ["x", "y"];
310
+ if (token.prefix.length > 0)
311
+ return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x`, `${token.prefix}/y/z`];
312
+ return ["", "x", "y", "x/y"];
313
+ };
314
+ let acc = [""];
315
+ for (const token of this.routeTokens) {
316
+ const next = [];
317
+ for (const base of acc) {
318
+ for (const value of values(token)) {
319
+ if (value === "") {
320
+ next.push(base);
321
+ } else if (value.startsWith("/")) {
322
+ next.push(`${base}${value}`);
323
+ } else {
324
+ next.push(`${base}/${value}`);
325
+ }
326
+ }
327
+ }
328
+ acc = next.length > 512 ? next.slice(0, 512) : next;
329
+ }
330
+ this._routePatternCandidates = acc.length === 0 ? ["/"] : Array.from(new Set(acc.map((x) => x === "" ? "/" : collapseDuplicateSlashes(x))));
331
+ }
332
+ return this._routePatternCandidates;
333
+ }
334
+ get pathParamsDefinition() {
335
+ if (this._pathParamsDefinition === void 0) {
336
+ const entries = this.routeTokens.filter((t) => t.kind !== "static").map((t) => t.kind === "param" ? [t.name, !t.optional] : ["*", !t.optional]);
337
+ this._pathParamsDefinition = Object.fromEntries(entries);
338
+ }
339
+ return this._pathParamsDefinition;
340
+ }
341
+ get definitionWithoutTrailingWildcard() {
342
+ if (this._definitionWithoutTrailingWildcard === void 0) {
343
+ this._definitionWithoutTrailingWildcard = this.definition.replace(/\*\??$/, "");
344
+ }
345
+ return this._definitionWithoutTrailingWildcard;
346
+ }
347
+ get routeRegexBaseStringRaw() {
348
+ if (this._routeRegexBaseStringRaw === void 0) {
349
+ if (this.routeTokens.length === 0) {
350
+ this._routeRegexBaseStringRaw = "";
351
+ } else {
352
+ let pattern = "";
353
+ for (const token of this.routeTokens) {
354
+ if (token.kind === "static") {
355
+ pattern += `/${escapeRegex(token.value)}`;
356
+ continue;
357
+ }
358
+ if (token.kind === "param") {
359
+ pattern += token.optional ? "(?:/([^/]+))?" : "/([^/]+)";
360
+ continue;
361
+ }
362
+ if (token.prefix.length > 0) {
363
+ pattern += `/${escapeRegex(token.prefix)}(.*)`;
364
+ } else {
365
+ pattern += "(?:/(.*))?";
366
+ }
367
+ }
368
+ this._routeRegexBaseStringRaw = pattern;
369
+ }
370
+ }
371
+ return this._routeRegexBaseStringRaw;
372
+ }
312
373
  get normalizedDefinition() {
313
374
  if (this._normalizedDefinition === void 0) {
314
375
  this._normalizedDefinition = this.definition.length > 1 && this.definition.endsWith("/") ? this.definition.slice(0, -1) : this.definition;
@@ -323,11 +384,11 @@ class Route0 {
323
384
  }
324
385
  /** Fast pathname exact match check without building a full location object. */
325
386
  isExactPathnameMatch(pathname) {
326
- return this.regex.test(normalizePathname(pathname));
387
+ return this.regex.test(Route0._normalizePathname(pathname));
327
388
  }
328
389
  /** Fast pathname exact or ancestor match check without building a full location object. */
329
390
  isExactOrAncestorPathnameMatch(pathname) {
330
- const normalizedPathname = normalizePathname(pathname);
391
+ const normalizedPathname = Route0._normalizePathname(pathname);
331
392
  return this.regex.test(normalizedPathname) || this.regexAncestor.test(normalizedPathname);
332
393
  }
333
394
  /** Creates a grouped regex pattern string from many routes. */
@@ -377,10 +438,9 @@ class Route0 {
377
438
  const base = abs ? void 0 : "http://example.com";
378
439
  const url = new URL(hrefOrHrefRelOrLocation, base);
379
440
  const search = parseSearchQuery(url.search);
380
- const pathname = normalizePathname(url.pathname);
381
- const hrefRel = pathname + url.search + url.hash;
441
+ const hrefRel = url.pathname + url.search + url.hash;
382
442
  const location = {
383
- pathname,
443
+ pathname: url.pathname,
384
444
  search,
385
445
  searchString: url.search,
386
446
  hash: url.hash,
@@ -413,9 +473,8 @@ class Route0 {
413
473
  const location = Route0.getLocation(hrefOrHrefRelOrLocation);
414
474
  location.route = this.definition;
415
475
  location.params = {};
416
- const pathname = normalizePathname(location.pathname);
476
+ const pathname = Route0._normalizePathname(location.pathname);
417
477
  const paramNames = this.captureKeys;
418
- const defParts = this.definitionParts;
419
478
  const exactRe = this.regex;
420
479
  const ancestorRe = this.regexAncestor;
421
480
  const exactMatch = pathname.match(exactRe);
@@ -435,46 +494,25 @@ class Route0 {
435
494
  } else {
436
495
  location.params = {};
437
496
  }
438
- const pathParts = pathname === "/" ? ["/"] : pathname.split("/").filter(Boolean);
439
- let isPrefix = true;
440
- if (pathParts.length > defParts.length) {
441
- isPrefix = false;
442
- } else {
443
- for (let i = 0; i < pathParts.length; i++) {
444
- const defPart = defParts[i];
445
- const pathPart = pathParts[i];
446
- if (!defPart) {
447
- isPrefix = false;
448
- break;
449
- }
450
- if (defPart.startsWith(":")) continue;
451
- if (defPart.includes("*")) {
452
- const prefix = defPart.replace(/\*\??$/, "");
453
- if (pathPart.startsWith(prefix)) continue;
454
- isPrefix = false;
455
- break;
456
- }
457
- if (defPart !== pathPart) {
458
- isPrefix = false;
459
- break;
460
- }
497
+ let descendant = false;
498
+ let descendantMatch = null;
499
+ let descendantCaptureKeys = [];
500
+ if (!exact && !ancestor) {
501
+ for (const matcher of this.regexDescendantMatchers) {
502
+ const match = pathname.match(matcher.regex);
503
+ if (!match) continue;
504
+ descendant = true;
505
+ descendantMatch = match;
506
+ descendantCaptureKeys = matcher.captureKeys;
507
+ break;
461
508
  }
462
509
  }
463
- const descendant = !exact && isPrefix;
464
510
  const unmatched = !exact && !ancestor && !descendant;
465
- if (descendant) {
466
- const descendantParams = {};
467
- for (let i = 0; i < pathParts.length; i++) {
468
- const defPart = defParts[i];
469
- const pathPart = pathParts[i];
470
- if (!defPart || !pathPart) continue;
471
- if (defPart.startsWith(":")) {
472
- descendantParams[defPart.replace(/^:/, "").replace(/\?$/, "")] = decodeURIComponent(pathPart);
473
- } else if (defPart.includes("*")) {
474
- descendantParams["*"] = decodeURIComponent(pathPart);
475
- }
476
- }
477
- location.params = descendantParams;
511
+ if (descendant && descendantMatch) {
512
+ const values = descendantMatch.slice(1, 1 + descendantCaptureKeys.length);
513
+ location.params = Object.fromEntries(
514
+ descendantCaptureKeys.map((key, index) => [key, decodeURIComponent(values[index])])
515
+ );
478
516
  }
479
517
  return {
480
518
  ...location,
@@ -573,15 +611,18 @@ class Route0 {
573
611
  };
574
612
  /** True when path structure is equal (param names are ignored). */
575
613
  isSame(other) {
576
- return getRouteTokens(this.definition).map((t) => {
614
+ const thisShape = this.routeTokens.map((t) => {
577
615
  if (t.kind === "static") return `s:${t.value}`;
578
616
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
579
617
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
580
- }).join("/") === getRouteTokens(other.definition).map((t) => {
618
+ }).join("/");
619
+ const otherRoute = Route0.from(other);
620
+ const otherShape = otherRoute.routeTokens.map((t) => {
581
621
  if (t.kind === "static") return `s:${t.value}`;
582
622
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
583
623
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
584
624
  }).join("/");
625
+ return thisShape === otherShape;
585
626
  }
586
627
  /** Static convenience wrapper for `isSame`. */
587
628
  static isSame(a, b) {
@@ -605,11 +646,21 @@ class Route0 {
605
646
  const thisParts = getParts(this.definition);
606
647
  const otherParts = getParts(other.definition);
607
648
  if (thisParts.length <= otherParts.length) return false;
649
+ const matchesPatternPart = (patternPart, valuePart) => {
650
+ if (patternPart.startsWith(":")) return { match: true, wildcard: false };
651
+ const wildcardIndex = patternPart.indexOf("*");
652
+ if (wildcardIndex >= 0) {
653
+ const prefix = patternPart.slice(0, wildcardIndex);
654
+ return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true };
655
+ }
656
+ return { match: patternPart === valuePart, wildcard: false };
657
+ };
608
658
  for (let i = 0; i < otherParts.length; i++) {
609
659
  const otherPart = otherParts[i];
610
660
  const thisPart = thisParts[i];
611
- if (otherPart.startsWith(":")) continue;
612
- if (otherPart !== thisPart) return false;
661
+ const result = matchesPatternPart(otherPart, thisPart);
662
+ if (!result.match) return false;
663
+ if (result.wildcard) return true;
613
664
  }
614
665
  return true;
615
666
  }
@@ -624,58 +675,54 @@ class Route0 {
624
675
  const thisParts = getParts(this.definition);
625
676
  const otherParts = getParts(other.definition);
626
677
  if (thisParts.length >= otherParts.length) return false;
678
+ const matchesPatternPart = (patternPart, valuePart) => {
679
+ if (patternPart.startsWith(":")) return { match: true, wildcard: false };
680
+ const wildcardIndex = patternPart.indexOf("*");
681
+ if (wildcardIndex >= 0) {
682
+ const prefix = patternPart.slice(0, wildcardIndex);
683
+ return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true };
684
+ }
685
+ return { match: patternPart === valuePart, wildcard: false };
686
+ };
627
687
  for (let i = 0; i < thisParts.length; i++) {
628
688
  const thisPart = thisParts[i];
629
689
  const otherPart = otherParts[i];
630
- if (thisPart.startsWith(":")) continue;
631
- if (thisPart !== otherPart) return false;
690
+ const result = matchesPatternPart(thisPart, otherPart);
691
+ if (!result.match) return false;
692
+ if (result.wildcard) return true;
632
693
  }
633
694
  return true;
634
695
  }
635
696
  /** True when two route patterns can match the same concrete URL. */
636
- isConflict(other) {
697
+ isOverlap(other) {
637
698
  if (!other) return false;
638
- other = Route0.create(other);
699
+ const otherRoute = Route0.from(other);
639
700
  const thisRegex = this.regex;
640
- const otherRegex = other.regex;
641
- const makeCandidates = (definition) => {
642
- const tokens = getRouteTokens(definition);
643
- const values = (token) => {
644
- if (token.kind === "static") return [token.value];
645
- if (token.kind === "param") return token.optional ? ["", "x"] : ["x"];
646
- if (token.prefix.length > 0) return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x/y`];
647
- return ["", "x", "x/y"];
648
- };
649
- let acc = [""];
650
- for (const token of tokens) {
651
- const next = [];
652
- for (const base of acc) {
653
- for (const value of values(token)) {
654
- if (value === "") {
655
- next.push(base);
656
- } else if (value.startsWith("/")) {
657
- next.push(`${base}${value}`);
658
- } else {
659
- next.push(`${base}/${value}`);
660
- }
661
- }
662
- }
663
- acc = next;
664
- }
665
- if (acc.length === 0) return ["/"];
666
- return Array.from(new Set(acc.map((x) => x === "" ? "/" : x.replace(/\/{2,}/g, "/"))));
667
- };
668
- const thisCandidates = makeCandidates(this.definition);
669
- const otherCandidates = makeCandidates(other.definition);
701
+ const otherRegex = otherRoute.regex;
702
+ const thisCandidates = this.routePatternCandidates;
703
+ const otherCandidates = otherRoute.routePatternCandidates;
670
704
  if (thisCandidates.some((path) => otherRegex.test(path))) return true;
671
705
  if (otherCandidates.some((path) => thisRegex.test(path))) return true;
672
706
  return false;
673
707
  }
674
- /** True when paths are same or can overlap when optional parts are omitted. */
675
- isMayBeSame(other) {
708
+ /**
709
+ * True when overlap is not resolvable by route ordering inside one route set.
710
+ *
711
+ * Non-conflicting overlap means one route is a strict subset of another
712
+ * (e.g. `/x/y` is a strict subset of `/x/:id`) and can be safely ordered first.
713
+ */
714
+ isConflict(other) {
676
715
  if (!other) return false;
677
- other = Route0.create(other);
678
- return this.isSame(other) || this.isConflict(other);
716
+ const otherRoute = Route0.from(other);
717
+ if (!this.isOverlap(otherRoute)) return false;
718
+ const thisRegex = this.regex;
719
+ const otherRegex = otherRoute.regex;
720
+ const thisCandidates = this.routePatternCandidates;
721
+ const otherCandidates = otherRoute.routePatternCandidates;
722
+ const thisExclusive = thisCandidates.some((path) => thisRegex.test(path) && !otherRegex.test(path));
723
+ const otherExclusive = otherCandidates.some((path) => otherRegex.test(path) && !thisRegex.test(path));
724
+ if (thisExclusive !== otherExclusive) return false;
725
+ return true;
679
726
  }
680
727
  /** Specificity comparator used for deterministic route ordering. */
681
728
  isMoreSpecificThan(other) {
@@ -703,6 +750,21 @@ class Route0 {
703
750
  }
704
751
  }
705
752
  class Routes {
753
+ static _getNormalizedPathnameFromInput(hrefOrHrefRelOrLocation) {
754
+ if (hrefOrHrefRelOrLocation instanceof URL) {
755
+ return normalizeSlashPath(hrefOrHrefRelOrLocation.pathname);
756
+ }
757
+ if (typeof hrefOrHrefRelOrLocation !== "string") {
758
+ if (typeof hrefOrHrefRelOrLocation.pathname === "string") {
759
+ return normalizeSlashPath(hrefOrHrefRelOrLocation.pathname);
760
+ }
761
+ hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
762
+ }
763
+ const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
764
+ const base = abs ? void 0 : "http://example.com";
765
+ const url = new URL(hrefOrHrefRelOrLocation, base);
766
+ return normalizeSlashPath(url.pathname);
767
+ }
706
768
  _routes;
707
769
  _pathsOrdering;
708
770
  _keysOrdering;
@@ -766,7 +828,7 @@ class Routes {
766
828
  }
767
829
  _getLocation(hrefOrHrefRelOrLocation) {
768
830
  const input = hrefOrHrefRelOrLocation;
769
- const pathname = getNormalizedPathnameFromInput(input);
831
+ const pathname = Routes._getNormalizedPathnameFromInput(input);
770
832
  for (const route of this._ordered) {
771
833
  if (!route.isExactPathnameMatch(pathname)) {
772
834
  continue;
@@ -788,7 +850,7 @@ class Routes {
788
850
  entries.sort(([_keyA, routeA], [_keyB, routeB]) => {
789
851
  const partsA = getParts(routeA.definition);
790
852
  const partsB = getParts(routeB.definition);
791
- if (routeA.isMayBeSame(routeB)) {
853
+ if (routeA.isOverlap(routeB)) {
792
854
  if (routeA.isMoreSpecificThan(routeB)) return -1;
793
855
  if (routeB.isMoreSpecificThan(routeA)) return 1;
794
856
  }