@devp0nt/route0 1.0.0-next.76 → 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 getRouteRegexBaseStrictString = (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 normalizePathname = (pathname) => {
75
- if (pathname.length > 1 && pathname.endsWith("/")) {
76
- return pathname.slice(0, -1);
77
- }
78
- return pathname;
79
- };
80
- const getNormalizedPathnameFromInput = (hrefOrHrefRelOrLocation) => {
81
- if (hrefOrHrefRelOrLocation instanceof URL) {
82
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
83
- }
84
- if (typeof hrefOrHrefRelOrLocation !== "string") {
85
- if (typeof hrefOrHrefRelOrLocation.pathname === "string") {
86
- return normalizePathname(hrefOrHrefRelOrLocation.pathname);
87
- }
88
- hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
89
- }
90
- const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
91
- const base = abs ? void 0 : "http://example.com";
92
- const url = new URL(hrefOrHrefRelOrLocation, base);
93
- return normalizePathname(url.pathname);
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}`;
8
+ return withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
94
9
  };
95
10
  class Route0 {
96
11
  definition;
97
12
  params;
98
13
  _origin;
99
14
  _callable;
100
- _regexBaseStrictString;
15
+ _routeSegments;
16
+ _routeTokens;
17
+ _routePatternCandidates;
18
+ _pathParamsDefinition;
19
+ _definitionWithoutTrailingWildcard;
20
+ _routeRegexBaseStringRaw;
101
21
  _regexBaseString;
102
- _regexStrictString;
103
22
  _regexString;
104
- _regexStrict;
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,9 +66,10 @@ class Route0 {
121
66
  this._origin = origin;
122
67
  }
123
68
  constructor(definition, config = {}) {
124
- validateRouteDefinition(definition);
125
- this.definition = definition;
126
- this.params = Route0._getParamsDefinitionByDefinition(definition);
69
+ const normalizedDefinition = Route0._normalizeRouteDefinition(definition);
70
+ Route0._validateRouteDefinition(normalizedDefinition);
71
+ this.definition = normalizedDefinition;
72
+ this.params = this.pathParamsDefinition;
127
73
  const { origin } = config;
128
74
  if (origin && typeof origin === "string" && origin.length) {
129
75
  this._origin = origin;
@@ -151,7 +97,10 @@ class Route0 {
151
97
  if (typeof definition === "function" || typeof definition === "object") {
152
98
  return definition.clone(config);
153
99
  }
154
- const original = new Route0(definition, config);
100
+ const original = new Route0(
101
+ Route0._normalizeRouteDefinition(definition),
102
+ config
103
+ );
155
104
  return original._callable;
156
105
  }
157
106
  /**
@@ -163,25 +112,25 @@ class Route0 {
163
112
  if (typeof definition === "function") {
164
113
  return definition;
165
114
  }
166
- const original = typeof definition === "object" ? definition : new Route0(definition);
115
+ const original = typeof definition === "object" ? definition : new Route0(
116
+ Route0._normalizeRouteDefinition(definition)
117
+ );
167
118
  return original._callable;
168
119
  }
169
120
  static _getAbsPath(origin, url) {
170
121
  return new URL(url, origin).toString().replace(/\/$/, "");
171
122
  }
172
- static _getParamsDefinitionByDefinition(definition) {
173
- return getPathParamsDefinition(definition);
174
- }
175
123
  search() {
176
124
  return this._callable;
177
125
  }
178
126
  /** Extends the current route definition by appending a suffix route. */
179
127
  extend(suffixDefinition) {
180
- const sourceDefinitionWithoutWildcard = stripTrailingWildcard(this.definition);
181
- const definition = `${sourceDefinitionWithoutWildcard}/${suffixDefinition}`.replace(/\/{2,}/g, "/");
128
+ const definition = Route0._normalizeRouteDefinition(`${this.definitionWithoutTrailingWildcard}/${suffixDefinition}`);
182
129
  return Route0.create(
183
130
  definition,
184
- { origin: this._origin }
131
+ {
132
+ origin: this._origin
133
+ }
185
134
  );
186
135
  }
187
136
  // implementation
@@ -253,7 +202,7 @@ class Route0 {
253
202
  url = url.replace(/\*/g, () => String(paramsInput["*"] ?? ""));
254
203
  const searchString = stringifySearchQuery(searchInput);
255
204
  url = [url, searchString].filter(Boolean).join("?");
256
- url = url.replace(/\/{2,}/g, "/");
205
+ url = collapseDuplicateSlashes(url);
257
206
  url = absInput ? Route0._getAbsPath(absOriginInput || this.origin, url) : url;
258
207
  if (hashInput !== void 0) {
259
208
  url = `${url}#${hashInput}`;
@@ -265,42 +214,24 @@ class Route0 {
265
214
  return Object.keys(this.params);
266
215
  }
267
216
  getTokens() {
268
- return getRouteTokens(this.definition);
217
+ return this.routeTokens.map((token) => ({ ...token }));
269
218
  }
270
219
  /** Clones route with optional config override. */
271
220
  clone(config) {
272
221
  return Route0.create(this.definition, config);
273
222
  }
274
- get regexBaseStrictString() {
275
- if (this._regexBaseStrictString === void 0) {
276
- this._regexBaseStrictString = getRouteRegexBaseStrictString(this.definition);
277
- }
278
- return this._regexBaseStrictString;
279
- }
280
223
  get regexBaseString() {
281
224
  if (this._regexBaseString === void 0) {
282
- this._regexBaseString = this.regexBaseStrictString.replace(/\/+$/, "") + "/?";
225
+ this._regexBaseString = this.routeRegexBaseStringRaw.replace(/\/+$/, "") + "/?";
283
226
  }
284
227
  return this._regexBaseString;
285
228
  }
286
- get regexStrictString() {
287
- if (this._regexStrictString === void 0) {
288
- this._regexStrictString = `^${this.regexBaseStrictString}$`;
289
- }
290
- return this._regexStrictString;
291
- }
292
229
  get regexString() {
293
230
  if (this._regexString === void 0) {
294
231
  this._regexString = `^${this.regexBaseString}$`;
295
232
  }
296
233
  return this._regexString;
297
234
  }
298
- get regexStrict() {
299
- if (this._regexStrict === void 0) {
300
- this._regexStrict = new RegExp(this.regexStrictString);
301
- }
302
- return this._regexStrict;
303
- }
304
235
  get regex() {
305
236
  if (this._regex === void 0) {
306
237
  this._regex = new RegExp(this.regexString);
@@ -313,12 +244,132 @@ class Route0 {
313
244
  }
314
245
  return this._regexAncestor;
315
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
+ }
316
274
  get captureKeys() {
317
275
  if (this._captureKeys === void 0) {
318
- this._captureKeys = getRouteCaptureKeys(this.definition);
276
+ this._captureKeys = this.routeTokens.filter((token) => token.kind !== "static").map((token) => token.kind === "param" ? token.name : "*");
319
277
  }
320
278
  return this._captureKeys;
321
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
+ }
322
373
  get normalizedDefinition() {
323
374
  if (this._normalizedDefinition === void 0) {
324
375
  this._normalizedDefinition = this.definition.length > 1 && this.definition.endsWith("/") ? this.definition.slice(0, -1) : this.definition;
@@ -333,23 +384,13 @@ class Route0 {
333
384
  }
334
385
  /** Fast pathname exact match check without building a full location object. */
335
386
  isExactPathnameMatch(pathname) {
336
- return this.regex.test(normalizePathname(pathname));
387
+ return this.regex.test(Route0._normalizePathname(pathname));
337
388
  }
338
389
  /** Fast pathname exact or ancestor match check without building a full location object. */
339
390
  isExactOrAncestorPathnameMatch(pathname) {
340
- const normalizedPathname = normalizePathname(pathname);
391
+ const normalizedPathname = Route0._normalizePathname(pathname);
341
392
  return this.regex.test(normalizedPathname) || this.regexAncestor.test(normalizedPathname);
342
393
  }
343
- /** Creates a grouped strict regex pattern string from many routes. */
344
- static getRegexStrictStringGroup(routes) {
345
- const patterns = routes.map((route) => route.regexStrictString).join("|");
346
- return `(${patterns})`;
347
- }
348
- /** Creates a strict grouped regex from many routes. */
349
- static getRegexStrictGroup(routes) {
350
- const patterns = Route0.getRegexStrictStringGroup(routes);
351
- return new RegExp(`^(${patterns})$`);
352
- }
353
394
  /** Creates a grouped regex pattern string from many routes. */
354
395
  static getRegexStringGroup(routes) {
355
396
  const patterns = routes.map((route) => route.regexString).join("|");
@@ -397,10 +438,9 @@ class Route0 {
397
438
  const base = abs ? void 0 : "http://example.com";
398
439
  const url = new URL(hrefOrHrefRelOrLocation, base);
399
440
  const search = parseSearchQuery(url.search);
400
- const pathname = normalizePathname(url.pathname);
401
- const hrefRel = pathname + url.search + url.hash;
441
+ const hrefRel = url.pathname + url.search + url.hash;
402
442
  const location = {
403
- pathname,
443
+ pathname: url.pathname,
404
444
  search,
405
445
  searchString: url.search,
406
446
  hash: url.hash,
@@ -433,9 +473,8 @@ class Route0 {
433
473
  const location = Route0.getLocation(hrefOrHrefRelOrLocation);
434
474
  location.route = this.definition;
435
475
  location.params = {};
436
- const pathname = normalizePathname(location.pathname);
476
+ const pathname = Route0._normalizePathname(location.pathname);
437
477
  const paramNames = this.captureKeys;
438
- const defParts = this.definitionParts;
439
478
  const exactRe = this.regex;
440
479
  const ancestorRe = this.regexAncestor;
441
480
  const exactMatch = pathname.match(exactRe);
@@ -455,46 +494,25 @@ class Route0 {
455
494
  } else {
456
495
  location.params = {};
457
496
  }
458
- const pathParts = pathname === "/" ? ["/"] : pathname.split("/").filter(Boolean);
459
- let isPrefix = true;
460
- if (pathParts.length > defParts.length) {
461
- isPrefix = false;
462
- } else {
463
- for (let i = 0; i < pathParts.length; i++) {
464
- const defPart = defParts[i];
465
- const pathPart = pathParts[i];
466
- if (!defPart) {
467
- isPrefix = false;
468
- break;
469
- }
470
- if (defPart.startsWith(":")) continue;
471
- if (defPart.includes("*")) {
472
- const prefix = defPart.replace(/\*\??$/, "");
473
- if (pathPart.startsWith(prefix)) continue;
474
- isPrefix = false;
475
- break;
476
- }
477
- if (defPart !== pathPart) {
478
- isPrefix = false;
479
- break;
480
- }
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;
481
508
  }
482
509
  }
483
- const descendant = !exact && isPrefix;
484
510
  const unmatched = !exact && !ancestor && !descendant;
485
- if (descendant) {
486
- const descendantParams = {};
487
- for (let i = 0; i < pathParts.length; i++) {
488
- const defPart = defParts[i];
489
- const pathPart = pathParts[i];
490
- if (!defPart || !pathPart) continue;
491
- if (defPart.startsWith(":")) {
492
- descendantParams[defPart.replace(/^:/, "").replace(/\?$/, "")] = decodeURIComponent(pathPart);
493
- } else if (defPart.includes("*")) {
494
- descendantParams["*"] = decodeURIComponent(pathPart);
495
- }
496
- }
497
- 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
+ );
498
516
  }
499
517
  return {
500
518
  ...location,
@@ -593,15 +611,18 @@ class Route0 {
593
611
  };
594
612
  /** True when path structure is equal (param names are ignored). */
595
613
  isSame(other) {
596
- return getRouteTokens(this.definition).map((t) => {
614
+ const thisShape = this.routeTokens.map((t) => {
597
615
  if (t.kind === "static") return `s:${t.value}`;
598
616
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
599
617
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
600
- }).join("/") === getRouteTokens(other.definition).map((t) => {
618
+ }).join("/");
619
+ const otherRoute = Route0.from(other);
620
+ const otherShape = otherRoute.routeTokens.map((t) => {
601
621
  if (t.kind === "static") return `s:${t.value}`;
602
622
  if (t.kind === "param") return `p:${t.optional ? "o" : "r"}`;
603
623
  return `w:${t.prefix}:${t.optional ? "o" : "r"}`;
604
624
  }).join("/");
625
+ return thisShape === otherShape;
605
626
  }
606
627
  /** Static convenience wrapper for `isSame`. */
607
628
  static isSame(a, b) {
@@ -625,11 +646,21 @@ class Route0 {
625
646
  const thisParts = getParts(this.definition);
626
647
  const otherParts = getParts(other.definition);
627
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
+ };
628
658
  for (let i = 0; i < otherParts.length; i++) {
629
659
  const otherPart = otherParts[i];
630
660
  const thisPart = thisParts[i];
631
- if (otherPart.startsWith(":")) continue;
632
- if (otherPart !== thisPart) return false;
661
+ const result = matchesPatternPart(otherPart, thisPart);
662
+ if (!result.match) return false;
663
+ if (result.wildcard) return true;
633
664
  }
634
665
  return true;
635
666
  }
@@ -644,58 +675,54 @@ class Route0 {
644
675
  const thisParts = getParts(this.definition);
645
676
  const otherParts = getParts(other.definition);
646
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
+ };
647
687
  for (let i = 0; i < thisParts.length; i++) {
648
688
  const thisPart = thisParts[i];
649
689
  const otherPart = otherParts[i];
650
- if (thisPart.startsWith(":")) continue;
651
- if (thisPart !== otherPart) return false;
690
+ const result = matchesPatternPart(thisPart, otherPart);
691
+ if (!result.match) return false;
692
+ if (result.wildcard) return true;
652
693
  }
653
694
  return true;
654
695
  }
655
696
  /** True when two route patterns can match the same concrete URL. */
656
- isConflict(other) {
697
+ isOverlap(other) {
657
698
  if (!other) return false;
658
- other = Route0.create(other);
699
+ const otherRoute = Route0.from(other);
659
700
  const thisRegex = this.regex;
660
- const otherRegex = other.regex;
661
- const makeCandidates = (definition) => {
662
- const tokens = getRouteTokens(definition);
663
- const values = (token) => {
664
- if (token.kind === "static") return [token.value];
665
- if (token.kind === "param") return token.optional ? ["", "x"] : ["x"];
666
- if (token.prefix.length > 0) return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x/y`];
667
- return ["", "x", "x/y"];
668
- };
669
- let acc = [""];
670
- for (const token of tokens) {
671
- const next = [];
672
- for (const base of acc) {
673
- for (const value of values(token)) {
674
- if (value === "") {
675
- next.push(base);
676
- } else if (value.startsWith("/")) {
677
- next.push(`${base}${value}`);
678
- } else {
679
- next.push(`${base}/${value}`);
680
- }
681
- }
682
- }
683
- acc = next;
684
- }
685
- if (acc.length === 0) return ["/"];
686
- return Array.from(new Set(acc.map((x) => x === "" ? "/" : x.replace(/\/{2,}/g, "/"))));
687
- };
688
- const thisCandidates = makeCandidates(this.definition);
689
- const otherCandidates = makeCandidates(other.definition);
701
+ const otherRegex = otherRoute.regex;
702
+ const thisCandidates = this.routePatternCandidates;
703
+ const otherCandidates = otherRoute.routePatternCandidates;
690
704
  if (thisCandidates.some((path) => otherRegex.test(path))) return true;
691
705
  if (otherCandidates.some((path) => thisRegex.test(path))) return true;
692
706
  return false;
693
707
  }
694
- /** True when paths are same or can overlap when optional parts are omitted. */
695
- 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) {
696
715
  if (!other) return false;
697
- other = Route0.create(other);
698
- 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;
699
726
  }
700
727
  /** Specificity comparator used for deterministic route ordering. */
701
728
  isMoreSpecificThan(other) {
@@ -723,6 +750,21 @@ class Route0 {
723
750
  }
724
751
  }
725
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
+ }
726
768
  _routes;
727
769
  _pathsOrdering;
728
770
  _keysOrdering;
@@ -786,7 +828,7 @@ class Routes {
786
828
  }
787
829
  _getLocation(hrefOrHrefRelOrLocation) {
788
830
  const input = hrefOrHrefRelOrLocation;
789
- const pathname = getNormalizedPathnameFromInput(input);
831
+ const pathname = Routes._getNormalizedPathnameFromInput(input);
790
832
  for (const route of this._ordered) {
791
833
  if (!route.isExactPathnameMatch(pathname)) {
792
834
  continue;
@@ -808,7 +850,7 @@ class Routes {
808
850
  entries.sort(([_keyA, routeA], [_keyB, routeB]) => {
809
851
  const partsA = getParts(routeA.definition);
810
852
  const partsB = getParts(routeB.definition);
811
- if (routeA.isMayBeSame(routeB)) {
853
+ if (routeA.isOverlap(routeB)) {
812
854
  if (routeA.isMoreSpecificThan(routeB)) return -1;
813
855
  if (routeB.isMoreSpecificThan(routeA)) return 1;
814
856
  }