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

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.
@@ -24,113 +24,58 @@ __export(index_exports, {
24
24
  module.exports = __toCommonJS(index_exports);
25
25
  var import_flat0 = require("@devp0nt/flat0");
26
26
  const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
- const getRouteSegments = (definition) => {
28
- if (definition === "" || definition === "/") return [];
29
- return definition.split("/").filter(Boolean);
30
- };
31
- const getRouteTokens = (definition) => {
32
- const segments = getRouteSegments(definition);
33
- return segments.map((segment) => {
34
- const param = segment.match(/^:([A-Za-z0-9_]+)(\?)?$/);
35
- if (param) {
36
- return { kind: "param", name: param[1], optional: param[2] === "?" };
37
- }
38
- if (segment === "*" || segment === "*?") {
39
- return { kind: "wildcard", prefix: "", optional: segment.endsWith("?") };
40
- }
41
- const wildcard = segment.match(/^(.*)\*(\?)?$/);
42
- if (wildcard && !segment.includes("\\*")) {
43
- return { kind: "wildcard", prefix: wildcard[1], optional: wildcard[2] === "?" };
44
- }
45
- return { kind: "static", value: segment };
46
- });
47
- };
48
- const getRouteRegexBaseString = (definition) => {
49
- const tokens = getRouteTokens(definition);
50
- if (tokens.length === 0) return "";
51
- let pattern = "";
52
- for (const token of tokens) {
53
- if (token.kind === "static") {
54
- pattern += `/${escapeRegex(token.value)}`;
55
- continue;
56
- }
57
- if (token.kind === "param") {
58
- pattern += token.optional ? "(?:/([^/]+))?" : "/([^/]+)";
59
- continue;
60
- }
61
- if (token.prefix.length > 0) {
62
- pattern += `/${escapeRegex(token.prefix)}(.*)`;
63
- } else {
64
- pattern += "(?:/(.*))?";
65
- }
66
- }
67
- return pattern;
68
- };
69
- const getRouteCaptureKeys = (definition) => {
70
- const keys = [];
71
- for (const token of getRouteTokens(definition)) {
72
- if (token.kind === "param") keys.push(token.name);
73
- if (token.kind === "wildcard") keys.push("*");
74
- }
75
- return keys;
76
- };
77
- const getPathParamsDefinition = (definition) => {
78
- const entries = getRouteTokens(definition).filter((t) => t.kind !== "static").map((t) => t.kind === "param" ? [t.name, !t.optional] : ["*", !t.optional]);
79
- return Object.fromEntries(entries);
80
- };
81
- const validateRouteDefinition = (definition) => {
82
- const segments = getRouteSegments(definition);
83
- const wildcardSegments = segments.filter((segment) => segment.includes("*"));
84
- if (wildcardSegments.length === 0) return;
85
- if (wildcardSegments.length > 1) {
86
- throw new Error(`Invalid route definition "${definition}": only one wildcard segment is allowed`);
87
- }
88
- const wildcardSegmentIndex = segments.findIndex((segment) => segment.includes("*"));
89
- const wildcardSegment = segments[wildcardSegmentIndex];
90
- if (!wildcardSegment.match(/^(?:\*|\*\?|[^*]+\*|\S+\*\?)$/)) {
91
- throw new Error(`Invalid route definition "${definition}": wildcard must be trailing in its segment`);
92
- }
93
- if (wildcardSegmentIndex !== segments.length - 1) {
94
- throw new Error(`Invalid route definition "${definition}": wildcard segment is allowed only at the end`);
95
- }
96
- };
97
- const stripTrailingWildcard = (definition) => definition.replace(/\*\??$/, "");
98
- const normalizeRouteDefinition = (definition) => {
99
- const value = definition.replace(/\/{2,}/g, "/");
100
- if (value === "" || value === "/") return "/";
101
- const withLeadingSlash = value.startsWith("/") ? value : `/${value}`;
27
+ const collapseDuplicateSlashes = (value) => value.replace(/\/{2,}/g, "/");
28
+ const normalizeSlashPath = (value) => {
29
+ const collapsed = collapseDuplicateSlashes(value);
30
+ if (collapsed === "" || collapsed === "/") return "/";
31
+ const withLeadingSlash = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
102
32
  return withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
103
33
  };
104
- const normalizePathname = (pathname) => {
105
- return normalizeRouteDefinition(pathname);
106
- };
107
- const getNormalizedPathnameFromInput = (hrefOrHrefRelOrLocation) => {
108
- if (hrefOrHrefRelOrLocation instanceof URL) {
109
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
110
- }
111
- if (typeof hrefOrHrefRelOrLocation !== "string") {
112
- if (typeof hrefOrHrefRelOrLocation.pathname === "string") {
113
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
114
- }
115
- hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
116
- }
117
- const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
118
- const base = abs ? void 0 : "http://example.com";
119
- const url = new URL(hrefOrHrefRelOrLocation, base);
120
- return normalizePathname(url.pathname);
121
- };
122
34
  class Route0 {
123
35
  definition;
124
36
  params;
125
37
  _origin;
126
38
  _callable;
39
+ _routeSegments;
40
+ _routeTokens;
41
+ _routePatternCandidates;
42
+ _pathParamsDefinition;
43
+ _definitionWithoutTrailingWildcard;
44
+ _routeRegexBaseStringRaw;
127
45
  _regexBaseString;
128
46
  _regexString;
129
47
  _regex;
130
48
  _regexAncestor;
49
+ _regexDescendantMatchers;
131
50
  _captureKeys;
132
51
  _normalizedDefinition;
133
52
  _definitionParts;
53
+ static _getRouteSegments(definition) {
54
+ if (definition === "" || definition === "/") return [];
55
+ return definition.split("/").filter(Boolean);
56
+ }
57
+ static _normalizeRouteDefinition(definition) {
58
+ return normalizeSlashPath(definition);
59
+ }
60
+ static _normalizePathname(pathname) {
61
+ return Route0._normalizeRouteDefinition(pathname);
62
+ }
63
+ static _validateRouteDefinition(definition) {
64
+ const segments = Route0._getRouteSegments(definition);
65
+ const wildcardSegments = segments.filter((segment) => segment.includes("*"));
66
+ if (wildcardSegments.length === 0) return;
67
+ if (wildcardSegments.length > 1) {
68
+ throw new Error(`Invalid route definition "${definition}": only one wildcard segment is allowed`);
69
+ }
70
+ const wildcardSegmentIndex = segments.findIndex((segment) => segment.includes("*"));
71
+ const wildcardSegment = segments[wildcardSegmentIndex];
72
+ if (!wildcardSegment.match(/^(?:\*|\*\?|[^*]+\*|\S+\*\?)$/)) {
73
+ throw new Error(`Invalid route definition "${definition}": wildcard must be trailing in its segment`);
74
+ }
75
+ if (wildcardSegmentIndex !== segments.length - 1) {
76
+ throw new Error(`Invalid route definition "${definition}": wildcard segment is allowed only at the end`);
77
+ }
78
+ }
134
79
  Infer = null;
135
80
  /** Base URL used when generating absolute URLs (`abs: true`). */
136
81
  get origin() {
@@ -145,10 +90,10 @@ class Route0 {
145
90
  this._origin = origin;
146
91
  }
147
92
  constructor(definition, config = {}) {
148
- const normalizedDefinition = normalizeRouteDefinition(definition);
149
- validateRouteDefinition(normalizedDefinition);
93
+ const normalizedDefinition = Route0._normalizeRouteDefinition(definition);
94
+ Route0._validateRouteDefinition(normalizedDefinition);
150
95
  this.definition = normalizedDefinition;
151
- this.params = Route0._getParamsDefinitionByDefinition(normalizedDefinition);
96
+ this.params = this.pathParamsDefinition;
152
97
  const { origin } = config;
153
98
  if (origin && typeof origin === "string" && origin.length) {
154
99
  this._origin = origin;
@@ -177,7 +122,7 @@ class Route0 {
177
122
  return definition.clone(config);
178
123
  }
179
124
  const original = new Route0(
180
- normalizeRouteDefinition(definition),
125
+ Route0._normalizeRouteDefinition(definition),
181
126
  config
182
127
  );
183
128
  return original._callable;
@@ -192,23 +137,19 @@ class Route0 {
192
137
  return definition;
193
138
  }
194
139
  const original = typeof definition === "object" ? definition : new Route0(
195
- normalizeRouteDefinition(definition)
140
+ Route0._normalizeRouteDefinition(definition)
196
141
  );
197
142
  return original._callable;
198
143
  }
199
144
  static _getAbsPath(origin, url) {
200
145
  return new URL(url, origin).toString().replace(/\/$/, "");
201
146
  }
202
- static _getParamsDefinitionByDefinition(definition) {
203
- return getPathParamsDefinition(definition);
204
- }
205
147
  search() {
206
148
  return this._callable;
207
149
  }
208
150
  /** Extends the current route definition by appending a suffix route. */
209
151
  extend(suffixDefinition) {
210
- const sourceDefinitionWithoutWildcard = stripTrailingWildcard(this.definition);
211
- const definition = normalizeRouteDefinition(`${sourceDefinitionWithoutWildcard}/${suffixDefinition}`);
152
+ const definition = Route0._normalizeRouteDefinition(`${this.definitionWithoutTrailingWildcard}/${suffixDefinition}`);
212
153
  return Route0.create(
213
154
  definition,
214
155
  {
@@ -285,7 +226,7 @@ class Route0 {
285
226
  url = url.replace(/\*/g, () => String(paramsInput["*"] ?? ""));
286
227
  const searchString = (0, import_flat0.stringify)(searchInput);
287
228
  url = [url, searchString].filter(Boolean).join("?");
288
- url = url.replace(/\/{2,}/g, "/");
229
+ url = collapseDuplicateSlashes(url);
289
230
  url = absInput ? Route0._getAbsPath(absOriginInput || this.origin, url) : url;
290
231
  if (hashInput !== void 0) {
291
232
  url = `${url}#${hashInput}`;
@@ -297,7 +238,7 @@ class Route0 {
297
238
  return Object.keys(this.params);
298
239
  }
299
240
  getTokens() {
300
- return getRouteTokens(this.definition);
241
+ return this.routeTokens.map((token) => ({ ...token }));
301
242
  }
302
243
  /** Clones route with optional config override. */
303
244
  clone(config) {
@@ -305,7 +246,7 @@ class Route0 {
305
246
  }
306
247
  get regexBaseString() {
307
248
  if (this._regexBaseString === void 0) {
308
- this._regexBaseString = getRouteRegexBaseString(this.definition).replace(/\/+$/, "") + "/?";
249
+ this._regexBaseString = this.routeRegexBaseStringRaw.replace(/\/+$/, "") + "/?";
309
250
  }
310
251
  return this._regexBaseString;
311
252
  }
@@ -327,12 +268,132 @@ class Route0 {
327
268
  }
328
269
  return this._regexAncestor;
329
270
  }
271
+ get regexDescendantMatchers() {
272
+ if (this._regexDescendantMatchers === void 0) {
273
+ const matchers = [];
274
+ if (this.definitionParts[0] !== "/") {
275
+ let pattern = "";
276
+ const captureKeys = [];
277
+ for (const part of this.definitionParts) {
278
+ if (part.startsWith(":")) {
279
+ pattern += "/([^/]+)";
280
+ captureKeys.push(part.replace(/^:/, "").replace(/\?$/, ""));
281
+ } else if (part.includes("*")) {
282
+ const prefix = part.replace(/\*\??$/, "");
283
+ pattern += `/${escapeRegex(prefix)}[^/]*`;
284
+ captureKeys.push("*");
285
+ } else {
286
+ pattern += `/${escapeRegex(part)}`;
287
+ }
288
+ matchers.push({
289
+ regex: new RegExp(`^${pattern}/?$`),
290
+ captureKeys: [...captureKeys]
291
+ });
292
+ }
293
+ }
294
+ this._regexDescendantMatchers = matchers;
295
+ }
296
+ return this._regexDescendantMatchers;
297
+ }
330
298
  get captureKeys() {
331
299
  if (this._captureKeys === void 0) {
332
- this._captureKeys = getRouteCaptureKeys(this.definition);
300
+ this._captureKeys = this.routeTokens.filter((token) => token.kind !== "static").map((token) => token.kind === "param" ? token.name : "*");
333
301
  }
334
302
  return this._captureKeys;
335
303
  }
304
+ get routeSegments() {
305
+ if (this._routeSegments === void 0) {
306
+ this._routeSegments = Route0._getRouteSegments(this.definition);
307
+ }
308
+ return this._routeSegments;
309
+ }
310
+ get routeTokens() {
311
+ if (this._routeTokens === void 0) {
312
+ this._routeTokens = this.routeSegments.map((segment) => {
313
+ const param = segment.match(/^:([A-Za-z0-9_]+)(\?)?$/);
314
+ if (param) {
315
+ return { kind: "param", name: param[1], optional: param[2] === "?" };
316
+ }
317
+ if (segment === "*" || segment === "*?") {
318
+ return { kind: "wildcard", prefix: "", optional: segment.endsWith("?") };
319
+ }
320
+ const wildcard = segment.match(/^(.*)\*(\?)?$/);
321
+ if (wildcard && !segment.includes("\\*")) {
322
+ return { kind: "wildcard", prefix: wildcard[1], optional: wildcard[2] === "?" };
323
+ }
324
+ return { kind: "static", value: segment };
325
+ });
326
+ }
327
+ return this._routeTokens;
328
+ }
329
+ get routePatternCandidates() {
330
+ if (this._routePatternCandidates === void 0) {
331
+ const values = (token) => {
332
+ if (token.kind === "static") return [token.value];
333
+ if (token.kind === "param") return token.optional ? ["", "x", "y"] : ["x", "y"];
334
+ if (token.prefix.length > 0)
335
+ return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x`, `${token.prefix}/y/z`];
336
+ return ["", "x", "y", "x/y"];
337
+ };
338
+ let acc = [""];
339
+ for (const token of this.routeTokens) {
340
+ const next = [];
341
+ for (const base of acc) {
342
+ for (const value of values(token)) {
343
+ if (value === "") {
344
+ next.push(base);
345
+ } else if (value.startsWith("/")) {
346
+ next.push(`${base}${value}`);
347
+ } else {
348
+ next.push(`${base}/${value}`);
349
+ }
350
+ }
351
+ }
352
+ acc = next.length > 512 ? next.slice(0, 512) : next;
353
+ }
354
+ this._routePatternCandidates = acc.length === 0 ? ["/"] : Array.from(new Set(acc.map((x) => x === "" ? "/" : collapseDuplicateSlashes(x))));
355
+ }
356
+ return this._routePatternCandidates;
357
+ }
358
+ get pathParamsDefinition() {
359
+ if (this._pathParamsDefinition === void 0) {
360
+ const entries = this.routeTokens.filter((t) => t.kind !== "static").map((t) => t.kind === "param" ? [t.name, !t.optional] : ["*", !t.optional]);
361
+ this._pathParamsDefinition = Object.fromEntries(entries);
362
+ }
363
+ return this._pathParamsDefinition;
364
+ }
365
+ get definitionWithoutTrailingWildcard() {
366
+ if (this._definitionWithoutTrailingWildcard === void 0) {
367
+ this._definitionWithoutTrailingWildcard = this.definition.replace(/\*\??$/, "");
368
+ }
369
+ return this._definitionWithoutTrailingWildcard;
370
+ }
371
+ get routeRegexBaseStringRaw() {
372
+ if (this._routeRegexBaseStringRaw === void 0) {
373
+ if (this.routeTokens.length === 0) {
374
+ this._routeRegexBaseStringRaw = "";
375
+ } else {
376
+ let pattern = "";
377
+ for (const token of this.routeTokens) {
378
+ if (token.kind === "static") {
379
+ pattern += `/${escapeRegex(token.value)}`;
380
+ continue;
381
+ }
382
+ if (token.kind === "param") {
383
+ pattern += token.optional ? "(?:/([^/]+))?" : "/([^/]+)";
384
+ continue;
385
+ }
386
+ if (token.prefix.length > 0) {
387
+ pattern += `/${escapeRegex(token.prefix)}(.*)`;
388
+ } else {
389
+ pattern += "(?:/(.*))?";
390
+ }
391
+ }
392
+ this._routeRegexBaseStringRaw = pattern;
393
+ }
394
+ }
395
+ return this._routeRegexBaseStringRaw;
396
+ }
336
397
  get normalizedDefinition() {
337
398
  if (this._normalizedDefinition === void 0) {
338
399
  this._normalizedDefinition = this.definition.length > 1 && this.definition.endsWith("/") ? this.definition.slice(0, -1) : this.definition;
@@ -347,11 +408,11 @@ class Route0 {
347
408
  }
348
409
  /** Fast pathname exact match check without building a full location object. */
349
410
  isExactPathnameMatch(pathname) {
350
- return this.regex.test(normalizePathname(pathname));
411
+ return this.regex.test(Route0._normalizePathname(pathname));
351
412
  }
352
413
  /** Fast pathname exact or ancestor match check without building a full location object. */
353
414
  isExactOrAncestorPathnameMatch(pathname) {
354
- const normalizedPathname = normalizePathname(pathname);
415
+ const normalizedPathname = Route0._normalizePathname(pathname);
355
416
  return this.regex.test(normalizedPathname) || this.regexAncestor.test(normalizedPathname);
356
417
  }
357
418
  /** Creates a grouped regex pattern string from many routes. */
@@ -401,10 +462,9 @@ class Route0 {
401
462
  const base = abs ? void 0 : "http://example.com";
402
463
  const url = new URL(hrefOrHrefRelOrLocation, base);
403
464
  const search = (0, import_flat0.parse)(url.search);
404
- const pathname = normalizePathname(url.pathname);
405
- const hrefRel = pathname + url.search + url.hash;
465
+ const hrefRel = url.pathname + url.search + url.hash;
406
466
  const location = {
407
- pathname,
467
+ pathname: url.pathname,
408
468
  search,
409
469
  searchString: url.search,
410
470
  hash: url.hash,
@@ -437,9 +497,8 @@ class Route0 {
437
497
  const location = Route0.getLocation(hrefOrHrefRelOrLocation);
438
498
  location.route = this.definition;
439
499
  location.params = {};
440
- const pathname = normalizePathname(location.pathname);
500
+ const pathname = Route0._normalizePathname(location.pathname);
441
501
  const paramNames = this.captureKeys;
442
- const defParts = this.definitionParts;
443
502
  const exactRe = this.regex;
444
503
  const ancestorRe = this.regexAncestor;
445
504
  const exactMatch = pathname.match(exactRe);
@@ -459,46 +518,25 @@ class Route0 {
459
518
  } else {
460
519
  location.params = {};
461
520
  }
462
- const pathParts = pathname === "/" ? ["/"] : pathname.split("/").filter(Boolean);
463
- let isPrefix = true;
464
- if (pathParts.length > defParts.length) {
465
- isPrefix = false;
466
- } else {
467
- for (let i = 0; i < pathParts.length; i++) {
468
- const defPart = defParts[i];
469
- const pathPart = pathParts[i];
470
- if (!defPart) {
471
- isPrefix = false;
472
- break;
473
- }
474
- if (defPart.startsWith(":")) continue;
475
- if (defPart.includes("*")) {
476
- const prefix = defPart.replace(/\*\??$/, "");
477
- if (pathPart.startsWith(prefix)) continue;
478
- isPrefix = false;
479
- break;
480
- }
481
- if (defPart !== pathPart) {
482
- isPrefix = false;
483
- break;
484
- }
521
+ let descendant = false;
522
+ let descendantMatch = null;
523
+ let descendantCaptureKeys = [];
524
+ if (!exact && !ancestor) {
525
+ for (const matcher of this.regexDescendantMatchers) {
526
+ const match = pathname.match(matcher.regex);
527
+ if (!match) continue;
528
+ descendant = true;
529
+ descendantMatch = match;
530
+ descendantCaptureKeys = matcher.captureKeys;
531
+ break;
485
532
  }
486
533
  }
487
- const descendant = !exact && isPrefix;
488
534
  const unmatched = !exact && !ancestor && !descendant;
489
- if (descendant) {
490
- const descendantParams = {};
491
- for (let i = 0; i < pathParts.length; i++) {
492
- const defPart = defParts[i];
493
- const pathPart = pathParts[i];
494
- if (!defPart || !pathPart) continue;
495
- if (defPart.startsWith(":")) {
496
- descendantParams[defPart.replace(/^:/, "").replace(/\?$/, "")] = decodeURIComponent(pathPart);
497
- } else if (defPart.includes("*")) {
498
- descendantParams["*"] = decodeURIComponent(pathPart);
499
- }
500
- }
501
- location.params = descendantParams;
535
+ if (descendant && descendantMatch) {
536
+ const values = descendantMatch.slice(1, 1 + descendantCaptureKeys.length);
537
+ location.params = Object.fromEntries(
538
+ descendantCaptureKeys.map((key, index) => [key, decodeURIComponent(values[index])])
539
+ );
502
540
  }
503
541
  return {
504
542
  ...location,
@@ -597,15 +635,18 @@ class Route0 {
597
635
  };
598
636
  /** True when path structure is equal (param names are ignored). */
599
637
  isSame(other) {
600
- return getRouteTokens(this.definition).map((t) => {
638
+ const thisShape = this.routeTokens.map((t) => {
601
639
  if (t.kind === "static") return `s:${t.value}`;
602
640
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
603
641
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
604
- }).join("/") === getRouteTokens(other.definition).map((t) => {
642
+ }).join("/");
643
+ const otherRoute = Route0.from(other);
644
+ const otherShape = otherRoute.routeTokens.map((t) => {
605
645
  if (t.kind === "static") return `s:${t.value}`;
606
646
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
607
647
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
608
648
  }).join("/");
649
+ return thisShape === otherShape;
609
650
  }
610
651
  /** Static convenience wrapper for `isSame`. */
611
652
  static isSame(a, b) {
@@ -629,11 +670,21 @@ class Route0 {
629
670
  const thisParts = getParts(this.definition);
630
671
  const otherParts = getParts(other.definition);
631
672
  if (thisParts.length <= otherParts.length) return false;
673
+ const matchesPatternPart = (patternPart, valuePart) => {
674
+ if (patternPart.startsWith(":")) return { match: true, wildcard: false };
675
+ const wildcardIndex = patternPart.indexOf("*");
676
+ if (wildcardIndex >= 0) {
677
+ const prefix = patternPart.slice(0, wildcardIndex);
678
+ return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true };
679
+ }
680
+ return { match: patternPart === valuePart, wildcard: false };
681
+ };
632
682
  for (let i = 0; i < otherParts.length; i++) {
633
683
  const otherPart = otherParts[i];
634
684
  const thisPart = thisParts[i];
635
- if (otherPart.startsWith(":")) continue;
636
- if (otherPart !== thisPart) return false;
685
+ const result = matchesPatternPart(otherPart, thisPart);
686
+ if (!result.match) return false;
687
+ if (result.wildcard) return true;
637
688
  }
638
689
  return true;
639
690
  }
@@ -648,58 +699,54 @@ class Route0 {
648
699
  const thisParts = getParts(this.definition);
649
700
  const otherParts = getParts(other.definition);
650
701
  if (thisParts.length >= otherParts.length) return false;
702
+ const matchesPatternPart = (patternPart, valuePart) => {
703
+ if (patternPart.startsWith(":")) return { match: true, wildcard: false };
704
+ const wildcardIndex = patternPart.indexOf("*");
705
+ if (wildcardIndex >= 0) {
706
+ const prefix = patternPart.slice(0, wildcardIndex);
707
+ return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true };
708
+ }
709
+ return { match: patternPart === valuePart, wildcard: false };
710
+ };
651
711
  for (let i = 0; i < thisParts.length; i++) {
652
712
  const thisPart = thisParts[i];
653
713
  const otherPart = otherParts[i];
654
- if (thisPart.startsWith(":")) continue;
655
- if (thisPart !== otherPart) return false;
714
+ const result = matchesPatternPart(thisPart, otherPart);
715
+ if (!result.match) return false;
716
+ if (result.wildcard) return true;
656
717
  }
657
718
  return true;
658
719
  }
659
720
  /** True when two route patterns can match the same concrete URL. */
660
- isConflict(other) {
721
+ isOverlap(other) {
661
722
  if (!other) return false;
662
- other = Route0.create(other);
723
+ const otherRoute = Route0.from(other);
663
724
  const thisRegex = this.regex;
664
- const otherRegex = other.regex;
665
- const makeCandidates = (definition) => {
666
- const tokens = getRouteTokens(definition);
667
- const values = (token) => {
668
- if (token.kind === "static") return [token.value];
669
- if (token.kind === "param") return token.optional ? ["", "x"] : ["x"];
670
- if (token.prefix.length > 0) return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x/y`];
671
- return ["", "x", "x/y"];
672
- };
673
- let acc = [""];
674
- for (const token of tokens) {
675
- const next = [];
676
- for (const base of acc) {
677
- for (const value of values(token)) {
678
- if (value === "") {
679
- next.push(base);
680
- } else if (value.startsWith("/")) {
681
- next.push(`${base}${value}`);
682
- } else {
683
- next.push(`${base}/${value}`);
684
- }
685
- }
686
- }
687
- acc = next;
688
- }
689
- if (acc.length === 0) return ["/"];
690
- return Array.from(new Set(acc.map((x) => x === "" ? "/" : x.replace(/\/{2,}/g, "/"))));
691
- };
692
- const thisCandidates = makeCandidates(this.definition);
693
- const otherCandidates = makeCandidates(other.definition);
725
+ const otherRegex = otherRoute.regex;
726
+ const thisCandidates = this.routePatternCandidates;
727
+ const otherCandidates = otherRoute.routePatternCandidates;
694
728
  if (thisCandidates.some((path) => otherRegex.test(path))) return true;
695
729
  if (otherCandidates.some((path) => thisRegex.test(path))) return true;
696
730
  return false;
697
731
  }
698
- /** True when paths are same or can overlap when optional parts are omitted. */
699
- isMayBeSame(other) {
732
+ /**
733
+ * True when overlap is not resolvable by route ordering inside one route set.
734
+ *
735
+ * Non-conflicting overlap means one route is a strict subset of another
736
+ * (e.g. `/x/y` is a strict subset of `/x/:id`) and can be safely ordered first.
737
+ */
738
+ isConflict(other) {
700
739
  if (!other) return false;
701
- other = Route0.create(other);
702
- return this.isSame(other) || this.isConflict(other);
740
+ const otherRoute = Route0.from(other);
741
+ if (!this.isOverlap(otherRoute)) return false;
742
+ const thisRegex = this.regex;
743
+ const otherRegex = otherRoute.regex;
744
+ const thisCandidates = this.routePatternCandidates;
745
+ const otherCandidates = otherRoute.routePatternCandidates;
746
+ const thisExclusive = thisCandidates.some((path) => thisRegex.test(path) && !otherRegex.test(path));
747
+ const otherExclusive = otherCandidates.some((path) => otherRegex.test(path) && !thisRegex.test(path));
748
+ if (thisExclusive !== otherExclusive) return false;
749
+ return true;
703
750
  }
704
751
  /** Specificity comparator used for deterministic route ordering. */
705
752
  isMoreSpecificThan(other) {
@@ -727,6 +774,21 @@ class Route0 {
727
774
  }
728
775
  }
729
776
  class Routes {
777
+ static _getNormalizedPathnameFromInput(hrefOrHrefRelOrLocation) {
778
+ if (hrefOrHrefRelOrLocation instanceof URL) {
779
+ return normalizeSlashPath(hrefOrHrefRelOrLocation.pathname);
780
+ }
781
+ if (typeof hrefOrHrefRelOrLocation !== "string") {
782
+ if (typeof hrefOrHrefRelOrLocation.pathname === "string") {
783
+ return normalizeSlashPath(hrefOrHrefRelOrLocation.pathname);
784
+ }
785
+ hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
786
+ }
787
+ const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
788
+ const base = abs ? void 0 : "http://example.com";
789
+ const url = new URL(hrefOrHrefRelOrLocation, base);
790
+ return normalizeSlashPath(url.pathname);
791
+ }
730
792
  _routes;
731
793
  _pathsOrdering;
732
794
  _keysOrdering;
@@ -790,7 +852,7 @@ class Routes {
790
852
  }
791
853
  _getLocation(hrefOrHrefRelOrLocation) {
792
854
  const input = hrefOrHrefRelOrLocation;
793
- const pathname = getNormalizedPathnameFromInput(input);
855
+ const pathname = Routes._getNormalizedPathnameFromInput(input);
794
856
  for (const route of this._ordered) {
795
857
  if (!route.isExactPathnameMatch(pathname)) {
796
858
  continue;
@@ -812,7 +874,7 @@ class Routes {
812
874
  entries.sort(([_keyA, routeA], [_keyB, routeB]) => {
813
875
  const partsA = getParts(routeA.definition);
814
876
  const partsB = getParts(routeB.definition);
815
- if (routeA.isMayBeSame(routeB)) {
877
+ if (routeA.isOverlap(routeB)) {
816
878
  if (routeA.isMoreSpecificThan(routeB)) return -1;
817
879
  if (routeB.isMoreSpecificThan(routeA)) return 1;
818
880
  }