@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/cache.d.ts +82 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +83 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/pattern-grammar.d.ts +295 -0
- package/dist/pattern-grammar.d.ts.map +1 -0
- package/dist/pattern-grammar.js +716 -0
- package/dist/pattern-grammar.js.map +1 -0
- package/package.json +61 -0
|
@@ -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
|