@peter.naydenov/url-pattern 1.0.2 → 1.0.4
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/url-pattern.cjs.js +207 -58
- package/dist/url-pattern.es.js +205 -58
- package/dist/url-pattern.umd.js +1 -1
- package/package.json +6 -6
- package/{README.md → readme.md} +26 -10
- package/src/main.js +204 -57
- package/types/main.d.ts +87 -71
package/src/main.js
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @typedef {Object} UrlPatternOptions
|
|
8
|
-
* @property {string} [escapeChar='\\'] - Character used for escaping
|
|
8
|
+
* @property {string} [escapeChar='\\'] - Character used for escaping. Only escapes regex metacharacters (`^$.*+?()[]{}|\`); for any other character the backslash is treated as a literal.
|
|
9
9
|
* @property {string} [segmentNameStartChar=':'] - Character that starts a named segment
|
|
10
|
-
* @property {string} [
|
|
10
|
+
* @property {string} [segmentNameEndChar] - Character that ends a named segment. When set, the segment name stops at the first occurrence of this character (instead of stopping at the first character outside `segmentNameCharset`).
|
|
11
|
+
* @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
|
|
11
12
|
* @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
|
|
12
13
|
* @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
|
|
13
14
|
* @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
|
|
14
|
-
* @property {string} [wildcardChar='*'] - Character that denotes a wildcard
|
|
15
|
+
* @property {string} [wildcardChar='*'] - Character that denotes a wildcard in the pattern
|
|
16
|
+
* @property {string} [wildcardName='_'] - Key under which the wildcard value is stored in the match result
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
* @property {string} name - Segment name
|
|
20
22
|
* @property {string} type - Segment type ('named' | 'wildcard' | 'literal')
|
|
21
23
|
* @property {boolean} [optional=false] - Whether the segment is optional
|
|
24
|
+
* @property {number} [optionalGroupId] - Identifier of the optional group this segment belongs to; absent for required segments
|
|
22
25
|
* @property {string} regex - Compiled regex string
|
|
23
26
|
*/
|
|
24
27
|
|
|
@@ -48,11 +51,13 @@
|
|
|
48
51
|
const DEFAULT_OPTIONS = {
|
|
49
52
|
escapeChar: '\\',
|
|
50
53
|
segmentNameStartChar: ':',
|
|
51
|
-
|
|
54
|
+
segmentNameEndChar: undefined,
|
|
55
|
+
segmentNameCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_',
|
|
52
56
|
segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
|
|
53
57
|
optionalSegmentStartChar: '(',
|
|
54
58
|
optionalSegmentEndChar: ')',
|
|
55
|
-
wildcardChar: '*'
|
|
59
|
+
wildcardChar: '*',
|
|
60
|
+
wildcardName: '_'
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
/**
|
|
@@ -62,6 +67,26 @@ const DEFAULT_OPTIONS = {
|
|
|
62
67
|
*/
|
|
63
68
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Builds a safe regex character class body from a charset string.
|
|
72
|
+
*
|
|
73
|
+
* The charset is treated as a list of explicit characters — range notation
|
|
74
|
+
* (`a-z`) is NOT interpreted as a range. To match the same set of chars
|
|
75
|
+
* with range syntax, expand it (e.g. `a-z` → `abcdefghijklmnopqrstuvwxyz`).
|
|
76
|
+
*
|
|
77
|
+
* The following chars are escaped because they are special inside `[...]`:
|
|
78
|
+
* `\`, `]`, `^`, and `-`.
|
|
79
|
+
* @param {string} charset - Character class body
|
|
80
|
+
* @returns {string} Safe char class body
|
|
81
|
+
*/
|
|
82
|
+
const escapeCharClass = (charset) => {
|
|
83
|
+
let escaped = charset.replace(/\\/g, '\\\\');
|
|
84
|
+
escaped = escaped.replace(/\]/g, '\\]');
|
|
85
|
+
escaped = escaped.replace(/\^/g, '\\^');
|
|
86
|
+
escaped = escaped.replace(/-/g, '\\-');
|
|
87
|
+
return escaped;
|
|
88
|
+
};
|
|
89
|
+
|
|
65
90
|
/**
|
|
66
91
|
* Merges default options with user provided options
|
|
67
92
|
* @param {UrlPatternOptions} [userOptions={}] - User provided options
|
|
@@ -112,29 +137,63 @@ const parsePattern = (pattern, options) => {
|
|
|
112
137
|
const segments = [];
|
|
113
138
|
let i = 0;
|
|
114
139
|
let inOptional = false;
|
|
140
|
+
let optionalGroupId = 0;
|
|
141
|
+
let parenDepth = 0;
|
|
115
142
|
|
|
116
143
|
while (i < pattern.length) {
|
|
117
144
|
const char = pattern[i];
|
|
118
145
|
|
|
119
|
-
if (char === options.escapeChar
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
if (char === options.escapeChar) {
|
|
147
|
+
// Throw when the escape char is at the end of the pattern — nothing follows
|
|
148
|
+
// it to escape, and the resulting regex would be broken.
|
|
149
|
+
if (i + 1 >= pattern.length) {
|
|
150
|
+
throw new Error(`Invalid pattern: '\\' at position ${i} has nothing to escape`);
|
|
151
|
+
}
|
|
152
|
+
const nextChar = pattern[i + 1];
|
|
153
|
+
// Only treat \ as an escape when followed by a regex metacharacter.
|
|
154
|
+
// For any other character the backslash is literal (e.g. '\:' in a URL
|
|
155
|
+
// is just a backslash followed by ':'). This means `:` cannot be escaped
|
|
156
|
+
// — it has no special meaning in regex, so the escape adds nothing.
|
|
157
|
+
const regexMetachars = '^$.*+?()[]{}|\\';
|
|
158
|
+
if (!regexMetachars.includes(nextChar)) {
|
|
159
|
+
// Not a regex metachar: treat the backslash as a literal character,
|
|
160
|
+
// advance past it so the next character is processed normally.
|
|
161
|
+
segments.push({
|
|
162
|
+
type: 'literal',
|
|
163
|
+
name: '\\',
|
|
164
|
+
regex: '\\\\',
|
|
165
|
+
optional: inOptional,
|
|
166
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
167
|
+
});
|
|
168
|
+
i += 1;
|
|
169
|
+
continue;
|
|
170
|
+
} else {
|
|
171
|
+
segments.push({
|
|
172
|
+
type: 'literal',
|
|
173
|
+
name: nextChar,
|
|
174
|
+
regex: escapeRegex(nextChar),
|
|
175
|
+
optional: inOptional,
|
|
176
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
177
|
+
});
|
|
178
|
+
i += 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
128
181
|
}
|
|
129
182
|
|
|
130
183
|
if (char === options.optionalSegmentStartChar) {
|
|
131
184
|
inOptional = true;
|
|
185
|
+
optionalGroupId++;
|
|
186
|
+
parenDepth++;
|
|
132
187
|
i++;
|
|
133
188
|
continue;
|
|
134
189
|
}
|
|
135
190
|
|
|
136
191
|
if (char === options.optionalSegmentEndChar) {
|
|
192
|
+
if (parenDepth === 0) {
|
|
193
|
+
throw new Error(`Invalid pattern: unmatched '${char}' at position ${i}`);
|
|
194
|
+
}
|
|
137
195
|
inOptional = false;
|
|
196
|
+
parenDepth--;
|
|
138
197
|
i++;
|
|
139
198
|
continue;
|
|
140
199
|
}
|
|
@@ -142,9 +201,10 @@ const parsePattern = (pattern, options) => {
|
|
|
142
201
|
if (char === options.wildcardChar) {
|
|
143
202
|
segments.push({
|
|
144
203
|
type: 'wildcard',
|
|
145
|
-
name:
|
|
204
|
+
name: options.wildcardName,
|
|
146
205
|
regex: '.*',
|
|
147
|
-
optional: inOptional
|
|
206
|
+
optional: inOptional,
|
|
207
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
148
208
|
});
|
|
149
209
|
i++;
|
|
150
210
|
continue;
|
|
@@ -154,8 +214,12 @@ const parsePattern = (pattern, options) => {
|
|
|
154
214
|
const remaining = pattern.slice(i + 1);
|
|
155
215
|
let nameEnd = 0;
|
|
156
216
|
const charset = options.segmentNameCharset || '';
|
|
157
|
-
|
|
217
|
+
const endChar = options.segmentNameEndChar;
|
|
218
|
+
|
|
158
219
|
for (let j = 0; j < remaining.length; j++) {
|
|
220
|
+
if (endChar && remaining[j] === endChar) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
159
223
|
if (!charset.includes(remaining[j])) {
|
|
160
224
|
break;
|
|
161
225
|
}
|
|
@@ -163,54 +227,77 @@ const parsePattern = (pattern, options) => {
|
|
|
163
227
|
}
|
|
164
228
|
|
|
165
229
|
const name = remaining.slice(0, nameEnd);
|
|
166
|
-
|
|
230
|
+
const consumeEndChar = !!(endChar && remaining[nameEnd] === endChar);
|
|
231
|
+
|
|
167
232
|
if (name.length > 0) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
valueCharset = valueCharset.replace(/-/g, '');
|
|
171
|
-
valueCharset += '-';
|
|
172
|
-
}
|
|
173
|
-
const escapedValueCharset = valueCharset.replace(/\]/g, '\\]');
|
|
233
|
+
const valueCharset = options.segmentValueCharset || '';
|
|
234
|
+
const escapedValueCharset = escapeCharClass(valueCharset);
|
|
174
235
|
const valueRegex = `([${escapedValueCharset}]+)`;
|
|
175
|
-
|
|
236
|
+
|
|
176
237
|
segments.push({
|
|
177
238
|
type: 'named',
|
|
178
239
|
name,
|
|
179
240
|
regex: valueRegex,
|
|
180
|
-
optional: inOptional
|
|
241
|
+
optional: inOptional,
|
|
242
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
181
243
|
});
|
|
182
|
-
i += 1 + nameEnd;
|
|
244
|
+
i += 1 + nameEnd + (consumeEndChar ? 1 : 0);
|
|
183
245
|
continue;
|
|
184
246
|
}
|
|
247
|
+
|
|
248
|
+
// segmentNameStartChar at end of pattern, or followed by a non-charset
|
|
249
|
+
// character that is also a special char (e.g. ':)').
|
|
250
|
+
throw new Error(`Invalid pattern: '${char}' at position ${i} has no segment name`);
|
|
185
251
|
}
|
|
186
252
|
|
|
187
253
|
const literalEnd = findNextSpecialChar(pattern, i, options);
|
|
188
|
-
|
|
254
|
+
|
|
189
255
|
if (literalEnd > i) {
|
|
190
256
|
const literal = pattern.slice(i, literalEnd);
|
|
191
257
|
segments.push({
|
|
192
258
|
type: 'literal',
|
|
193
259
|
name: literal,
|
|
194
260
|
regex: escapeRegex(literal),
|
|
195
|
-
optional: inOptional
|
|
261
|
+
optional: inOptional,
|
|
262
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
196
263
|
});
|
|
197
264
|
i = literalEnd;
|
|
198
265
|
continue;
|
|
199
266
|
}
|
|
200
267
|
|
|
201
|
-
i
|
|
268
|
+
// literalEnd === i: current char itself is a special char with no
|
|
269
|
+
// name/value following. Throw for clarity.
|
|
270
|
+
throw new Error(`Invalid pattern: '${char}' at position ${i} has no segment name`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (parenDepth !== 0) {
|
|
274
|
+
throw new Error(`Invalid pattern: unclosed '${options.optionalSegmentStartChar}'`);
|
|
202
275
|
}
|
|
203
276
|
|
|
204
277
|
return segments;
|
|
205
278
|
};
|
|
206
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Returns true when a value should be treated as "absent" for an optional segment.
|
|
282
|
+
* @param {*} val - Value to inspect
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
const isAbsentValue = (val) => {
|
|
286
|
+
if (val === undefined || val === null || val === '') return true;
|
|
287
|
+
// Number.isNaN catches numeric NaN values that would otherwise stringify to
|
|
288
|
+
// the literal string "NaN" in a generated URL.
|
|
289
|
+
if (typeof val === 'number' && Number.isNaN(val)) return true;
|
|
290
|
+
if (Array.isArray(val) && val.length === 0) return true;
|
|
291
|
+
return false;
|
|
292
|
+
};
|
|
293
|
+
|
|
207
294
|
/**
|
|
208
295
|
* Compiles segments into a regex pattern
|
|
209
296
|
* @param {Array<ParsedSegment>} segments - Parsed segments
|
|
210
297
|
* @param {UrlPatternOptions} options - Options
|
|
211
298
|
* @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
|
|
212
299
|
*/
|
|
213
|
-
const compileRegex = (segments,
|
|
300
|
+
const compileRegex = (segments, options) => {
|
|
214
301
|
let regex = '^';
|
|
215
302
|
let groupIndex = 0;
|
|
216
303
|
/** @type {Array<SegmentName>} */
|
|
@@ -223,13 +310,14 @@ const compileRegex = (segments, _options) => {
|
|
|
223
310
|
if (segment.optional) {
|
|
224
311
|
let optionalPart = '';
|
|
225
312
|
let j = i;
|
|
226
|
-
|
|
227
|
-
|
|
313
|
+
const currentGroupId = segment.optionalGroupId;
|
|
314
|
+
|
|
315
|
+
while (j < segments.length && segments[j].optional && segments[j].optionalGroupId === currentGroupId) {
|
|
228
316
|
const seg = segments[j];
|
|
229
317
|
|
|
230
318
|
if (seg.type === 'wildcard') {
|
|
231
319
|
optionalPart += '(.*)';
|
|
232
|
-
segmentNames.push({ name:
|
|
320
|
+
segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
|
|
233
321
|
groupIndex++;
|
|
234
322
|
} else if (seg.type === 'named') {
|
|
235
323
|
optionalPart += seg.regex;
|
|
@@ -249,7 +337,7 @@ const compileRegex = (segments, _options) => {
|
|
|
249
337
|
|
|
250
338
|
if (segment.type === 'wildcard') {
|
|
251
339
|
regex += '(.*)';
|
|
252
|
-
segmentNames.push({ name:
|
|
340
|
+
segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
|
|
253
341
|
groupIndex++;
|
|
254
342
|
} else if (segment.type === 'named') {
|
|
255
343
|
regex += segment.regex;
|
|
@@ -296,9 +384,20 @@ const makePattern = (pattern, options = {}) => {
|
|
|
296
384
|
* @returns {CompiledPattern} Compiled pattern
|
|
297
385
|
*/
|
|
298
386
|
const makePatternFromRegex = (regex, keys = []) => {
|
|
387
|
+
// Strip the `g` and `y` flags. The `g` flag makes `RegExp.prototype.exec`
|
|
388
|
+
// advance `lastIndex` between calls, which would cause subsequent matches
|
|
389
|
+
// to start from the wrong position. The `y` (sticky) flag requires a match
|
|
390
|
+
// starting exactly at `lastIndex`, which is incompatible with our
|
|
391
|
+
// anchored, single-match contract. Other flags (`i`, `m`, `s`, `d`, `u`)
|
|
392
|
+
// are preserved.
|
|
393
|
+
const safeFlags = regex.flags.replace(/[gy]/g, '');
|
|
394
|
+
const regexObj = safeFlags === regex.flags
|
|
395
|
+
? regex
|
|
396
|
+
: new RegExp(regex.source, safeFlags);
|
|
397
|
+
|
|
299
398
|
return {
|
|
300
399
|
regex: regex.source,
|
|
301
|
-
regexObj
|
|
400
|
+
regexObj,
|
|
302
401
|
segments: [],
|
|
303
402
|
segmentNames: keys.map((name, index) => ({ name, index, type: 'named' })),
|
|
304
403
|
options: DEFAULT_OPTIONS,
|
|
@@ -314,6 +413,14 @@ const makePatternFromRegex = (regex, keys = []) => {
|
|
|
314
413
|
* @returns {Object|null} Extracted values or null if no match
|
|
315
414
|
*/
|
|
316
415
|
const match = (compiled, str) => {
|
|
416
|
+
// Guard against non-string input. `RegExp.prototype.exec` coerces with
|
|
417
|
+
// String(), so `match(123)` would silently try to match "123" — almost
|
|
418
|
+
// certainly not what the caller meant. Throw a TypeError so the misuse
|
|
419
|
+
// surfaces immediately.
|
|
420
|
+
if (typeof str !== 'string') {
|
|
421
|
+
throw new TypeError(`pattern.match() requires a string, got ${typeof str}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
317
424
|
const matchResult = compiled.regexObj.exec(str);
|
|
318
425
|
|
|
319
426
|
if (!matchResult) {
|
|
@@ -325,20 +432,40 @@ const match = (compiled, str) => {
|
|
|
325
432
|
const result = {};
|
|
326
433
|
compiled.keys.forEach((key, index) => {
|
|
327
434
|
const val = matchResult[index + 1];
|
|
328
|
-
|
|
435
|
+
// Undefined means the optional group didn't participate — omit the key
|
|
436
|
+
// so this is consistent with string-pattern behaviour.
|
|
437
|
+
if (val !== undefined) {
|
|
438
|
+
result[key] = val;
|
|
439
|
+
}
|
|
329
440
|
});
|
|
330
441
|
return result;
|
|
331
442
|
}
|
|
443
|
+
// No keys, no groups → return the captured values as an array. If the
|
|
444
|
+
// regex had no capture groups, this is an empty array; we keep the
|
|
445
|
+
// existing behaviour (documented in the README's regex example) rather
|
|
446
|
+
// than coercing to `{}` and breaking callers that rely on the array.
|
|
332
447
|
return matchResult.slice(1);
|
|
333
448
|
}
|
|
334
449
|
|
|
335
450
|
const result = {};
|
|
336
451
|
const usedNames = new Set();
|
|
337
452
|
|
|
453
|
+
// Track which segment names are wildcards so their empty-string captures survive
|
|
454
|
+
// the cleanup loop. An empty wildcard result (e.g. '/files/*' on '/files/')
|
|
455
|
+
// is a valid match result.
|
|
456
|
+
const wildcardNames = new Set();
|
|
457
|
+
for (const seg of compiled.segmentNames) {
|
|
458
|
+
if (seg.type === 'wildcard') wildcardNames.add(seg.name);
|
|
459
|
+
}
|
|
460
|
+
|
|
338
461
|
for (let i = 0; i < compiled.segmentNames.length; i++) {
|
|
339
462
|
const segInfo = compiled.segmentNames[i];
|
|
340
|
-
const
|
|
341
|
-
|
|
463
|
+
const hasCapture = segInfo.index + 1 in matchResult;
|
|
464
|
+
// When an optional group didn't participate, the regex returns undefined.
|
|
465
|
+
// Normalize to '' so both non-isRegex and isRegex paths behave the same.
|
|
466
|
+
const rawValue = hasCapture ? matchResult[segInfo.index + 1] : '';
|
|
467
|
+
const value = rawValue === undefined ? '' : rawValue;
|
|
468
|
+
|
|
342
469
|
if (usedNames.has(segInfo.name)) {
|
|
343
470
|
if (!Array.isArray(result[segInfo.name])) {
|
|
344
471
|
result[segInfo.name] = [result[segInfo.name]];
|
|
@@ -350,8 +477,19 @@ const match = (compiled, str) => {
|
|
|
350
477
|
}
|
|
351
478
|
}
|
|
352
479
|
|
|
480
|
+
// Delete empty-string entries, but preserve wildcards — an empty capture is
|
|
481
|
+
// a valid match result for wildcards. Also clean null/undefined from arrays.
|
|
353
482
|
for (const key in result) {
|
|
354
|
-
|
|
483
|
+
const val = result[key];
|
|
484
|
+
if (Array.isArray(val)) {
|
|
485
|
+
// Filter out empty-string entries from arrays (but preserve wildcards).
|
|
486
|
+
const filtered = val.filter(v => v !== '' || wildcardNames.has(key));
|
|
487
|
+
if (filtered.length === 0) {
|
|
488
|
+
delete result[key];
|
|
489
|
+
} else {
|
|
490
|
+
result[key] = filtered;
|
|
491
|
+
}
|
|
492
|
+
} else if (val === '' && !wildcardNames.has(key)) {
|
|
355
493
|
delete result[key];
|
|
356
494
|
}
|
|
357
495
|
}
|
|
@@ -380,37 +518,38 @@ const stringify = (compiled, values = {}) => {
|
|
|
380
518
|
if (segment.optional) {
|
|
381
519
|
let optionalPart = '';
|
|
382
520
|
let j = i;
|
|
383
|
-
|
|
384
|
-
|
|
521
|
+
const currentGroupId = segment.optionalGroupId;
|
|
522
|
+
|
|
523
|
+
while (j < compiled.segments.length && compiled.segments[j].optional && compiled.segments[j].optionalGroupId === currentGroupId) {
|
|
385
524
|
const seg = compiled.segments[j];
|
|
386
|
-
|
|
525
|
+
|
|
387
526
|
if (seg.type === 'literal') {
|
|
388
527
|
optionalPart += seg.name;
|
|
389
528
|
} else if (seg.type === 'named') {
|
|
390
529
|
const val = values[seg.name];
|
|
391
|
-
if (val
|
|
392
|
-
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
393
|
-
} else {
|
|
530
|
+
if (isAbsentValue(val)) {
|
|
394
531
|
optionalPart = '';
|
|
395
532
|
break;
|
|
396
533
|
}
|
|
534
|
+
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
397
535
|
} else if (seg.type === 'wildcard') {
|
|
398
|
-
const val = values.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
536
|
+
const val = values[compiled.options.wildcardName];
|
|
537
|
+
// Wildcards inside an optional group still consume values[wildcardName]
|
|
538
|
+
// when present; if absent, the whole group is wiped.
|
|
539
|
+
if (isAbsentValue(val)) {
|
|
402
540
|
optionalPart = '';
|
|
403
541
|
break;
|
|
404
542
|
}
|
|
543
|
+
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
405
544
|
}
|
|
406
|
-
|
|
545
|
+
|
|
407
546
|
j++;
|
|
408
547
|
}
|
|
409
|
-
|
|
548
|
+
|
|
410
549
|
if (optionalPart !== '') {
|
|
411
550
|
result += optionalPart;
|
|
412
551
|
}
|
|
413
|
-
|
|
552
|
+
|
|
414
553
|
if (i === j) {
|
|
415
554
|
i++;
|
|
416
555
|
} else {
|
|
@@ -423,13 +562,15 @@ const stringify = (compiled, values = {}) => {
|
|
|
423
562
|
result += segment.name;
|
|
424
563
|
} else if (segment.type === 'named') {
|
|
425
564
|
const value = values[segment.name];
|
|
426
|
-
if (value
|
|
565
|
+
if (isAbsentValue(value)) {
|
|
427
566
|
throw new Error(`Missing required value for segment: ${segment.name}`);
|
|
428
567
|
}
|
|
429
568
|
result += Array.isArray(value) ? value.join('/') : value;
|
|
430
569
|
} else if (segment.type === 'wildcard') {
|
|
431
|
-
const value = values.
|
|
432
|
-
|
|
570
|
+
const value = values[compiled.options.wildcardName];
|
|
571
|
+
// Empty string is a valid wildcard match result (e.g. '/files/*' on '/files/')
|
|
572
|
+
// and should be allowed without throwing.
|
|
573
|
+
if (value === undefined || value === null) {
|
|
433
574
|
throw new Error('Missing required wildcard value');
|
|
434
575
|
}
|
|
435
576
|
result += Array.isArray(value) ? value.join('/') : value;
|
|
@@ -458,6 +599,12 @@ class UrlPattern {
|
|
|
458
599
|
/** @type {CompiledPattern} */
|
|
459
600
|
this.compiled = makePattern(pattern, /** @type {UrlPatternOptions} */ (options));
|
|
460
601
|
}
|
|
602
|
+
// The compiled state is exposed as a read-only introspection field; freeze
|
|
603
|
+
// it (and the nested `options` object) so accidental mutation fails loudly
|
|
604
|
+
// instead of silently desynchronising the cached regex. `Object.freeze`
|
|
605
|
+
// is shallow, so we explicitly freeze `options` too.
|
|
606
|
+
Object.freeze(this.compiled.options);
|
|
607
|
+
Object.freeze(this.compiled);
|
|
461
608
|
}
|
|
462
609
|
|
|
463
610
|
/**
|
|
@@ -490,4 +637,4 @@ const urlPattern = (pattern, options = {}) => {
|
|
|
490
637
|
};
|
|
491
638
|
|
|
492
639
|
export { UrlPattern, urlPattern, makePattern, makePatternFromRegex, match, stringify, DEFAULT_OPTIONS };
|
|
493
|
-
export default
|
|
640
|
+
export default urlPattern;
|
package/types/main.d.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview URL pattern matching library
|
|
3
|
+
* @module url-pattern
|
|
4
|
+
*/
|
|
2
5
|
export type UrlPatternOptions = {
|
|
3
6
|
/**
|
|
4
|
-
* - Character used for escaping
|
|
7
|
+
* - Character used for escaping. Only escapes regex metacharacters (`^$.*+?()[]{}|\`); for any other character the backslash is treated as a literal.
|
|
5
8
|
*/
|
|
6
9
|
escapeChar?: string;
|
|
7
10
|
/**
|
|
8
11
|
* - Character that starts a named segment
|
|
9
12
|
*/
|
|
10
13
|
segmentNameStartChar?: string;
|
|
14
|
+
/**
|
|
15
|
+
* - Character that ends a named segment. When set, the segment name stops at the first occurrence of this character (instead of stopping at the first character outside `segmentNameCharset`).
|
|
16
|
+
*/
|
|
17
|
+
segmentNameEndChar?: string;
|
|
11
18
|
/**
|
|
12
19
|
* - Characters allowed in segment names
|
|
13
20
|
*/
|
|
@@ -25,9 +32,13 @@ export type UrlPatternOptions = {
|
|
|
25
32
|
*/
|
|
26
33
|
optionalSegmentEndChar?: string;
|
|
27
34
|
/**
|
|
28
|
-
* - Character that denotes a wildcard
|
|
35
|
+
* - Character that denotes a wildcard in the pattern
|
|
29
36
|
*/
|
|
30
37
|
wildcardChar?: string;
|
|
38
|
+
/**
|
|
39
|
+
* - Key under which the wildcard value is stored in the match result
|
|
40
|
+
*/
|
|
41
|
+
wildcardName?: string;
|
|
31
42
|
};
|
|
32
43
|
export type ParsedSegment = {
|
|
33
44
|
/**
|
|
@@ -42,6 +53,10 @@ export type ParsedSegment = {
|
|
|
42
53
|
* - Whether the segment is optional
|
|
43
54
|
*/
|
|
44
55
|
optional?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* - Identifier of the optional group this segment belongs to; absent for required segments
|
|
58
|
+
*/
|
|
59
|
+
optionalGroupId?: number;
|
|
45
60
|
/**
|
|
46
61
|
* - Compiled regex string
|
|
47
62
|
*/
|
|
@@ -95,85 +110,24 @@ export type CompiledPattern = {
|
|
|
95
110
|
*/
|
|
96
111
|
keys?: Array<string>;
|
|
97
112
|
};
|
|
98
|
-
/**
|
|
99
|
-
* UrlPattern class for matching and generating URLs
|
|
100
|
-
*/
|
|
101
|
-
export class UrlPattern {
|
|
102
|
-
/**
|
|
103
|
-
* @param {string|RegExp} pattern - Pattern string or regex
|
|
104
|
-
* @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys (for regex)
|
|
105
|
-
*/
|
|
106
|
-
constructor(pattern: string | RegExp, options?: UrlPatternOptions | Array<string>);
|
|
107
|
-
/** @type {CompiledPattern} */
|
|
108
|
-
compiled: CompiledPattern;
|
|
109
|
-
/**
|
|
110
|
-
* Match a string against the pattern
|
|
111
|
-
* @param {string} str - String to match
|
|
112
|
-
* @returns {Object|null} Extracted values or null if no match
|
|
113
|
-
*/
|
|
114
|
-
match(str: string): any | null;
|
|
115
|
-
/**
|
|
116
|
-
* Generate a string from the pattern
|
|
117
|
-
* @param {Object} [values={}] - Values to stringify
|
|
118
|
-
* @returns {string} Generated string
|
|
119
|
-
*/
|
|
120
|
-
stringify(values?: any): string;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Creates a new UrlPattern instance (functional API)
|
|
124
|
-
* @param {string|RegExp} pattern - Pattern string or regex
|
|
125
|
-
* @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys
|
|
126
|
-
* @returns {UrlPattern} UrlPattern instance
|
|
127
|
-
*/
|
|
128
|
-
export function urlPattern(pattern: string | RegExp, options?: UrlPatternOptions | Array<string>): UrlPattern;
|
|
129
|
-
/**
|
|
130
|
-
* Creates a compiled pattern from a string
|
|
131
|
-
* @param {string} pattern - Pattern string
|
|
132
|
-
* @param {UrlPatternOptions} [options={}] - Options
|
|
133
|
-
* @returns {CompiledPattern} Compiled pattern
|
|
134
|
-
*/
|
|
135
|
-
export function makePattern(pattern: string, options?: UrlPatternOptions): CompiledPattern;
|
|
136
|
-
/**
|
|
137
|
-
* Creates a compiled pattern from a regex
|
|
138
|
-
* @param {RegExp} regex - Regex pattern
|
|
139
|
-
* @param {Array<string>} [keys=[]] - Array of key names for captured groups
|
|
140
|
-
* @returns {CompiledPattern} Compiled pattern
|
|
141
|
-
*/
|
|
142
|
-
export function makePatternFromRegex(regex: RegExp, keys?: Array<string>): CompiledPattern;
|
|
143
|
-
/**
|
|
144
|
-
* Matches a string against a compiled pattern
|
|
145
|
-
* @param {CompiledPattern} compiled - Compiled pattern
|
|
146
|
-
* @param {string} str - String to match
|
|
147
|
-
* @returns {Object|null} Extracted values or null if no match
|
|
148
|
-
*/
|
|
149
|
-
export function match(compiled: CompiledPattern, str: string): any | null;
|
|
150
|
-
/**
|
|
151
|
-
* Stringifies a pattern with given values
|
|
152
|
-
* @param {CompiledPattern} compiled - Compiled pattern
|
|
153
|
-
* @param {Object} [values={}] - Values to stringify
|
|
154
|
-
* @returns {string} Generated string
|
|
155
|
-
* @throws {Error} If required values are missing
|
|
156
|
-
*/
|
|
157
|
-
export function stringify(compiled: CompiledPattern, values?: any): string;
|
|
158
|
-
/**
|
|
159
|
-
* @fileoverview URL pattern matching library
|
|
160
|
-
* @module url-pattern
|
|
161
|
-
*/
|
|
162
113
|
/**
|
|
163
114
|
* @typedef {Object} UrlPatternOptions
|
|
164
|
-
* @property {string} [escapeChar='\\'] - Character used for escaping
|
|
115
|
+
* @property {string} [escapeChar='\\'] - Character used for escaping. Only escapes regex metacharacters (`^$.*+?()[]{}|\`); for any other character the backslash is treated as a literal.
|
|
165
116
|
* @property {string} [segmentNameStartChar=':'] - Character that starts a named segment
|
|
166
|
-
* @property {string} [
|
|
117
|
+
* @property {string} [segmentNameEndChar] - Character that ends a named segment. When set, the segment name stops at the first occurrence of this character (instead of stopping at the first character outside `segmentNameCharset`).
|
|
118
|
+
* @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
|
|
167
119
|
* @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
|
|
168
120
|
* @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
|
|
169
121
|
* @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
|
|
170
|
-
* @property {string} [wildcardChar='*'] - Character that denotes a wildcard
|
|
122
|
+
* @property {string} [wildcardChar='*'] - Character that denotes a wildcard in the pattern
|
|
123
|
+
* @property {string} [wildcardName='_'] - Key under which the wildcard value is stored in the match result
|
|
171
124
|
*/
|
|
172
125
|
/**
|
|
173
126
|
* @typedef {Object} ParsedSegment
|
|
174
127
|
* @property {string} name - Segment name
|
|
175
128
|
* @property {string} type - Segment type ('named' | 'wildcard' | 'literal')
|
|
176
129
|
* @property {boolean} [optional=false] - Whether the segment is optional
|
|
130
|
+
* @property {number} [optionalGroupId] - Identifier of the optional group this segment belongs to; absent for required segments
|
|
177
131
|
* @property {string} regex - Compiled regex string
|
|
178
132
|
*/
|
|
179
133
|
/**
|
|
@@ -197,4 +151,66 @@ export function stringify(compiled: CompiledPattern, values?: any): string;
|
|
|
197
151
|
* Default options for URL pattern matching
|
|
198
152
|
* @type {UrlPatternOptions}
|
|
199
153
|
*/
|
|
200
|
-
|
|
154
|
+
declare const DEFAULT_OPTIONS: UrlPatternOptions;
|
|
155
|
+
/**
|
|
156
|
+
* Creates a compiled pattern from a string
|
|
157
|
+
* @param {string} pattern - Pattern string
|
|
158
|
+
* @param {UrlPatternOptions} [options={}] - Options
|
|
159
|
+
* @returns {CompiledPattern} Compiled pattern
|
|
160
|
+
*/
|
|
161
|
+
declare const makePattern: (pattern: string, options?: UrlPatternOptions) => CompiledPattern;
|
|
162
|
+
/**
|
|
163
|
+
* Creates a compiled pattern from a regex
|
|
164
|
+
* @param {RegExp} regex - Regex pattern
|
|
165
|
+
* @param {Array<string>} [keys=[]] - Array of key names for captured groups
|
|
166
|
+
* @returns {CompiledPattern} Compiled pattern
|
|
167
|
+
*/
|
|
168
|
+
declare const makePatternFromRegex: (regex: RegExp, keys?: Array<string>) => CompiledPattern;
|
|
169
|
+
/**
|
|
170
|
+
* Matches a string against a compiled pattern
|
|
171
|
+
* @param {CompiledPattern} compiled - Compiled pattern
|
|
172
|
+
* @param {string} str - String to match
|
|
173
|
+
* @returns {Object|null} Extracted values or null if no match
|
|
174
|
+
*/
|
|
175
|
+
declare const match: (compiled: CompiledPattern, str: string) => any | null;
|
|
176
|
+
/**
|
|
177
|
+
* Stringifies a pattern with given values
|
|
178
|
+
* @param {CompiledPattern} compiled - Compiled pattern
|
|
179
|
+
* @param {Object} [values={}] - Values to stringify
|
|
180
|
+
* @returns {string} Generated string
|
|
181
|
+
* @throws {Error} If required values are missing
|
|
182
|
+
*/
|
|
183
|
+
declare const stringify: (compiled: CompiledPattern, values?: any) => string;
|
|
184
|
+
/**
|
|
185
|
+
* UrlPattern class for matching and generating URLs
|
|
186
|
+
*/
|
|
187
|
+
declare class UrlPattern {
|
|
188
|
+
/** @type {CompiledPattern} */
|
|
189
|
+
compiled: CompiledPattern;
|
|
190
|
+
/**
|
|
191
|
+
* @param {string|RegExp} pattern - Pattern string or regex
|
|
192
|
+
* @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys (for regex)
|
|
193
|
+
*/
|
|
194
|
+
constructor(pattern: string | RegExp, options?: UrlPatternOptions | Array<string>);
|
|
195
|
+
/**
|
|
196
|
+
* Match a string against the pattern
|
|
197
|
+
* @param {string} str - String to match
|
|
198
|
+
* @returns {Object|null} Extracted values or null if no match
|
|
199
|
+
*/
|
|
200
|
+
match(str: string): any | null;
|
|
201
|
+
/**
|
|
202
|
+
* Generate a string from the pattern
|
|
203
|
+
* @param {Object} [values={}] - Values to stringify
|
|
204
|
+
* @returns {string} Generated string
|
|
205
|
+
*/
|
|
206
|
+
stringify(values?: any): string;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Creates a new UrlPattern instance (functional API)
|
|
210
|
+
* @param {string|RegExp} pattern - Pattern string or regex
|
|
211
|
+
* @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys
|
|
212
|
+
* @returns {UrlPattern} UrlPattern instance
|
|
213
|
+
*/
|
|
214
|
+
declare const urlPattern: (pattern: string | RegExp, options?: UrlPatternOptions | Array<string>) => UrlPattern;
|
|
215
|
+
export { UrlPattern, urlPattern, makePattern, makePatternFromRegex, match, stringify, DEFAULT_OPTIONS };
|
|
216
|
+
export default urlPattern;
|