@devp0nt/route0 1.0.0-next.9 → 1.0.0

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,134 +1,937 @@
1
+ import { parse as parseSearchQuery, stringify as stringifySearchQuery } from "@devp0nt/flat0";
2
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ const collapseDuplicateSlashes = (value) => value.replace(/\/{2,}/g, "/");
1
4
  class Route0 {
2
- pathOriginalDefinition;
3
- pathDefinition;
4
- paramsDefinition;
5
- queryDefinition;
6
- baseUrl;
5
+ definition;
6
+ params;
7
+ _origin;
8
+ _callable;
9
+ _routeSegments;
10
+ _routeTokens;
11
+ _routePatternCandidates;
12
+ _pathParamsDefinition;
13
+ _definitionWithoutTrailingWildcard;
14
+ _routeRegexBaseStringRaw;
15
+ _regexBaseString;
16
+ _regexString;
17
+ _regex;
18
+ _regexAncestor;
19
+ _regexDescendantMatchers;
20
+ _captureKeys;
21
+ _normalizedDefinition;
22
+ _definitionParts;
23
+ static normalizeSlash = (value) => {
24
+ const collapsed = collapseDuplicateSlashes(value);
25
+ if (collapsed === "" || collapsed === "/") return "/";
26
+ const withLeadingSlash = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
27
+ return withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
28
+ };
29
+ static _getRouteSegments(definition) {
30
+ if (definition === "" || definition === "/") return [];
31
+ return definition.split("/").filter(Boolean);
32
+ }
33
+ static _validateRouteDefinition(definition) {
34
+ const segments = Route0._getRouteSegments(definition);
35
+ const wildcardSegments = segments.filter((segment) => segment.includes("*"));
36
+ if (wildcardSegments.length === 0) return;
37
+ if (wildcardSegments.length > 1) {
38
+ throw new Error(`Invalid route definition "${definition}": only one wildcard segment is allowed`);
39
+ }
40
+ const wildcardSegmentIndex = segments.findIndex((segment) => segment.includes("*"));
41
+ const wildcardSegment = segments[wildcardSegmentIndex];
42
+ if (!wildcardSegment.match(/^(?:\*|\*\?|[^*]+\*|\S+\*\?)$/)) {
43
+ throw new Error(`Invalid route definition "${definition}": wildcard must be trailing in its segment`);
44
+ }
45
+ if (wildcardSegmentIndex !== segments.length - 1) {
46
+ throw new Error(`Invalid route definition "${definition}": wildcard segment is allowed only at the end`);
47
+ }
48
+ }
49
+ Infer = null;
50
+ /** Base URL used when generating absolute URLs (`abs: true`). */
51
+ get origin() {
52
+ if (!this._origin) {
53
+ throw new Error(
54
+ "origin for route " + this.definition + ' is not set, please provide it like Route0.create(route, {origin: "https://example.com"}) in config or set via clones like routes._.clone({origin: "https://example.com"})'
55
+ );
56
+ }
57
+ return this._origin;
58
+ }
59
+ set origin(origin) {
60
+ this._origin = origin;
61
+ }
7
62
  constructor(definition, config = {}) {
8
- this.pathOriginalDefinition = definition;
9
- this.pathDefinition = Route0._getPathDefinitionByOriginalDefinition(definition);
10
- this.paramsDefinition = Route0._getParamsDefinitionByRouteDefinition(definition);
11
- this.queryDefinition = Route0._getQueryDefinitionByRouteDefinition(definition);
12
- const { baseUrl } = config;
13
- if (baseUrl && typeof baseUrl === "string" && baseUrl.length) {
14
- this.baseUrl = baseUrl;
63
+ const normalizedDefinition = Route0.normalizeSlash(definition);
64
+ Route0._validateRouteDefinition(normalizedDefinition);
65
+ this.definition = normalizedDefinition;
66
+ this.params = this.pathParamsDefinition;
67
+ const { origin } = config;
68
+ if (origin && typeof origin === "string" && origin.length) {
69
+ this._origin = origin;
15
70
  } else {
16
71
  const g = globalThis;
17
- if (g?.location?.origin) {
18
- this.baseUrl = g.location.origin;
72
+ if (typeof g?.location?.origin === "string" && g.location.origin.length > 0) {
73
+ this._origin = g.location.origin;
19
74
  } else {
20
- this.baseUrl = "https://example.com";
75
+ this._origin = void 0;
21
76
  }
22
77
  }
78
+ const callable = this.get.bind(this);
79
+ Object.setPrototypeOf(callable, this);
80
+ Object.defineProperty(callable, Symbol.toStringTag, {
81
+ value: this.definition
82
+ });
83
+ this._callable = callable;
23
84
  }
85
+ /**
86
+ * Creates a callable route instance.
87
+ *
88
+ * If an existing route/callable route is provided, it is cloned.
89
+ */
24
90
  static create(definition, config) {
91
+ if (typeof definition === "function" || typeof definition === "object") {
92
+ return definition.clone(config);
93
+ }
25
94
  const original = new Route0(
26
- definition,
95
+ Route0.normalizeSlash(definition),
27
96
  config
28
97
  );
29
- const callable = original.get.bind(original);
30
- const proxy = new Proxy(callable, {
31
- get(_target, prop, receiver) {
32
- const value = original[prop];
33
- if (typeof value === "function") {
34
- return value.bind(original);
98
+ return original._callable;
99
+ }
100
+ /**
101
+ * Normalizes a definition/route into a callable route.
102
+ *
103
+ * Unlike `create`, passing a callable route returns the same instance.
104
+ */
105
+ static from(definition) {
106
+ if (typeof definition === "function") {
107
+ return definition;
108
+ }
109
+ const original = typeof definition === "object" ? definition : new Route0(
110
+ Route0.normalizeSlash(definition)
111
+ );
112
+ return original._callable;
113
+ }
114
+ static _getAbsPath(origin, url) {
115
+ return new URL(url, origin).toString().replace(/\/$/, "");
116
+ }
117
+ search() {
118
+ return this._callable;
119
+ }
120
+ /** Extends the current route definition by appending a suffix route. */
121
+ extend(suffixDefinition) {
122
+ const definition = Route0.normalizeSlash(`${this.definitionWithoutTrailingWildcard}/${suffixDefinition}`);
123
+ return Route0.create(
124
+ definition,
125
+ {
126
+ origin: this._origin
127
+ }
128
+ );
129
+ }
130
+ // implementation
131
+ get(...args) {
132
+ const { searchInput, paramsInput, absInput, absOriginInput, hashInput } = (() => {
133
+ if (args.length === 0) {
134
+ return {
135
+ searchInput: {},
136
+ paramsInput: {},
137
+ absInput: false,
138
+ absOriginInput: void 0,
139
+ hashInput: void 0
140
+ };
141
+ }
142
+ const [input, abs] = (() => {
143
+ if (typeof args[0] === "object" && args[0] !== null) {
144
+ return [args[0], args[1]];
35
145
  }
36
- return value;
37
- },
38
- set(_target, prop, value, receiver) {
39
- ;
40
- original[prop] = value;
146
+ if (typeof args[1] === "object" && args[1] !== null) {
147
+ return [args[1], args[0]];
148
+ }
149
+ if (typeof args[0] === "boolean" || typeof args[0] === "string") {
150
+ return [{}, args[0]];
151
+ }
152
+ if (typeof args[1] === "boolean" || typeof args[1] === "string") {
153
+ return [{}, args[1]];
154
+ }
155
+ return [{}, void 0];
156
+ })();
157
+ let searchInput2 = {};
158
+ let hashInput2 = void 0;
159
+ const paramsInput2 = {};
160
+ for (const [key, value] of Object.entries(input)) {
161
+ if (key === "?" && typeof value === "object" && value !== null) {
162
+ searchInput2 = value;
163
+ } else if (key === "#" && (typeof value === "string" || typeof value === "number")) {
164
+ hashInput2 = String(value);
165
+ } else if (key in this.params && (typeof value === "string" || typeof value === "number")) {
166
+ Object.assign(paramsInput2, { [key]: String(value) });
167
+ }
168
+ }
169
+ const absOriginInput2 = typeof abs === "string" && abs.length > 0 ? abs : void 0;
170
+ return {
171
+ searchInput: searchInput2,
172
+ paramsInput: paramsInput2,
173
+ absInput: absOriginInput2 !== void 0 || abs === true,
174
+ absOriginInput: absOriginInput2,
175
+ hashInput: hashInput2
176
+ };
177
+ })();
178
+ let url = this.definition;
179
+ url = url.replace(/\/:([A-Za-z0-9_]+)\?/g, (_m, k) => {
180
+ const value = paramsInput[k];
181
+ if (value === void 0) return "";
182
+ return `/${encodeURIComponent(String(value))}`;
183
+ });
184
+ url = url.replace(/:([A-Za-z0-9_]+)(?!\?)/g, (_m, k) => encodeURIComponent(String(paramsInput?.[k] ?? "undefined")));
185
+ url = url.replace(/\/\*\?/g, () => {
186
+ const value = paramsInput["*"];
187
+ if (value === void 0) return "";
188
+ const stringValue = String(value);
189
+ return stringValue.startsWith("/") ? stringValue : `/${stringValue}`;
190
+ });
191
+ url = url.replace(/\/\*/g, () => {
192
+ const value = String(paramsInput["*"] ?? "");
193
+ return value.startsWith("/") ? value : `/${value}`;
194
+ });
195
+ url = url.replace(/\*\?/g, () => String(paramsInput["*"] ?? ""));
196
+ url = url.replace(/\*/g, () => String(paramsInput["*"] ?? ""));
197
+ const searchString = stringifySearchQuery(searchInput, { arrayIndexes: false });
198
+ url = [url, searchString].filter(Boolean).join("?");
199
+ url = collapseDuplicateSlashes(url);
200
+ url = absInput ? Route0._getAbsPath(absOriginInput || this.origin, url) : url;
201
+ if (hashInput !== void 0) {
202
+ url = `${url}#${hashInput}`;
203
+ }
204
+ return url;
205
+ }
206
+ /** Returns path param keys extracted from route definition. */
207
+ getParamsKeys() {
208
+ return Object.keys(this.params);
209
+ }
210
+ getTokens() {
211
+ return this.routeTokens.map((token) => ({ ...token }));
212
+ }
213
+ /** Clones route with optional config override. */
214
+ clone(config) {
215
+ return Route0.create(this.definition, config);
216
+ }
217
+ get regexBaseString() {
218
+ if (this._regexBaseString === void 0) {
219
+ if (this.definition === "/") {
220
+ this._regexBaseString = "/";
221
+ } else {
222
+ this._regexBaseString = this.routeRegexBaseStringRaw.replace(/\/+$/, "") + "/?";
223
+ }
224
+ }
225
+ return this._regexBaseString;
226
+ }
227
+ get regexString() {
228
+ if (this._regexString === void 0) {
229
+ this._regexString = `^${this.regexBaseString}$`;
230
+ }
231
+ return this._regexString;
232
+ }
233
+ get regex() {
234
+ if (this._regex === void 0) {
235
+ this._regex = new RegExp(this.regexString);
236
+ }
237
+ return this._regex;
238
+ }
239
+ get regexAncestor() {
240
+ if (this._regexAncestor === void 0) {
241
+ if (this.definition === "/") {
242
+ this._regexAncestor = /^\/.+$/;
243
+ } else {
244
+ this._regexAncestor = new RegExp(`^${this.regexBaseString}(?:/.*)?$`);
245
+ }
246
+ }
247
+ return this._regexAncestor;
248
+ }
249
+ get regexDescendantMatchers() {
250
+ if (this._regexDescendantMatchers === void 0) {
251
+ const matchers = [];
252
+ if (this.definitionParts[0] !== "/") {
253
+ let pattern = "";
254
+ const captureKeys = [];
255
+ for (const part of this.definitionParts) {
256
+ if (part.startsWith(":")) {
257
+ pattern += "/([^/]+)";
258
+ captureKeys.push(part.replace(/^:/, "").replace(/\?$/, ""));
259
+ } else if (part.includes("*")) {
260
+ const prefix = part.replace(/\*\??$/, "");
261
+ pattern += `/${escapeRegex(prefix)}[^/]*`;
262
+ captureKeys.push("*");
263
+ } else {
264
+ pattern += `/${escapeRegex(part)}`;
265
+ }
266
+ matchers.push({
267
+ regex: new RegExp(`^${pattern}/?$`),
268
+ captureKeys: [...captureKeys]
269
+ });
270
+ }
271
+ }
272
+ this._regexDescendantMatchers = matchers;
273
+ }
274
+ return this._regexDescendantMatchers;
275
+ }
276
+ get captureKeys() {
277
+ if (this._captureKeys === void 0) {
278
+ this._captureKeys = this.routeTokens.filter((token) => token.kind !== "static").map((token) => token.kind === "param" ? token.name : "*");
279
+ }
280
+ return this._captureKeys;
281
+ }
282
+ get routeSegments() {
283
+ if (this._routeSegments === void 0) {
284
+ this._routeSegments = Route0._getRouteSegments(this.definition);
285
+ }
286
+ return this._routeSegments;
287
+ }
288
+ get routeTokens() {
289
+ if (this._routeTokens === void 0) {
290
+ this._routeTokens = this.routeSegments.map((segment) => {
291
+ const param = segment.match(/^:([A-Za-z0-9_]+)(\?)?$/);
292
+ if (param) {
293
+ return { kind: "param", name: param[1], optional: param[2] === "?" };
294
+ }
295
+ if (segment === "*" || segment === "*?") {
296
+ return { kind: "wildcard", prefix: "", optional: segment.endsWith("?") };
297
+ }
298
+ const wildcard = segment.match(/^(.*)\*(\?)?$/);
299
+ if (wildcard && !segment.includes("\\*")) {
300
+ return { kind: "wildcard", prefix: wildcard[1], optional: wildcard[2] === "?" };
301
+ }
302
+ return { kind: "static", value: segment };
303
+ });
304
+ }
305
+ return this._routeTokens;
306
+ }
307
+ get routePatternCandidates() {
308
+ if (this._routePatternCandidates === void 0) {
309
+ const values = (token) => {
310
+ if (token.kind === "static") return [token.value];
311
+ if (token.kind === "param") return token.optional ? ["", "x", "y"] : ["x", "y"];
312
+ if (token.prefix.length > 0)
313
+ return [token.prefix, `${token.prefix}-x`, `${token.prefix}/x`, `${token.prefix}/y/z`];
314
+ return ["", "x", "y", "x/y"];
315
+ };
316
+ let acc = [""];
317
+ for (const token of this.routeTokens) {
318
+ const next = [];
319
+ for (const base of acc) {
320
+ for (const value of values(token)) {
321
+ if (value === "") {
322
+ next.push(base);
323
+ } else if (value.startsWith("/")) {
324
+ next.push(`${base}${value}`);
325
+ } else {
326
+ next.push(`${base}/${value}`);
327
+ }
328
+ }
329
+ }
330
+ acc = next.length > 512 ? next.slice(0, 512) : next;
331
+ }
332
+ this._routePatternCandidates = acc.length === 0 ? ["/"] : Array.from(new Set(acc.map((x) => x === "" ? "/" : collapseDuplicateSlashes(x))));
333
+ }
334
+ return this._routePatternCandidates;
335
+ }
336
+ get pathParamsDefinition() {
337
+ if (this._pathParamsDefinition === void 0) {
338
+ const entries = this.routeTokens.filter((t) => t.kind !== "static").map((t) => t.kind === "param" ? [t.name, !t.optional] : ["*", !t.optional]);
339
+ this._pathParamsDefinition = Object.fromEntries(entries);
340
+ }
341
+ return this._pathParamsDefinition;
342
+ }
343
+ get definitionWithoutTrailingWildcard() {
344
+ if (this._definitionWithoutTrailingWildcard === void 0) {
345
+ this._definitionWithoutTrailingWildcard = this.definition.replace(/\*\??$/, "");
346
+ }
347
+ return this._definitionWithoutTrailingWildcard;
348
+ }
349
+ get routeRegexBaseStringRaw() {
350
+ if (this._routeRegexBaseStringRaw === void 0) {
351
+ if (this.routeTokens.length === 0) {
352
+ this._routeRegexBaseStringRaw = "";
353
+ } else {
354
+ let pattern = "";
355
+ for (const token of this.routeTokens) {
356
+ if (token.kind === "static") {
357
+ pattern += `/${escapeRegex(token.value)}`;
358
+ continue;
359
+ }
360
+ if (token.kind === "param") {
361
+ pattern += token.optional ? "(?:/([^/]+))?" : "/([^/]+)";
362
+ continue;
363
+ }
364
+ if (token.prefix.length > 0) {
365
+ pattern += `/${escapeRegex(token.prefix)}(.*)`;
366
+ } else {
367
+ pattern += "(?:/(.*))?";
368
+ }
369
+ }
370
+ this._routeRegexBaseStringRaw = pattern;
371
+ }
372
+ }
373
+ return this._routeRegexBaseStringRaw;
374
+ }
375
+ get normalizedDefinition() {
376
+ if (this._normalizedDefinition === void 0) {
377
+ this._normalizedDefinition = this.definition.length > 1 && this.definition.endsWith("/") ? this.definition.slice(0, -1) : this.definition;
378
+ }
379
+ return this._normalizedDefinition;
380
+ }
381
+ get definitionParts() {
382
+ if (this._definitionParts === void 0) {
383
+ this._definitionParts = this.normalizedDefinition === "/" ? ["/"] : this.normalizedDefinition.split("/").filter(Boolean);
384
+ }
385
+ return this._definitionParts;
386
+ }
387
+ /** Fast pathname exact match check without building a full relation object. */
388
+ isExact(pathname, normalize = true) {
389
+ const normalizedPathname = normalize ? Route0.normalizeSlash(pathname) : pathname;
390
+ return this.regex.test(normalizedPathname);
391
+ }
392
+ /** Fast pathname exact or ancestor match check without building a full relation object. */
393
+ isExactOrAncestor(pathname, normalize = true) {
394
+ const normalizedPathname = normalize ? Route0.normalizeSlash(pathname) : pathname;
395
+ return this.regex.test(normalizedPathname) || this.regexAncestor.test(normalizedPathname);
396
+ }
397
+ /** True when route is ancestor of pathname (pathname is deeper). */
398
+ isAncestor(pathname, normalize = true) {
399
+ const normalizedPathname = normalize ? Route0.normalizeSlash(pathname) : pathname;
400
+ return !this.regex.test(normalizedPathname) && this.regexAncestor.test(normalizedPathname);
401
+ }
402
+ /** True when route is descendant of pathname (pathname is shallower). */
403
+ isDescendant(pathname, normalize = true) {
404
+ const normalizedPathname = normalize ? Route0.normalizeSlash(pathname) : pathname;
405
+ if (this.regex.test(normalizedPathname) || this.regexAncestor.test(normalizedPathname)) {
406
+ return false;
407
+ }
408
+ for (const matcher of this.regexDescendantMatchers) {
409
+ if (normalizedPathname.match(matcher.regex)) {
41
410
  return true;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+ /** Creates a grouped regex pattern string from many routes. */
416
+ static getRegexStringGroup(routes) {
417
+ const patterns = routes.map((route) => `(?:${route.regexBaseString})`).join("|");
418
+ return `^(?:${patterns})$`;
419
+ }
420
+ /** Creates a grouped regex from many routes. */
421
+ static getRegexGroup(routes) {
422
+ return new RegExp(Route0.getRegexStringGroup(routes));
423
+ }
424
+ /** Converts any location shape to relative form (removes host/origin fields). */
425
+ static toRelLocation(location) {
426
+ return {
427
+ ...location,
428
+ abs: false,
429
+ origin: void 0,
430
+ href: void 0,
431
+ port: void 0,
432
+ host: void 0,
433
+ hostname: void 0
434
+ };
435
+ }
436
+ /** Converts a location to absolute form using provided origin URL. */
437
+ static toAbsLocation(location, origin) {
438
+ const relLoc = Route0.toRelLocation(location);
439
+ const url = new URL(relLoc.hrefRel, origin);
440
+ return {
441
+ ...location,
442
+ abs: true,
443
+ origin: url.origin,
444
+ href: url.href,
445
+ port: url.port,
446
+ host: url.host,
447
+ hostname: url.hostname
448
+ };
449
+ }
450
+ static getLocation(hrefOrHrefRelOrLocation) {
451
+ if (hrefOrHrefRelOrLocation instanceof URL) {
452
+ return Route0.getLocation(hrefOrHrefRelOrLocation.href);
453
+ }
454
+ if (typeof hrefOrHrefRelOrLocation !== "string") {
455
+ hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
456
+ }
457
+ const abs = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(hrefOrHrefRelOrLocation);
458
+ const base = abs ? void 0 : "http://example.com";
459
+ const url = new URL(hrefOrHrefRelOrLocation, base);
460
+ const hrefRel = url.pathname + url.search + url.hash;
461
+ let _search;
462
+ const location = {
463
+ pathname: url.pathname,
464
+ get search() {
465
+ if (_search === void 0) {
466
+ _search = parseSearchQuery(url.search);
467
+ }
468
+ return _search;
42
469
  },
43
- has(_target, prop) {
44
- return prop in original;
470
+ searchString: url.search,
471
+ hash: url.hash,
472
+ origin: abs ? url.origin : void 0,
473
+ href: abs ? url.href : void 0,
474
+ hrefRel,
475
+ abs,
476
+ // extra host-related fields (available even for relative with dummy base)
477
+ host: abs ? url.host : void 0,
478
+ hostname: abs ? url.hostname : void 0,
479
+ port: abs ? url.port || void 0 : void 0,
480
+ // specific to UnknownLocation
481
+ params: void 0,
482
+ route: void 0
483
+ };
484
+ return location;
485
+ }
486
+ getRelation(hrefOrHrefRelOrLocation) {
487
+ if (hrefOrHrefRelOrLocation instanceof URL) {
488
+ return this.getRelation(hrefOrHrefRelOrLocation.href);
489
+ }
490
+ if (typeof hrefOrHrefRelOrLocation !== "string") {
491
+ hrefOrHrefRelOrLocation = hrefOrHrefRelOrLocation.href || hrefOrHrefRelOrLocation.hrefRel;
492
+ }
493
+ const pathname = Route0.normalizeSlash(new URL(hrefOrHrefRelOrLocation, "http://example.com").pathname);
494
+ const paramNames = this.captureKeys;
495
+ const exactRe = this.regex;
496
+ const exactMatch = pathname.match(exactRe);
497
+ if (exactMatch) {
498
+ const values = exactMatch.slice(1, 1 + paramNames.length);
499
+ const params = Object.fromEntries(
500
+ paramNames.map((n, i) => {
501
+ const value = values[i];
502
+ return [n, value === void 0 ? void 0 : decodeURIComponent(value)];
503
+ })
504
+ );
505
+ return {
506
+ type: "exact",
507
+ route: this.definition,
508
+ params,
509
+ exact: true,
510
+ ancestor: false,
511
+ descendant: false,
512
+ unmatched: false
513
+ };
514
+ }
515
+ const ancestorRe = this.regexAncestor;
516
+ const ancestorMatch = pathname.match(ancestorRe);
517
+ if (ancestorMatch) {
518
+ const values = ancestorMatch.slice(1, 1 + paramNames.length);
519
+ const params = Object.fromEntries(
520
+ paramNames.map((n, i) => {
521
+ const value = values[i];
522
+ return [n, value === void 0 ? void 0 : decodeURIComponent(value)];
523
+ })
524
+ );
525
+ return {
526
+ type: "ancestor",
527
+ route: this.definition,
528
+ params,
529
+ exact: false,
530
+ ancestor: true,
531
+ descendant: false,
532
+ unmatched: false
533
+ };
534
+ }
535
+ let descendantMatch = null;
536
+ let descendantCaptureKeys = [];
537
+ for (const matcher of this.regexDescendantMatchers) {
538
+ const match = pathname.match(matcher.regex);
539
+ if (!match) continue;
540
+ descendantMatch = match;
541
+ descendantCaptureKeys = matcher.captureKeys;
542
+ break;
543
+ }
544
+ if (descendantMatch) {
545
+ const values = descendantMatch.slice(1, 1 + descendantCaptureKeys.length);
546
+ const params = Object.fromEntries(
547
+ descendantCaptureKeys.map((key, index) => [key, decodeURIComponent(values[index])])
548
+ );
549
+ return {
550
+ type: "descendant",
551
+ route: this.definition,
552
+ params,
553
+ exact: false,
554
+ ancestor: false,
555
+ descendant: true,
556
+ unmatched: false
557
+ };
558
+ }
559
+ return {
560
+ type: "unmatched",
561
+ route: this.definition,
562
+ params: {},
563
+ exact: false,
564
+ ancestor: false,
565
+ descendant: false,
566
+ unmatched: true
567
+ };
568
+ }
569
+ _validateParamsInput(input) {
570
+ const paramsEntries = Object.entries(this.params);
571
+ const paramsMap = this.params;
572
+ const requiredParamsKeys = paramsEntries.filter(([, required]) => required).map(([k]) => k);
573
+ const paramsKeys = paramsEntries.map(([k]) => k);
574
+ if (input === void 0) {
575
+ if (requiredParamsKeys.length) {
576
+ return {
577
+ issues: [
578
+ {
579
+ message: `Missing params: ${requiredParamsKeys.map((k) => `"${k}"`).join(", ")}`
580
+ }
581
+ ]
582
+ };
45
583
  }
46
- });
47
- Object.setPrototypeOf(proxy, Route0.prototype);
48
- return proxy;
584
+ input = {};
585
+ }
586
+ if (typeof input !== "object" || input === null) {
587
+ return {
588
+ issues: [{ message: "Invalid route params: expected object" }]
589
+ };
590
+ }
591
+ const inputObj = input;
592
+ const inputKeys = Object.keys(inputObj);
593
+ const notDefinedKeys = requiredParamsKeys.filter((k) => !inputKeys.includes(k));
594
+ if (notDefinedKeys.length) {
595
+ return {
596
+ issues: [
597
+ {
598
+ message: `Missing params: ${notDefinedKeys.map((k) => `"${k}"`).join(", ")}`
599
+ }
600
+ ]
601
+ };
602
+ }
603
+ const data = {};
604
+ for (const k of paramsKeys) {
605
+ const v = inputObj[k];
606
+ const required = paramsMap[k];
607
+ if (v === void 0 && !required) {
608
+ data[k] = void 0;
609
+ } else if (typeof v === "string") {
610
+ data[k] = v;
611
+ } else if (typeof v === "number") {
612
+ data[k] = String(v);
613
+ } else {
614
+ return {
615
+ issues: [{ message: `Invalid route params: expected string, number, got ${typeof v} for "${k}"` }]
616
+ };
617
+ }
618
+ }
619
+ return {
620
+ value: data
621
+ };
49
622
  }
50
- static _splitPathDefinitionAndQueryTailDefinition(pathOriginalDefinition) {
51
- const i = pathOriginalDefinition.indexOf("&");
52
- if (i === -1) return { pathDefinition: pathOriginalDefinition, queryTailDefinition: "" };
623
+ _safeParseSchemaResult(result) {
624
+ if ("issues" in result) {
625
+ return {
626
+ success: false,
627
+ data: void 0,
628
+ error: new Error(result.issues?.[0]?.message ?? "Invalid input")
629
+ };
630
+ }
53
631
  return {
54
- pathDefinition: pathOriginalDefinition.slice(0, i),
55
- queryTailDefinition: pathOriginalDefinition.slice(i)
632
+ success: true,
633
+ data: result.value,
634
+ error: void 0
56
635
  };
57
636
  }
58
- static _getAbsPath(baseUrl, pathWithQuery) {
59
- return new URL(pathWithQuery, baseUrl).toString().replace(/\/$/, "");
637
+ _parseSchemaResult(result) {
638
+ const safeResult = this._safeParseSchemaResult(result);
639
+ if (safeResult.error) {
640
+ throw safeResult.error;
641
+ }
642
+ return safeResult.data;
60
643
  }
61
- static _getPathDefinitionByOriginalDefinition(pathOriginalDefinition) {
62
- const { pathDefinition } = Route0._splitPathDefinitionAndQueryTailDefinition(pathOriginalDefinition);
63
- return pathDefinition;
644
+ /** Standard Schema for route params input. */
645
+ schema = {
646
+ "~standard": {
647
+ version: 1,
648
+ vendor: "route0",
649
+ validate: (value) => this._validateParamsInput(value),
650
+ types: void 0
651
+ },
652
+ parse: (value) => this._parseSchemaResult(this._validateParamsInput(value)),
653
+ safeParse: (value) => this._safeParseSchemaResult(this._validateParamsInput(value))
654
+ };
655
+ // /** True when path structure is equal (param names are ignored). */
656
+ // isSame(other: AnyRoute): boolean {
657
+ // const thisShape = this.routeTokens
658
+ // .map((t) => {
659
+ // if (t.kind === 'static') return `s:${t.value}`
660
+ // if (t.kind === 'param') return `p:${t.optional ? 'o' : 'r'}`
661
+ // return `w:${t.prefix}:${t.optional ? 'o' : 'r'}`
662
+ // })
663
+ // .join('/')
664
+ // const otherRoute = Route0.from(other) as Route0<string, UnknownSearchInput>
665
+ // const otherShape = otherRoute.routeTokens
666
+ // .map((t) => {
667
+ // if (t.kind === 'static') return `s:${t.value}`
668
+ // if (t.kind === 'param') return `p:${t.optional ? 'o' : 'r'}`
669
+ // return `w:${t.prefix}:${t.optional ? 'o' : 'r'}`
670
+ // })
671
+ // .join('/')
672
+ // return thisShape === otherShape
673
+ // }
674
+ // /** Static convenience wrapper for `isSame`. */
675
+ // static isSame(a: AnyRoute | string | undefined, b: AnyRoute | string | undefined): boolean {
676
+ // if (!a) {
677
+ // if (!b) return true
678
+ // return false
679
+ // }
680
+ // if (!b) {
681
+ // return false
682
+ // }
683
+ // return Route0.create(a).isSame(Route0.create(b))
684
+ // }
685
+ // /** True when current route is more specific/deeper than `other`. */
686
+ // isDescendant(other: AnyRoute | string | undefined): boolean {
687
+ // if (!other) return false
688
+ // other = Route0.create(other)
689
+ // // this is a descendant of other if:
690
+ // // - paths are not exactly the same
691
+ // // - other's path is a prefix of this path, matching params as wildcards
692
+ // const getParts = (path: string) => (path === '/' ? ['/'] : path.split('/').filter(Boolean))
693
+ // // Root is ancestor of any non-root; thus any non-root is a descendant of root
694
+ // if (other.definition === '/' && this.definition !== '/') {
695
+ // return true
696
+ // }
697
+ // const thisParts = getParts(this.definition)
698
+ // const otherParts = getParts(other.definition)
699
+ // // A descendant must be deeper
700
+ // if (thisParts.length <= otherParts.length) return false
701
+ // const matchesPatternPart = (patternPart: string, valuePart: string): { match: boolean; wildcard: boolean } => {
702
+ // if (patternPart.startsWith(':')) return { match: true, wildcard: false }
703
+ // const wildcardIndex = patternPart.indexOf('*')
704
+ // if (wildcardIndex >= 0) {
705
+ // const prefix = patternPart.slice(0, wildcardIndex)
706
+ // return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true }
707
+ // }
708
+ // return { match: patternPart === valuePart, wildcard: false }
709
+ // }
710
+ // for (let i = 0; i < otherParts.length; i++) {
711
+ // const otherPart = otherParts[i]
712
+ // const thisPart = thisParts[i]
713
+ // const result = matchesPatternPart(otherPart, thisPart)
714
+ // if (!result.match) return false
715
+ // if (result.wildcard) return true
716
+ // }
717
+ // // Not equal (depth already ensures not equal)
718
+ // return true
719
+ // }
720
+ // /** True when current route is broader/shallower than `other`. */
721
+ // isAncestor(other: AnyRoute | string | undefined): boolean {
722
+ // if (!other) return false
723
+ // other = Route0.create(other)
724
+ // // this is an ancestor of other if:
725
+ // // - paths are not exactly the same
726
+ // // - this path is a prefix of other path, matching params as wildcards
727
+ // const getParts = (path: string) => (path === '/' ? ['/'] : path.split('/').filter(Boolean))
728
+ // // Root is ancestor of any non-root path
729
+ // if (this.definition === '/' && other.definition !== '/') {
730
+ // return true
731
+ // }
732
+ // const thisParts = getParts(this.definition)
733
+ // const otherParts = getParts(other.definition)
734
+ // // An ancestor must be shallower
735
+ // if (thisParts.length >= otherParts.length) return false
736
+ // const matchesPatternPart = (patternPart: string, valuePart: string): { match: boolean; wildcard: boolean } => {
737
+ // if (patternPart.startsWith(':')) return { match: true, wildcard: false }
738
+ // const wildcardIndex = patternPart.indexOf('*')
739
+ // if (wildcardIndex >= 0) {
740
+ // const prefix = patternPart.slice(0, wildcardIndex)
741
+ // return { match: prefix.length === 0 || valuePart.startsWith(prefix), wildcard: true }
742
+ // }
743
+ // return { match: patternPart === valuePart, wildcard: false }
744
+ // }
745
+ // for (let i = 0; i < thisParts.length; i++) {
746
+ // const thisPart = thisParts[i]
747
+ // const otherPart = otherParts[i]
748
+ // const result = matchesPatternPart(thisPart, otherPart)
749
+ // if (!result.match) return false
750
+ // if (result.wildcard) return true
751
+ // }
752
+ // // Not equal (depth already ensures not equal)
753
+ // return true
754
+ // }
755
+ /** True when two route patterns can match the same concrete URL. */
756
+ isOverlap(other) {
757
+ if (!other) return false;
758
+ const otherRoute = Route0.from(other);
759
+ const thisRegex = this.regex;
760
+ const otherRegex = otherRoute.regex;
761
+ const thisCandidates = this.routePatternCandidates;
762
+ const otherCandidates = otherRoute.routePatternCandidates;
763
+ if (thisCandidates.some((path) => otherRegex.test(path))) return true;
764
+ if (otherCandidates.some((path) => thisRegex.test(path))) return true;
765
+ return false;
64
766
  }
65
- static _getParamsDefinitionByRouteDefinition(pathOriginalDefinition) {
66
- const { pathDefinition } = Route0._splitPathDefinitionAndQueryTailDefinition(pathOriginalDefinition);
67
- const matches = Array.from(pathDefinition.matchAll(/:([A-Za-z0-9_]+)/g));
68
- const paramsDefinition = Object.fromEntries(matches.map((m) => [m[1], true]));
69
- return paramsDefinition;
767
+ /**
768
+ * True when overlap is not resolvable by route ordering inside one route set.
769
+ *
770
+ * Non-conflicting overlap means one route is a strict subset of another
771
+ * (e.g. `/x/y` is a strict subset of `/x/:id`) and can be safely ordered first.
772
+ */
773
+ isConflict(other) {
774
+ if (!other) return false;
775
+ const otherRoute = Route0.from(other);
776
+ if (!this.isOverlap(otherRoute)) return false;
777
+ const thisRegex = this.regex;
778
+ const otherRegex = otherRoute.regex;
779
+ const thisCandidates = this.routePatternCandidates;
780
+ const otherCandidates = otherRoute.routePatternCandidates;
781
+ const thisExclusive = thisCandidates.some((path) => thisRegex.test(path) && !otherRegex.test(path));
782
+ const otherExclusive = otherCandidates.some((path) => otherRegex.test(path) && !thisRegex.test(path));
783
+ if (thisExclusive !== otherExclusive) return false;
784
+ return true;
70
785
  }
71
- static _getQueryDefinitionByRouteDefinition(pathOriginalDefinition) {
72
- const { queryTailDefinition } = Route0._splitPathDefinitionAndQueryTailDefinition(pathOriginalDefinition);
73
- if (!queryTailDefinition) {
74
- return {};
786
+ /** Specificity comparator used for deterministic route ordering. */
787
+ isMoreSpecificThan(other) {
788
+ if (!other) return false;
789
+ other = Route0.create(other);
790
+ const getParts = (path) => {
791
+ if (path === "/") return ["/"];
792
+ return path.split("/").filter(Boolean);
793
+ };
794
+ const rank = (part) => {
795
+ if (part.includes("*")) return -1;
796
+ if (part.startsWith(":") && part.endsWith("?")) return 0;
797
+ if (part.startsWith(":")) return 1;
798
+ return 2;
799
+ };
800
+ const thisParts = getParts(this.definition);
801
+ const otherParts = getParts(other.definition);
802
+ for (let i = 0; i < Math.min(thisParts.length, otherParts.length); i++) {
803
+ const thisRank = rank(thisParts[i]);
804
+ const otherRank = rank(otherParts[i]);
805
+ if (thisRank > otherRank) return true;
806
+ if (thisRank < otherRank) return false;
807
+ }
808
+ return this.definition < other.definition;
809
+ }
810
+ }
811
+ class Routes {
812
+ _routes;
813
+ _pathsOrdering;
814
+ _keysOrdering;
815
+ _ordered;
816
+ _;
817
+ constructor({
818
+ routes,
819
+ isHydrated = false,
820
+ pathsOrdering,
821
+ keysOrdering,
822
+ ordered
823
+ }) {
824
+ this._routes = isHydrated ? routes : Routes.hydrate(routes);
825
+ if (!pathsOrdering || !keysOrdering || !ordered) {
826
+ const ordering = Routes.makeOrdering(this._routes);
827
+ this._pathsOrdering = ordering.pathsOrdering;
828
+ this._keysOrdering = ordering.keysOrdering;
829
+ this._ordered = this._keysOrdering.map((key) => this._routes[key]);
830
+ } else {
831
+ this._pathsOrdering = pathsOrdering;
832
+ this._keysOrdering = keysOrdering;
833
+ this._ordered = ordered;
75
834
  }
76
- const keys = queryTailDefinition.split("&").map(Boolean);
77
- const queryDefinition = Object.fromEntries(keys.map((k) => [k, true]));
78
- return queryDefinition;
835
+ this._ = {
836
+ routes: this._routes,
837
+ getLocation: this._getLocation.bind(this),
838
+ clone: this._clone.bind(this),
839
+ pathsOrdering: this._pathsOrdering,
840
+ keysOrdering: this._keysOrdering,
841
+ ordered: this._ordered
842
+ };
843
+ }
844
+ /** Creates and hydrates a typed routes collection. */
845
+ static create(routes, override) {
846
+ const result = Routes.prettify(new Routes({ routes }));
847
+ if (!override) {
848
+ return result;
849
+ }
850
+ return result._.clone(override);
851
+ }
852
+ static prettify(instance) {
853
+ Object.setPrototypeOf(instance, Routes.prototype);
854
+ Object.defineProperty(instance, Symbol.toStringTag, {
855
+ value: "Routes"
856
+ });
857
+ Object.assign(instance, {
858
+ clone: instance._clone.bind(instance)
859
+ });
860
+ Object.assign(instance, instance._routes);
861
+ return instance;
79
862
  }
80
- static overrideMany(routes, config) {
863
+ static hydrate(routes) {
81
864
  const result = {};
82
- for (const [key, value] of Object.entries(routes)) {
83
- ;
84
- result[key] = value.clone(config);
865
+ for (const key in routes) {
866
+ if (Object.hasOwn(routes, key)) {
867
+ const value = routes[key];
868
+ result[key] = typeof value === "string" ? Route0.create(value) : value;
869
+ }
85
870
  }
86
871
  return result;
87
872
  }
88
- extend(suffixDefinition) {
89
- const { pathDefinition: parentPathDefinition } = Route0._splitPathDefinitionAndQueryTailDefinition(
90
- this.pathOriginalDefinition
91
- );
92
- const { pathDefinition: suffixPathDefinition, queryTailDefinition: suffixQueryTailDefinition } = Route0._splitPathDefinitionAndQueryTailDefinition(suffixDefinition);
93
- const pathDefinition = `${parentPathDefinition}/${suffixPathDefinition}`.replace(/\/{2,}/g, "/");
94
- const pathOriginalDefinition = `${pathDefinition}${suffixQueryTailDefinition}`;
95
- return Route0.create(pathOriginalDefinition, { baseUrl: this.baseUrl });
873
+ _getLocation(hrefOrHrefRelOrLocation) {
874
+ const input = hrefOrHrefRelOrLocation;
875
+ const location = Route0.getLocation(input);
876
+ for (const route of this._ordered) {
877
+ if (route.isExact(location.pathname, false)) {
878
+ const relation = route.getRelation(input);
879
+ return Object.assign(location, {
880
+ route: route.definition,
881
+ params: relation.params
882
+ });
883
+ }
884
+ }
885
+ return location;
96
886
  }
97
- // implementation
98
- get(...args) {
99
- const { queryInput, paramsInput, absInput } = (() => {
100
- if (args.length === 0) {
101
- return { queryInput: {}, paramsInput: {}, absInput: false };
887
+ static makeOrdering(routes) {
888
+ const hydrated = Routes.hydrate(routes);
889
+ const entries = Object.entries(hydrated);
890
+ const getParts = (path) => {
891
+ if (path === "/") return ["/"];
892
+ return path.split("/").filter(Boolean);
893
+ };
894
+ entries.sort(([_keyA, routeA], [_keyB, routeB]) => {
895
+ const partsA = getParts(routeA.definition);
896
+ const partsB = getParts(routeB.definition);
897
+ if (routeA.isOverlap(routeB)) {
898
+ if (routeA.isMoreSpecificThan(routeB)) return -1;
899
+ if (routeB.isMoreSpecificThan(routeA)) return 1;
102
900
  }
103
- const input = args[0];
104
- if (typeof input !== "object" || input === null) {
105
- return { queryInput: {}, paramsInput: {}, absInput: false };
901
+ if (partsA.length !== partsB.length) {
902
+ return partsA.length - partsB.length;
106
903
  }
107
- const { query, abs, ...params } = input;
108
- return { queryInput: query || {}, paramsInput: params, absInput: abs ?? false };
109
- })();
110
- const neededParamsKeys = Object.keys(this.paramsDefinition);
111
- const providedParamsKeys = Object.keys(paramsInput);
112
- const notProvidedKeys = neededParamsKeys.filter((k) => !providedParamsKeys.includes(k));
113
- if (notProvidedKeys.length) {
114
- Object.assign(paramsInput, Object.fromEntries(notProvidedKeys.map((k) => [k, "undefined"])));
115
- }
116
- let url = String(this.pathDefinition);
117
- url = url.replace(/:([A-Za-z0-9_]+)/g, (_m, k) => encodeURIComponent(String(paramsInput?.[k] ?? "")));
118
- const queryInputStringified = Object.fromEntries(Object.entries(queryInput).map(([k, v]) => [k, String(v)]));
119
- url = [url, new URLSearchParams(queryInputStringified).toString()].filter(Boolean).join("?");
120
- url = url.replace(/\/{2,}/g, "/");
121
- url = absInput ? Route0._getAbsPath(this.baseUrl, url) : url;
122
- return url;
123
- }
124
- getDefinition() {
125
- return this.pathDefinition;
904
+ return routeA.definition.localeCompare(routeB.definition);
905
+ });
906
+ const pathsOrdering = entries.map(([_key, route]) => route.definition);
907
+ const keysOrdering = entries.map(([_key]) => _key);
908
+ return { pathsOrdering, keysOrdering };
126
909
  }
127
- clone(config) {
128
- return new Route0(this.pathOriginalDefinition, config);
910
+ /** Returns a cloned routes collection with config applied to each route. */
911
+ _clone(config) {
912
+ const newRoutes = {};
913
+ for (const key in this._routes) {
914
+ if (Object.hasOwn(this._routes, key)) {
915
+ newRoutes[key] = this._routes[key].clone(config);
916
+ }
917
+ }
918
+ const instance = new Routes({
919
+ routes: newRoutes,
920
+ isHydrated: true,
921
+ pathsOrdering: this._pathsOrdering,
922
+ keysOrdering: this._keysOrdering,
923
+ ordered: this._keysOrdering.map((key) => newRoutes[key])
924
+ });
925
+ return Routes.prettify(instance);
129
926
  }
927
+ static _ = {
928
+ prettify: Routes.prettify.bind(Routes),
929
+ hydrate: Routes.hydrate.bind(Routes),
930
+ makeOrdering: Routes.makeOrdering.bind(Routes)
931
+ };
130
932
  }
131
933
  export {
132
- Route0
934
+ Route0,
935
+ Routes
133
936
  };
134
937
  //# sourceMappingURL=index.js.map