@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/dist/url-pattern.es.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;
|
|
@@ -276,7 +364,7 @@ const compileRegex = (segments, _options) => {
|
|
|
276
364
|
const makePattern = (pattern, options = {}) => {
|
|
277
365
|
const mergedOptions = mergeOptions(options);
|
|
278
366
|
const segments = parsePattern(pattern, mergedOptions);
|
|
279
|
-
const { regex, segmentNames } = compileRegex(segments);
|
|
367
|
+
const { regex, segmentNames } = compileRegex(segments, mergedOptions);
|
|
280
368
|
|
|
281
369
|
return {
|
|
282
370
|
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
|
/**
|
|
@@ -489,4 +636,4 @@ const urlPattern = (pattern, options = {}) => {
|
|
|
489
636
|
return new UrlPattern(pattern, options);
|
|
490
637
|
};
|
|
491
638
|
|
|
492
|
-
export { DEFAULT_OPTIONS, UrlPattern,
|
|
639
|
+
export { DEFAULT_OPTIONS, UrlPattern, urlPattern as default, makePattern, makePatternFromRegex, match, stringify, urlPattern };
|
package/dist/url-pattern.umd.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(e,
|
|
1
|
+
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).urlPattern={})}(this,function(e){"use strict";const t={escapeChar:"\\",segmentNameStartChar:":",segmentNameEndChar:void 0,segmentNameCharset:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_",segmentValueCharset:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %",optionalSegmentStartChar:"(",optionalSegmentEndChar:")",wildcardChar:"*",wildcardName:"_"},n=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),r=e=>{let t=e.replace(/\\/g,"\\\\");return t=t.replace(/\]/g,"\\]"),t=t.replace(/\^/g,"\\^"),t=t.replace(/-/g,"\\-"),t},a=(e,t,n)=>{const r=[n.escapeChar,n.optionalSegmentStartChar,n.optionalSegmentEndChar,n.wildcardChar,n.segmentNameStartChar];let a=e.length;for(let n=0;n<r.length;n++){const o=r[n];if(!o)continue;const i=e.indexOf(o,t);-1!==i&&i<a&&(a=i)}return a},o=e=>null==e||""===e||(!("number"!=typeof e||!Number.isNaN(e))||!(!Array.isArray(e)||0!==e.length)),i=(e,o={})=>{const i=((e={})=>({...t,...e}))(o),s=((e,t)=>{const o=[];let i=0,s=!1,l=0,p=0;for(;i<e.length;){const d=e[i];if(d===t.escapeChar){if(i+1>=e.length)throw new Error(`Invalid pattern: '\\' at position ${i} has nothing to escape`);const t=e[i+1];if("^$.*+?()[]{}|\\".includes(t)){o.push({type:"literal",name:t,regex:n(t),optional:s,optionalGroupId:s?l:void 0}),i+=2;continue}o.push({type:"literal",name:"\\",regex:"\\\\",optional:s,optionalGroupId:s?l:void 0}),i+=1;continue}if(d===t.optionalSegmentStartChar){s=!0,l++,p++,i++;continue}if(d===t.optionalSegmentEndChar){if(0===p)throw new Error(`Invalid pattern: unmatched '${d}' at position ${i}`);s=!1,p--,i++;continue}if(d===t.wildcardChar){o.push({type:"wildcard",name:t.wildcardName,regex:".*",optional:s,optionalGroupId:s?l:void 0}),i++;continue}if(d===t.segmentNameStartChar&&i+1<e.length){const n=e.slice(i+1);let a=0;const p=t.segmentNameCharset||"",c=t.segmentNameEndChar;for(let e=0;e<n.length&&(!c||n[e]!==c)&&p.includes(n[e]);e++)a=e+1;const m=n.slice(0,a),g=!(!c||n[a]!==c);if(m.length>0){const e=t.segmentValueCharset||"",n=`([${r(e)}]+)`;o.push({type:"named",name:m,regex:n,optional:s,optionalGroupId:s?l:void 0}),i+=1+a+(g?1:0);continue}throw new Error(`Invalid pattern: '${d}' at position ${i} has no segment name`)}const c=a(e,i,t);if(c>i){const t=e.slice(i,c);o.push({type:"literal",name:t,regex:n(t),optional:s,optionalGroupId:s?l:void 0}),i=c;continue}throw new Error(`Invalid pattern: '${d}' at position ${i} has no segment name`)}if(0!==p)throw new Error(`Invalid pattern: unclosed '${t.optionalSegmentStartChar}'`);return o})(e,i),{regex:l,segmentNames:p}=((e,t)=>{let n="^",r=0;const a=[];let o=0;for(;o<e.length;){const i=e[o];if(i.optional){let s="",l=o;const p=i.optionalGroupId;for(;l<e.length&&e[l].optional&&e[l].optionalGroupId===p;){const n=e[l];"wildcard"===n.type?(s+="(.*)",a.push({name:t.wildcardName,index:r,type:"wildcard"}),r++):"named"===n.type?(s+=n.regex,a.push({name:n.name,index:r,type:"named"}),r++):s+=n.regex,l++}n+=`(?:${s})?`,o=l;continue}"wildcard"===i.type?(n+="(.*)",a.push({name:t.wildcardName,index:r,type:"wildcard"}),r++):"named"===i.type?(n+=i.regex,a.push({name:i.name,index:r,type:"named"}),r++):n+=i.regex,o++}return n+="$",{regex:n,segmentNames:a}})(s,i);return{regex:l,regexObj:new RegExp(l),segments:s,segmentNames:p,options:i,isRegex:!1,pattern:e}},s=(e,n=[])=>{const r=e.flags.replace(/[gy]/g,""),a=r===e.flags?e:new RegExp(e.source,r);return{regex:e.source,regexObj:a,segments:[],segmentNames:n.map((e,t)=>({name:e,index:t,type:"named"})),options:t,isRegex:!0,keys:n}},l=(e,t)=>{if("string"!=typeof t)throw new TypeError("pattern.match() requires a string, got "+typeof t);const n=e.regexObj.exec(t);if(!n)return null;if(e.isRegex){if(e.keys&&e.keys.length>0){const t={};return e.keys.forEach((e,r)=>{const a=n[r+1];void 0!==a&&(t[e]=a)}),t}return n.slice(1)}const r={},a=new Set,o=new Set;for(const t of e.segmentNames)"wildcard"===t.type&&o.add(t.name);for(let t=0;t<e.segmentNames.length;t++){const o=e.segmentNames[t],i=o.index+1 in n?n[o.index+1]:"",s=void 0===i?"":i;a.has(o.name)?(Array.isArray(r[o.name])||(r[o.name]=[r[o.name]]),r[o.name].push(s)):(a.add(o.name),r[o.name]=s)}for(const e in r){const t=r[e];if(Array.isArray(t)){const n=t.filter(t=>""!==t||o.has(e));0===n.length?delete r[e]:r[e]=n}else""!==t||o.has(e)||delete r[e]}return r},p=(e,t={})=>{if(e.isRegex)throw new Error("Cannot stringify a pattern created from regex");let n="",r=0;for(;r<e.segments.length;){const a=e.segments[r];if(a.optional){let i="",s=r;const l=a.optionalGroupId;for(;s<e.segments.length&&e.segments[s].optional&&e.segments[s].optionalGroupId===l;){const n=e.segments[s];if("literal"===n.type)i+=n.name;else if("named"===n.type){const e=t[n.name];if(o(e)){i="";break}i+=Array.isArray(e)?e.join("/"):e}else if("wildcard"===n.type){const n=t[e.options.wildcardName];if(o(n)){i="";break}i+=Array.isArray(n)?n.join("/"):n}s++}""!==i&&(n+=i),r===s?r++:r=s;continue}if("literal"===a.type)n+=a.name;else if("named"===a.type){const e=t[a.name];if(o(e))throw new Error(`Missing required value for segment: ${a.name}`);n+=Array.isArray(e)?e.join("/"):e}else if("wildcard"===a.type){const r=t[e.options.wildcardName];if(null==r)throw new Error("Missing required wildcard value");n+=Array.isArray(r)?r.join("/"):r}r++}return n};class d{constructor(e,t={}){if(e instanceof RegExp){const n=Array.isArray(t)?t:[];this.compiled=s(e,n)}else this.compiled=i(e,t);Object.freeze(this.compiled.options),Object.freeze(this.compiled)}match(e){return l(this.compiled,e)}stringify(e){return p(this.compiled,e)}}const c=(e,t={})=>new d(e,t);e.DEFAULT_OPTIONS=t,e.UrlPattern=d,e.default=c,e.makePattern=i,e.makePatternFromRegex=s,e.match=l,e.stringify=p,e.urlPattern=c,Object.defineProperty(e,"__esModule",{value:!0})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.naydenov/url-pattern",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Matching patterns for urls and other strings. Turn strings into data or data into strings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/main.js",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@rollup/plugin-terser": "^1.0.0",
|
|
49
|
-
"@vitest/coverage-v8": "^4.1.
|
|
50
|
-
"oxlint": "^1.
|
|
51
|
-
"rollup": "^4.
|
|
52
|
-
"typescript": "^
|
|
53
|
-
"vitest": "^4.1.
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
50
|
+
"oxlint": "^1.73.0",
|
|
51
|
+
"rollup": "^4.62.2",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
|
+
"vitest": "^4.1.10"
|
|
54
54
|
},
|
|
55
55
|
"allowScripts": {
|
|
56
56
|
"fsevents@2.3.3": true
|
package/{README.md → readme.md}
RENAMED
|
@@ -59,7 +59,7 @@ const pattern = urlPattern ( '/files/*' )
|
|
|
59
59
|
const result = pattern.match ( '/files/images/photo.jpg' )
|
|
60
60
|
|
|
61
61
|
console.log ( result )
|
|
62
|
-
// Output: {
|
|
62
|
+
// Output: { _: 'images/photo.jpg' }
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
### Generate URL from data
|
|
@@ -106,35 +106,51 @@ Creates a new pattern instance.
|
|
|
106
106
|
|
|
107
107
|
#### Options
|
|
108
108
|
|
|
109
|
-
- **escapeChar** `{string}` - Character used for escaping
|
|
109
|
+
- **escapeChar** `{string}` - Character used for escaping (default: `'\\'`). Only escapes regex metacharacters (`^$.*+?()[]{}|\`); the backslash is kept literal for any other following character.
|
|
110
110
|
- **segmentNameStartChar** `{string}` - Character that starts a named segment (default: `':'`)
|
|
111
|
-
- **segmentNameEndChar** `{string}` - Character that ends a named segment (default: `undefined`)
|
|
112
|
-
- **segmentNameCharset** `{string}` - Characters allowed in segment names (default: `'
|
|
113
|
-
- **segmentValueCharset** `{string}` - Characters allowed in segment values (default: `'
|
|
111
|
+
- **segmentNameEndChar** `{string}` - Character that ends a named segment (default: `undefined`). When set, the segment name stops at the first occurrence of this character instead of at the first character outside `segmentNameCharset`.
|
|
112
|
+
- **segmentNameCharset** `{string}` - Characters allowed in segment names (default: `'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'`). Treated as a list of explicit characters; range notation is not interpreted.
|
|
113
|
+
- **segmentValueCharset** `{string}` - Characters allowed in segment values (default: `'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %'`). Treated as a list of explicit characters; range notation is not interpreted.
|
|
114
114
|
- **optionalSegmentStartChar** `{string}` - Character that starts an optional segment (default: `'('`)
|
|
115
115
|
- **optionalSegmentEndChar** `{string}` - Character that ends an optional segment (default: `')'`)
|
|
116
|
-
- **wildcardChar** `{string}` - Character that denotes a wildcard (default: `'*'`)
|
|
116
|
+
- **wildcardChar** `{string}` - Character that denotes a wildcard in the pattern (default: `'*'`)
|
|
117
|
+
- **wildcardName** `{string}` - Key under which the wildcard value is stored in the match result (default: `'_'`). Change this to avoid colliding with a named segment that also uses `_`, or to give wildcards a more descriptive key.
|
|
117
118
|
|
|
118
119
|
### Pattern Methods
|
|
119
120
|
|
|
120
121
|
#### `pattern.match(string)`
|
|
121
122
|
|
|
122
|
-
Matches a string against the pattern and returns an object with captured values, or `null` if no match.
|
|
123
|
+
Matches a string against the pattern and returns an object with captured values, or `null` if no match. Throws a `TypeError` if the argument is not a string.
|
|
123
124
|
|
|
124
125
|
#### `pattern.stringify(data)`
|
|
125
126
|
|
|
126
127
|
Generates a URL string from provided data object.
|
|
127
128
|
|
|
128
|
-
#### `pattern.
|
|
129
|
+
#### `pattern.compiled`
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
Read-only object exposing the internal compiled state: `regex` (regex source), `regexObj` (compiled `RegExp`), `segments` (parsed segments array), `segmentNames` (segment name → capture-group mappings), `options` (merged options), `isRegex` (whether the pattern was created from a regex), and `pattern` (the original pattern string). The object is frozen — any attempt to mutate it will throw in strict mode (or fail silently elsewhere). Useful for introspection; do not mutate.
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
### Wildcard result key
|
|
136
|
+
|
|
137
|
+
Wildcards are stored under the key `_` by default. If a pattern has both a wildcard and a named segment that resolves to `_` (e.g. `/api/:_/files/*`), both values end up under the same key — the second one is appended, producing an array. To avoid this, configure the wildcard key with the `wildcardName` option:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const pattern = urlPattern('/api/:id/files/*', { wildcardName: 'rest' })
|
|
141
|
+
pattern.match('/api/42/files/a/b') // → { id: '42', rest: 'a/b' }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Regex patterns
|
|
145
|
+
|
|
146
|
+
When you pass a `RegExp` to `urlPattern`, the `g` and `y` flags are stripped. They are incompatible with this library's anchored, single-match contract: `g` would make `exec` advance `lastIndex` between calls (so the second `match()` would return `null`); `y` (sticky) requires a match starting exactly at `lastIndex`. Other flags (`i`, `m`, `s`, `d`, `u`) are preserved. The original regex object you passed in is not mutated — the library creates a fresh `RegExp` internally.
|
|
131
147
|
|
|
132
148
|
|
|
133
149
|
|
|
134
150
|
## Links
|
|
135
151
|
- [Supported URL patterns with examples](./PATTERNS.md).
|
|
136
152
|
- [History of changes](CHANGELOG.md)
|
|
137
|
-
- [TypeScript definitions](types/
|
|
153
|
+
- [TypeScript definitions](types/main.d.ts)
|
|
138
154
|
|
|
139
155
|
|
|
140
156
|
|