@xmachines/play-pattern 5.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.
@@ -0,0 +1,716 @@
1
+ import { createPatternCache } from "./cache.js";
2
+ /**
3
+ * Computes the key of the bucket index of a concrete PATH.
4
+ *
5
+ * The key is the first path segment, for example `"settings"` of `"/settings/billing"`.
6
+ * The root path `"/"` gives the key `"/"`, and a path whose first segment is EMPTY —
7
+ * `"//docs"` — gives the key `""`. The two are different keys, so a pattern that matches
8
+ * both asks {@link ParsedPattern.indexKey} for the wildcard key instead.
9
+ *
10
+ * {@link ParsedPattern.indexKey} computes the key of a PATTERN, and it calls this
11
+ * function for every pattern whose first segment is a literal. One function therefore
12
+ * answers both sides, and a pattern in the wrong bucket cannot happen.
13
+ *
14
+ * @param path - The string of a URL path.
15
+ */
16
+ export function getIndexKey(path) {
17
+ const trimmed = path.startsWith("/") ? path.slice(1) : path;
18
+ if (trimmed.length === 0)
19
+ return "/";
20
+ // The FIRST separator ends the segment. A split of the whole path allocated one string
21
+ // for each segment, and this function runs on every lookup of `RouteMap`.
22
+ const end = trimmed.indexOf("/");
23
+ return end === -1 ? trimmed : trimmed.slice(0, end);
24
+ }
25
+ /** The characters that start a param name. */
26
+ const NAME_START_RE = /[A-Za-z_$]/;
27
+ /**
28
+ * The characters that continue a param name.
29
+ *
30
+ * The hyphen is the divergence from URLPattern that the module comment states. It is
31
+ * here, and in {@link ParsedPattern.normalized}, and nowhere else.
32
+ */
33
+ const NAME_PART_RE = /[A-Za-z0-9_$-]/;
34
+ /** The modifiers, as the parser meets them after a part. */
35
+ const MODIFIERS = new Set(["?", "+", "*"]);
36
+ /**
37
+ * The character that a param absorbs from the literal before it.
38
+ *
39
+ * URLPattern calls it the prefix code point, and for a pathname it is `/`. The parser
40
+ * moves it INTO the param, so that a `?` or a `*` modifier removes the separator
41
+ * together with the value. That is the whole reason `/settings/:section?` matches
42
+ * `/settings` and not `/settings/`.
43
+ */
44
+ const PREFIX = "/";
45
+ /**
46
+ * The parse of the patterns that this module read last, kept at the module level.
47
+ *
48
+ * A route pattern is a static string of the route map, so the answer for one pattern
49
+ * never changes and the cache pays for itself.
50
+ *
51
+ * The cache is BOUNDED even so, because two callers hand it a string that no route map
52
+ * holds. `RouterBridgeBase` resolves the escape of an actor route in
53
+ * `resolveNavigationPath` and in `isActorAtPath`, and an actor route is a concrete
54
+ * location — one for each value that an application puts in a param. Both callers reach
55
+ * the parse only for a route that carries a BACKSLASH, which no ordinary location does,
56
+ * and `holdsUnsubstitutedParam` answers the question that took the plain path of every
57
+ * navigation before.
58
+ *
59
+ * A bound removes the whole class anyway. An unbounded map grew for the life of the page,
60
+ * one entry for each location an application visited, and the eviction costs one re-parse
61
+ * of a pattern that fell out — the trade `RouteMap` already makes for its path-match
62
+ * cache.
63
+ */
64
+ const parseCache = createPatternCache(500);
65
+ /**
66
+ * Reads a route pattern, one time for each distinct pattern.
67
+ *
68
+ * The parser reports a fault never. A pattern that it cannot read reports
69
+ * `parameterized: true` and `bareForm: null`, which sends it to the URLPattern
70
+ * constructor and keeps every derived answer on the safe side.
71
+ *
72
+ * @param pattern - The route pattern, for example `/profile/:userId`.
73
+ * @returns The parse. The caller treats it as read-only: the cache holds this object.
74
+ */
75
+ export function parsePattern(pattern, options) {
76
+ const cache = options?.patternCache ?? parseCache;
77
+ const cached = cache.get(pattern);
78
+ if (cached)
79
+ return cached;
80
+ const parsed = readPattern(pattern);
81
+ cache.set(pattern, parsed);
82
+ return parsed;
83
+ }
84
+ /**
85
+ * Reads the pattern into parts, and builds the normalized form in the same pass.
86
+ *
87
+ * One pass gives both, and that is what keeps them together: the normalized form carries
88
+ * the source of every part without a change, and the rewritten name of each param. A
89
+ * second pass with an expression of its own rewrote a name that the parser reads as a
90
+ * literal, which is the defect this module exists to end.
91
+ */
92
+ function readPattern(source) {
93
+ const root = { parts: [], pending: "", escapedTail: false };
94
+ const stack = [];
95
+ let frame = root;
96
+ let normalized = "";
97
+ const declaredNames = new Map();
98
+ let malformed = false;
99
+ /**
100
+ * True when the pattern carries an escape, for example `/x\:lit`.
101
+ *
102
+ * Such a pattern is a literal path, and the parts report it as one. It must still
103
+ * reach URLPattern: the exact-match map of `RouteMap` keys on the pattern string AS
104
+ * WRITTEN, so it would hold `/x\:lit` and answer the path `/x:lit` never.
105
+ */
106
+ let escaped = false;
107
+ let index = 0;
108
+ /** Moves the pending literal characters into the frame as one part. */
109
+ function flush() {
110
+ frame.escapedTail = false;
111
+ if (frame.pending === "")
112
+ return;
113
+ frame.parts.push({ kind: "literal", text: frame.pending });
114
+ frame.pending = "";
115
+ }
116
+ /**
117
+ * Takes the `/` that a param absorbs, and flushes what stays behind it.
118
+ *
119
+ * A GROUP takes no prefix. `/a{/b}?/c` writes the separator inside the group itself,
120
+ * and `/a/{b}?/c` keeps it outside, which is why the second one matches `/a//c`.
121
+ */
122
+ function takePrefix() {
123
+ // An ESCAPED separator belongs to the literal, and no param takes it. `/x\\/:id?`
124
+ // therefore matches `/x/` and not `/x`, and the bare form has to say so.
125
+ if (frame.escapedTail || !frame.pending.endsWith(PREFIX)) {
126
+ flush();
127
+ return "";
128
+ }
129
+ frame.pending = frame.pending.slice(0, -PREFIX.length);
130
+ flush();
131
+ return PREFIX;
132
+ }
133
+ /** Reads a modifier when one follows the part that the parser just added. */
134
+ function readModifier() {
135
+ const next = source.at(index);
136
+ if (next !== undefined && MODIFIERS.has(next)) {
137
+ index += 1;
138
+ return next;
139
+ }
140
+ return "";
141
+ }
142
+ while (index < source.length) {
143
+ const char = source.at(index);
144
+ if (char === "\\") {
145
+ // The escape keeps its backslash in the normalized form, and it drops it in the
146
+ // literal text: the text is what a bare form must carry, and a path carries the
147
+ // escaped character alone.
148
+ const literal = source.at(index + 1);
149
+ if (literal === undefined) {
150
+ malformed = true;
151
+ normalized += char;
152
+ index += 1;
153
+ break;
154
+ }
155
+ escaped = true;
156
+ frame.pending += literal;
157
+ frame.escapedTail = true;
158
+ normalized += char + literal;
159
+ index += 2;
160
+ continue;
161
+ }
162
+ if (char === "{") {
163
+ flush();
164
+ stack.push(frame);
165
+ frame = { parts: [], pending: "", escapedTail: false };
166
+ normalized += char;
167
+ index += 1;
168
+ continue;
169
+ }
170
+ if (char === "}") {
171
+ const parent = stack.pop();
172
+ if (parent === undefined) {
173
+ // A close with no open. Keep it as a literal and let URLPattern report the
174
+ // fault, because the API owns the error message of an invalid pattern.
175
+ malformed = true;
176
+ frame.pending += char;
177
+ frame.escapedTail = false;
178
+ normalized += char;
179
+ index += 1;
180
+ continue;
181
+ }
182
+ flush();
183
+ const parts = frame.parts;
184
+ frame = parent;
185
+ normalized += char;
186
+ index += 1;
187
+ const modifier = readModifier();
188
+ normalized += modifier;
189
+ frame.parts.push({ kind: "group", parts, modifier });
190
+ continue;
191
+ }
192
+ if (char === ":") {
193
+ const name = readName(source, index + 1);
194
+ if (name === "") {
195
+ // A `:` that no name follows. URLPattern refuses every such pattern — `/x/:`,
196
+ // `/a:` and `/time/10:30` all fail in the constructor — so the fault belongs to
197
+ // the API. A parser that kept the character as a literal called the pattern
198
+ // static, and `RouteMap` then registered `/time/10:30` in its exact-match map,
199
+ // where the route matched the path it declares and reported no fault. Write
200
+ // `\\:` when you mean the character itself.
201
+ malformed = true;
202
+ frame.pending += char;
203
+ frame.escapedTail = false;
204
+ normalized += char;
205
+ index += 1;
206
+ continue;
207
+ }
208
+ index += 1 + name.length;
209
+ const prefix = takePrefix();
210
+ const regexp = source.at(index) === "(" ? readRegexp(source, index) : null;
211
+ if (regexp !== null)
212
+ index += regexp.length + 2;
213
+ const modifier = readModifier();
214
+ const groupName = name.replace(/-/g, "_");
215
+ if (groupName !== name)
216
+ declaredNames.set(groupName, name);
217
+ normalized += `:${groupName}${regexp === null ? "" : `(${regexp})`}${modifier}`;
218
+ frame.parts.push({ kind: "param", name, groupName, regexp, prefix, modifier });
219
+ continue;
220
+ }
221
+ if (char === "(") {
222
+ const regexp = readRegexp(source, index);
223
+ if (regexp === null) {
224
+ // An open with no close. The same reason as the stray `}` above.
225
+ malformed = true;
226
+ frame.pending += char;
227
+ frame.escapedTail = false;
228
+ normalized += char;
229
+ index += 1;
230
+ continue;
231
+ }
232
+ index += regexp.length + 2;
233
+ const prefix = takePrefix();
234
+ const modifier = readModifier();
235
+ normalized += `(${regexp})${modifier}`;
236
+ frame.parts.push({
237
+ kind: "param",
238
+ name: null,
239
+ groupName: null,
240
+ regexp,
241
+ prefix,
242
+ modifier,
243
+ });
244
+ continue;
245
+ }
246
+ if (char === "*") {
247
+ index += 1;
248
+ const prefix = takePrefix();
249
+ const modifier = readModifier();
250
+ normalized += `*${modifier}`;
251
+ frame.parts.push({
252
+ kind: "param",
253
+ name: null,
254
+ groupName: null,
255
+ regexp: null,
256
+ prefix,
257
+ modifier,
258
+ });
259
+ continue;
260
+ }
261
+ // A `?` or a `+` that reaches this point follows no part, and URLPattern refuses
262
+ // every such pattern — the native API and the polyfill both. A parser that kept it
263
+ // as a literal sent `/settings?` to the exact-match map of `RouteMap`, where
264
+ // `getStateIdByPath` reaches it never: the lookup cuts the path at its `?` first.
265
+ // Report the fault instead, and let the API name it.
266
+ if (char === "?" || char === "+")
267
+ malformed = true;
268
+ frame.pending += char;
269
+ frame.escapedTail = false;
270
+ normalized += char;
271
+ index += 1;
272
+ }
273
+ flush();
274
+ // An open group that no `}` closed. Its parts belong to the pattern, and the fault
275
+ // belongs to URLPattern.
276
+ while (stack.length > 0) {
277
+ malformed = true;
278
+ const parts = frame.parts;
279
+ const parent = stack.pop();
280
+ /* c8 ignore next */
281
+ if (parent === undefined)
282
+ break;
283
+ frame = parent;
284
+ flush();
285
+ frame.parts.push({ kind: "group", parts, modifier: "" });
286
+ }
287
+ const parts = root.parts;
288
+ const params = [];
289
+ collectParams(parts, false, params);
290
+ // The cache holds this object for the life of the process, and `parsePattern` hands the
291
+ // SAME arrays to every caller. The freeze makes that safe to do: a caller cannot write
292
+ // into the answer of the next one. The accessors copied an array for this reason, and
293
+ // the freeze replaces the copy.
294
+ //
295
+ // `declaredNames` carries the same promise through its TYPE alone. `Object.freeze`
296
+ // stops no `Map.prototype.set`, so a freeze of it would say nothing, and a copy for
297
+ // each caller would allocate a map that the common pattern leaves empty. Treat it as
298
+ // read-only, as {@link ParsedPattern} states for every field.
299
+ return {
300
+ source,
301
+ parts: freezeParts(parts),
302
+ params: Object.freeze(params.map((param) => Object.freeze(param))),
303
+ names: Object.freeze(params.map((param) => param.name)),
304
+ requiredNames: Object.freeze(params.filter((param) => !param.optional).map((param) => param.name)),
305
+ parameterized: malformed || escaped || parts.some(isNotLiteral),
306
+ bareForm: malformed ? null : buildBareForm(parts),
307
+ literalPath: malformed ? null : buildLiteralPath(parts),
308
+ indexKey: readIndexKey(parts),
309
+ normalized,
310
+ declaredNames,
311
+ };
312
+ }
313
+ /**
314
+ * Reads a param name from `start`, or `""` when no name stands there.
315
+ *
316
+ * Every character read of this module calls `String.at`, and not a bracket index. The
317
+ * two answer the same way for a non-negative index, and the method form says that the
318
+ * read is a CHARACTER of a string and not a property of an object — which is also what
319
+ * the object-injection rule of the security scan asks for.
320
+ */
321
+ function readName(source, start) {
322
+ const first = source.at(start);
323
+ if (first === undefined || !NAME_START_RE.test(first))
324
+ return "";
325
+ let end = start + 1;
326
+ while (end < source.length && NAME_PART_RE.test(source.at(end) ?? ""))
327
+ end += 1;
328
+ // A trailing hyphen belongs to the literal that follows, and not to the name:
329
+ // `/x/:id-` declares the param `id` and the literal `-`.
330
+ let name = source.slice(start, end);
331
+ while (name.endsWith("-"))
332
+ name = name.slice(0, -1);
333
+ return name;
334
+ }
335
+ /**
336
+ * Reads the source of a `(…)` constraint that starts at `open`, or `null` when no `)`
337
+ * closes it.
338
+ *
339
+ * The reader counts the nesting and it honours an escape, so `([a-z]{2,3})` and `(\()`
340
+ * both come back whole. A `{` inside a constraint is an ordinary character, and a parser
341
+ * that treated it as a group opened one that nothing closes.
342
+ */
343
+ function readRegexp(source, open) {
344
+ let depth = 0;
345
+ for (let cursor = open; cursor < source.length; cursor += 1) {
346
+ const char = source.at(cursor);
347
+ if (char === "\\") {
348
+ cursor += 1;
349
+ continue;
350
+ }
351
+ if (char === "(")
352
+ depth += 1;
353
+ else if (char === ")") {
354
+ depth -= 1;
355
+ if (depth === 0)
356
+ return source.slice(open + 1, cursor);
357
+ }
358
+ }
359
+ return null;
360
+ }
361
+ /** True for every part that needs a URLPattern match. */
362
+ function isNotLiteral(part) {
363
+ return part.kind !== "literal";
364
+ }
365
+ /** A modifier that lets a path carry no value for the part. */
366
+ function makesOptional(modifier) {
367
+ return modifier === "?" || modifier === "*";
368
+ }
369
+ /** Collects every NAMED param, and carries the optionality of each group into it. */
370
+ function collectParams(parts, insideOptionalGroup, into) {
371
+ for (const part of parts) {
372
+ if (part.kind === "literal")
373
+ continue;
374
+ if (part.kind === "group") {
375
+ collectParams(part.parts, insideOptionalGroup || makesOptional(part.modifier), into);
376
+ continue;
377
+ }
378
+ // An ANONYMOUS param stays out of the list. URLPattern numbers it from `0`, no
379
+ // framework router reports such a name, and `resolveFrameworkParams` answers `{}`
380
+ // for a pattern that declares no name — which is what a bare `*` route means to a
381
+ // machine. The bare form below still counts it, because `/docs/*` matches `/docs`
382
+ // never.
383
+ if (part.name === null || part.groupName === null)
384
+ continue;
385
+ into.push({
386
+ name: part.name,
387
+ groupName: part.groupName,
388
+ optional: insideOptionalGroup || makesOptional(part.modifier),
389
+ });
390
+ }
391
+ }
392
+ /**
393
+ * Freezes the parts of one frame, and the parts of every group inside it.
394
+ *
395
+ * `Object.freeze` reaches ONE level, and a group carries an array of its own. A caller
396
+ * that reads `parts[0].parts` would receive a mutable array without this walk, and a
397
+ * write into it would reach every later reader of the same cached parse.
398
+ */
399
+ function freezeParts(parts) {
400
+ for (const part of parts) {
401
+ if (part.kind === "group")
402
+ freezeParts(part.parts);
403
+ Object.freeze(part);
404
+ }
405
+ return Object.freeze(parts);
406
+ }
407
+ /**
408
+ * Builds the path that the pattern matches with every optional part absent, or `null`
409
+ * when some part needs a value.
410
+ *
411
+ * The walk drops an optional part TOGETHER with the `/` that it absorbed, which is the
412
+ * prefix rule of the module comment.
413
+ */
414
+ function buildBareForm(parts) {
415
+ let bare = "";
416
+ for (const part of parts) {
417
+ if (part.kind === "literal") {
418
+ bare += part.text;
419
+ continue;
420
+ }
421
+ if (makesOptional(part.modifier))
422
+ continue;
423
+ if (part.kind === "param")
424
+ return null;
425
+ const inner = buildBareForm(part.parts);
426
+ if (inner === null)
427
+ return null;
428
+ bare += inner;
429
+ }
430
+ return bare;
431
+ }
432
+ /** The name that an anonymous param reports when it has no value. */
433
+ const ANONYMOUS = "(anonymous)";
434
+ /**
435
+ * Builds the path that a pattern describes, with each param filled from `params`.
436
+ *
437
+ * This is the OUTBOUND half of the language, and it reads the same parse as the match.
438
+ * The walk drops an optional part that has no value TOGETHER with the `/` that the part
439
+ * absorbed, which is the prefix rule of the module comment: `/settings/:section?` builds
440
+ * `/settings`, and not `/settings/`.
441
+ *
442
+ * The function reports a fault through its return value, and it throws never. The
443
+ * `reason` field names the fault: `missing` for a param that needs a value and has none,
444
+ * and `unresolvable` for a param that carries a value no path can hold. A caller raises
445
+ * the error of its own layer — `@xmachines/play-xstate` raises `MissingRouteParamError`
446
+ * for the first one and `InvalidRouteParamError` for the second.
447
+ *
448
+ * @param pattern - The route pattern, for example `/profile/:userId`.
449
+ * @param params - The value of each param, keyed by the name that the pattern declares.
450
+ * @returns The path, or the reason that stopped the walk with the param that carries it.
451
+ *
452
+ * @example
453
+ * ```typescript
454
+ * buildPath("/profile/:userId", { userId: "alice" }); // { ok: true, path: "/profile/alice" }
455
+ * buildPath("/settings/:section?", {}); // { ok: true, path: "/settings" }
456
+ * buildPath("/tags/c\\+\\+", {}); // { ok: true, path: "/tags/c++" }
457
+ * buildPath("/profile/:userId", {}); // { ok: false, reason: "missing", missing: "userId" }
458
+ * buildPath("/files/:name", { name: ".." }); // { ok: false, reason: "unresolvable", … }
459
+ * ```
460
+ */
461
+ export function buildPath(pattern, params = {}, options) {
462
+ const walked = walkParts(parsePattern(pattern, options).parts, params);
463
+ if (!walked.ok)
464
+ return walked;
465
+ const offending = findUnresolvable(walked.path, walked.marks);
466
+ if (offending) {
467
+ return {
468
+ ok: false,
469
+ reason: "unresolvable",
470
+ param: offending.param,
471
+ value: offending.value,
472
+ };
473
+ }
474
+ return { ok: true, path: walked.path };
475
+ }
476
+ /**
477
+ * Answers the param that writes a segment which resolves away, or `null` when none does.
478
+ *
479
+ * The test reads the ASSEMBLED segment, and not the value on its own. `/files/v:version`
480
+ * with the value `"."` writes the segment `"v."`, which names itself and which the same
481
+ * pattern matches back; a test of the value alone refused it. Only a value that stands as
482
+ * the WHOLE segment can resolve that segment away.
483
+ *
484
+ * A segment of dots that NO param writes belongs to the pattern itself. The walk keeps it:
485
+ * the author of the pattern wrote it, and this function answers for the values of a caller.
486
+ */
487
+ function findUnresolvable(path, marks) {
488
+ let start = 0;
489
+ for (const segment of path.split("/")) {
490
+ const end = start + segment.length;
491
+ if (isDotSegment(segment)) {
492
+ const mark = marks.find((candidate) => candidate.start < end && start < candidate.end);
493
+ if (mark)
494
+ return mark;
495
+ }
496
+ start = end + 1;
497
+ }
498
+ return null;
499
+ }
500
+ /** Walks the parts of one frame. A group opens a frame of its own. */
501
+ function walkParts(parts, params) {
502
+ let path = "";
503
+ const marks = [];
504
+ for (const part of parts) {
505
+ if (part.kind === "literal") {
506
+ // The text carries every escape resolved already, so `/tags/c\+\+` writes `c++`.
507
+ path += part.text;
508
+ continue;
509
+ }
510
+ if (part.kind === "param") {
511
+ const value = readValue(part, params);
512
+ if (value !== null) {
513
+ // The span of the VALUE alone, and not of the prefix: `findUnresolvable` asks
514
+ // whether the value stands as the whole segment.
515
+ path += part.prefix;
516
+ const start = path.length;
517
+ path += encodeValue(value, part);
518
+ marks.push({ start, end: path.length, param: part.name ?? ANONYMOUS, value });
519
+ continue;
520
+ }
521
+ // `?` and `*` mean zero or more, so the part goes away with its prefix. The
522
+ // modifier `+` needs one value, and so does a param that carries no modifier.
523
+ if (makesOptional(part.modifier))
524
+ continue;
525
+ return { ok: false, reason: "missing", missing: part.name ?? ANONYMOUS };
526
+ }
527
+ const inner = walkParts(part.parts, params);
528
+ if (inner.ok) {
529
+ // A group opens a frame of its own, so its marks count from ITS first character.
530
+ // Rebase them onto this frame as the group text joins the path.
531
+ const base = path.length;
532
+ path += inner.path;
533
+ for (const mark of inner.marks) {
534
+ marks.push({ ...mark, start: mark.start + base, end: mark.end + base });
535
+ }
536
+ continue;
537
+ }
538
+ // A group that needs a value it does not have goes away WHOLE when the group carries
539
+ // an optional modifier. `/books{/:id}?` with no `id` therefore builds `/books`.
540
+ if (makesOptional(part.modifier))
541
+ continue;
542
+ return inner;
543
+ }
544
+ return { ok: true, path, marks };
545
+ }
546
+ /**
547
+ * Reads the value of one param, or `null` when the param has none.
548
+ *
549
+ * An EMPTY string counts as no value. An optional param with an empty value then goes
550
+ * away, rather than writing a separator with nothing behind it.
551
+ */
552
+ function readValue(part, params) {
553
+ if (part.name === null)
554
+ return null;
555
+ if (!Object.hasOwn(params, part.name))
556
+ return null;
557
+ // nosemgrep: gitlab.eslint.detect-object-injection
558
+ const raw = params[part.name];
559
+ if (raw === undefined || raw === null)
560
+ return null;
561
+ const value = String(raw);
562
+ // A param that carries `*` or `+` holds a PATH, and an empty segment of that path
563
+ // names nothing: `/docs/intro` and `docs/intro/` and `a//b` each describe the same
564
+ // sequence of segments as `docs/intro` and `a/b`. The walk drops the empty segments,
565
+ // because the pattern can express none of them — `/files/:path*` with `/docs/intro`
566
+ // built `/files//docs/intro` before, which its OWN route matches never.
567
+ //
568
+ // The normalization belongs to the VALUE, and not to the built path. A `{…}` group
569
+ // writes a doubled separator legitimately — `/a/{b}?/c` matches `/a//c` — so a collapse
570
+ // of the whole path would break the bare form that the match reads.
571
+ if (part.modifier === "*" || part.modifier === "+") {
572
+ const segments = value.split("/").filter((segment) => segment !== "");
573
+ return segments.length === 0 ? null : segments.join("/");
574
+ }
575
+ return value === "" ? null : value;
576
+ }
577
+ /**
578
+ * Answers whether one segment resolves away in a URL, rather than naming itself.
579
+ *
580
+ * A URL parser reads the dot segments BEFORE it decodes the percent escapes, and it counts
581
+ * `%2e` as the dot. The WHATWG URL standard names six forms, and a browser resolves every
582
+ * one of them out of a location: `.` and `%2e` for the single dot, and `..`, `.%2e`, `%2e.`
583
+ * and `%2e%2e` for the double dot. Each percent test is case-insensitive.
584
+ *
585
+ * NO encoding of `.` or `..` therefore survives. A param that carries one describes a path
586
+ * that its own pattern matches back never, so {@link buildPath} refuses it, and
587
+ * `normalizeBasePath` of `@xmachines/play-url` refuses the same value in a base path.
588
+ *
589
+ * Give the segment in the form that the URL carries, and encode nothing here. The two
590
+ * callers reach that form by two roads, and both are correct. {@link buildPath} encodes a
591
+ * param value first, so a value that HOLDS a percent sign arrives with that sign escaped:
592
+ * the string `"%2e"` encodes to `"%252e"`, which names itself and stays. `normalizeBasePath`
593
+ * of `@xmachines/play-url` reads a path that an author wrote, which is encoded already, so
594
+ * it passes the segment RAW and `"%2e"` there is the dot. A caller that encodes an
595
+ * already-encoded segment a second time hides every `%2e` from this test.
596
+ *
597
+ * @param segment - ONE path segment, with no separator around it, in the form that the URL
598
+ * carries it.
599
+ * @returns `true` when a URL resolves the segment away.
600
+ */
601
+ export function isDotSegment(segment) {
602
+ const dots = segment.toLowerCase().replaceAll("%2e", ".");
603
+ return dots === "." || dots === "..";
604
+ }
605
+ /**
606
+ * Percent-encodes one value, and the modifier decides how much of it.
607
+ *
608
+ * A param that carries `*` or `+` matches ACROSS a separator, so its value holds a PATH
609
+ * and not a segment: `/files/:path*` with `a/b` must build `/files/a/b`. The walk encodes
610
+ * each segment of such a value and keeps the separators between them.
611
+ * `encodeURIComponent` over the whole value wrote `a%2Fb`, which the pattern reads back
612
+ * as ONE segment named `a/b`, and a round trip that changes the value is no round trip.
613
+ */
614
+ function encodeValue(value, part) {
615
+ if (part.modifier === "*" || part.modifier === "+") {
616
+ return value.split("/").map(encodeURIComponent).join("/");
617
+ }
618
+ return encodeURIComponent(value);
619
+ }
620
+ /**
621
+ * Builds the one path that the pattern matches, or `null` when it matches more than one.
622
+ *
623
+ * The walk resolves each escape, because {@link LiteralPart.text} already holds the
624
+ * character alone. A param carries a value that the pattern does not hold, and a modifier
625
+ * makes its part repeat or disappear: either one describes a SET of paths, and the walk
626
+ * answers `null` for it.
627
+ */
628
+ function buildLiteralPath(parts) {
629
+ let path = "";
630
+ for (const part of parts) {
631
+ if (part.kind === "literal") {
632
+ path += part.text;
633
+ continue;
634
+ }
635
+ if (part.kind === "param" || part.modifier !== "")
636
+ return null;
637
+ const inner = buildLiteralPath(part.parts);
638
+ if (inner === null)
639
+ return null;
640
+ path += inner;
641
+ }
642
+ return path;
643
+ }
644
+ /**
645
+ * Reads the key of the bucket index from the parts: the first path segment.
646
+ *
647
+ * The key is `"*"` when a param can stand in the first segment, because the concrete
648
+ * path then carries a segment that the pattern does not name. `getIndexKey` computes the
649
+ * same key from a concrete path, and the two must agree: a pattern in the wrong bucket
650
+ * is a route that matches nothing.
651
+ */
652
+ function readIndexKey(parts) {
653
+ const first = parts[0];
654
+ if (first === undefined)
655
+ return "/";
656
+ if (first.kind !== "literal")
657
+ return "*";
658
+ // The literal reaches past the first separator, so the first segment is complete and
659
+ // every part after it belongs to a later segment.
660
+ const complete = first.text.slice(1).includes("/");
661
+ if (complete)
662
+ return getIndexKey(first.text);
663
+ // The literal is the ROOT separator alone, so it opens no segment of its own: the part
664
+ // after it fills the first segment, or it starts with a `/` and leaves that segment
665
+ // EMPTY. `getIndexKey` answers `"/"` for the path `"/"` and `""` for the path `"//x"`,
666
+ // so neither key covers the whole set, and `/{/v2}` sat in a bucket that the path
667
+ // `//v2` reaches never. The wildcard key covers it: a lookup joins that bucket with
668
+ // the bucket of its own first segment.
669
+ if (first.text === PREFIX && parts.length > 1)
670
+ return "*";
671
+ // The literal ends inside the first segment, so a part that adds characters to that
672
+ // segment makes the key unknown. A group that opens with the separator adds none, and
673
+ // `/books{/:id}?` therefore keeps the literal key `books`.
674
+ //
675
+ // The walk reads past every OPTIONAL part, and it stops at the first part that a path
676
+ // must carry. A test of the next part alone read `/x{/:a}?-b` as the key `x`, while the
677
+ // path of its bare form is `/x-b` and gives the key `x-b`: the route sat in a bucket
678
+ // that no path reaches, which is the same defect as the `books{` key of the split.
679
+ // `Array.at`, for the reason that {@link readName} gives for `String.at`: the read is an
680
+ // ELEMENT and not a property, and the method form says so. A `slice(1)` said it too,
681
+ // and it allocated a copy of every part to skip one.
682
+ for (let index = 1; index < parts.length; index += 1) {
683
+ const part = parts.at(index);
684
+ /* c8 ignore next */
685
+ if (part === undefined)
686
+ break;
687
+ // A group with no part of its own carries no character, so it ends nothing and it
688
+ // decides nothing. A break on it read `/c{}\+` as the key `c`, while the path
689
+ // `/c+` that the pattern matches gives the key `c+`.
690
+ if (part.kind === "group" && part.parts.length === 0)
691
+ continue;
692
+ if (continuesFirstSegment(part))
693
+ return "*";
694
+ if (!isOptionalPart(part))
695
+ break;
696
+ }
697
+ return getIndexKey(first.text);
698
+ }
699
+ /** True when a matching path can carry no characters for the part. */
700
+ function isOptionalPart(part) {
701
+ return part.kind !== "literal" && makesOptional(part.modifier);
702
+ }
703
+ /** True when the part can add characters to the first path segment. */
704
+ function continuesFirstSegment(part) {
705
+ if (part.kind === "param")
706
+ return part.prefix === "";
707
+ if (part.kind === "literal")
708
+ return !part.text.startsWith("/");
709
+ const first = part.parts[0];
710
+ if (first === undefined)
711
+ return false;
712
+ if (first.kind === "literal")
713
+ return !first.text.startsWith("/");
714
+ return first.kind !== "param" || first.prefix === "";
715
+ }
716
+ //# sourceMappingURL=pattern-grammar.js.map