@peter.naydenov/url-pattern 1.0.1 → 1.0.3
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 +163 -48
- package/dist/url-pattern.es.js +161 -48
- package/dist/url-pattern.umd.js +1 -509
- package/package.json +12 -9
- package/readme.md +20 -7
- package/src/main.js +161 -48
- package/types/main.d.ts +78 -67
- package/dist/index.d.ts +0 -131
- package/dist/index.js +0 -442
- package/dist/main.js +0 -442
- package/dist/url-pattern.umd.min.js +0 -1
- package/types/index.d.ts +0 -200
package/dist/url-pattern.cjs.js
CHANGED
|
@@ -11,7 +11,8 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
11
11
|
* @typedef {Object} UrlPatternOptions
|
|
12
12
|
* @property {string} [escapeChar='\\'] - Character used for escaping special characters
|
|
13
13
|
* @property {string} [segmentNameStartChar=':'] - Character that starts a named segment
|
|
14
|
-
* @property {string} [
|
|
14
|
+
* @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`).
|
|
15
|
+
* @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
|
|
15
16
|
* @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
|
|
16
17
|
* @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
|
|
17
18
|
* @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
|
|
@@ -23,6 +24,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
23
24
|
* @property {string} name - Segment name
|
|
24
25
|
* @property {string} type - Segment type ('named' | 'wildcard' | 'literal')
|
|
25
26
|
* @property {boolean} [optional=false] - Whether the segment is optional
|
|
27
|
+
* @property {number} [optionalGroupId] - Identifier of the optional group this segment belongs to; absent for required segments
|
|
26
28
|
* @property {string} regex - Compiled regex string
|
|
27
29
|
*/
|
|
28
30
|
|
|
@@ -52,7 +54,8 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
52
54
|
const DEFAULT_OPTIONS = {
|
|
53
55
|
escapeChar: '\\',
|
|
54
56
|
segmentNameStartChar: ':',
|
|
55
|
-
|
|
57
|
+
segmentNameEndChar: undefined,
|
|
58
|
+
segmentNameCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_',
|
|
56
59
|
segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
|
|
57
60
|
optionalSegmentStartChar: '(',
|
|
58
61
|
optionalSegmentEndChar: ')',
|
|
@@ -66,6 +69,26 @@ const DEFAULT_OPTIONS = {
|
|
|
66
69
|
*/
|
|
67
70
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Builds a safe regex character class body from a charset string.
|
|
74
|
+
*
|
|
75
|
+
* The charset is treated as a list of explicit characters — range notation
|
|
76
|
+
* (`a-z`) is NOT interpreted as a range. To match the same set of chars
|
|
77
|
+
* with range syntax, expand it (e.g. `a-z` → `abcdefghijklmnopqrstuvwxyz`).
|
|
78
|
+
*
|
|
79
|
+
* The following chars are escaped because they are special inside `[...]`:
|
|
80
|
+
* `\`, `]`, `^`, and `-`.
|
|
81
|
+
* @param {string} charset - Character class body
|
|
82
|
+
* @returns {string} Safe char class body
|
|
83
|
+
*/
|
|
84
|
+
const escapeCharClass = (charset) => {
|
|
85
|
+
let escaped = charset.replace(/\\/g, '\\\\');
|
|
86
|
+
escaped = escaped.replace(/\]/g, '\\]');
|
|
87
|
+
escaped = escaped.replace(/\^/g, '\\^');
|
|
88
|
+
escaped = escaped.replace(/-/g, '\\-');
|
|
89
|
+
return escaped;
|
|
90
|
+
};
|
|
91
|
+
|
|
69
92
|
/**
|
|
70
93
|
* Merges default options with user provided options
|
|
71
94
|
* @param {UrlPatternOptions} [userOptions={}] - User provided options
|
|
@@ -116,29 +139,62 @@ const parsePattern = (pattern, options) => {
|
|
|
116
139
|
const segments = [];
|
|
117
140
|
let i = 0;
|
|
118
141
|
let inOptional = false;
|
|
142
|
+
let optionalGroupId = 0;
|
|
143
|
+
let parenDepth = 0;
|
|
119
144
|
|
|
120
145
|
while (i < pattern.length) {
|
|
121
146
|
const char = pattern[i];
|
|
122
147
|
|
|
123
|
-
if (char === options.escapeChar
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
if (char === options.escapeChar) {
|
|
149
|
+
// Throw when the escape char is at the end of the pattern — nothing follows
|
|
150
|
+
// it to escape, and the resulting regex would be broken.
|
|
151
|
+
if (i + 1 >= pattern.length) {
|
|
152
|
+
throw new Error(`Invalid pattern: '\\' at position ${i} has nothing to escape`);
|
|
153
|
+
}
|
|
154
|
+
const nextChar = pattern[i + 1];
|
|
155
|
+
// Only treat \ as an escape when followed by a regex metacharacter.
|
|
156
|
+
// For all other characters the backslash is literal (e.g. '\)' in a URL
|
|
157
|
+
// is just a backslash followed by ')'). This prevents the escape handler
|
|
158
|
+
// from accidentally consuming the optional-group close delimiter ')'.
|
|
159
|
+
const regexMetachars = '^$\.*+?()[]{}|\\';
|
|
160
|
+
if (!regexMetachars.includes(nextChar)) {
|
|
161
|
+
// Not a regex metachar: treat the backslash as a literal character,
|
|
162
|
+
// advance past it so the next character is processed normally.
|
|
163
|
+
segments.push({
|
|
164
|
+
type: 'literal',
|
|
165
|
+
name: '\\',
|
|
166
|
+
regex: '\\\\',
|
|
167
|
+
optional: inOptional,
|
|
168
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
169
|
+
});
|
|
170
|
+
i += 1;
|
|
171
|
+
} else {
|
|
172
|
+
segments.push({
|
|
173
|
+
type: 'literal',
|
|
174
|
+
name: nextChar,
|
|
175
|
+
regex: escapeRegex(nextChar),
|
|
176
|
+
optional: inOptional,
|
|
177
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
178
|
+
});
|
|
179
|
+
i += 2;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
132
182
|
}
|
|
133
183
|
|
|
134
184
|
if (char === options.optionalSegmentStartChar) {
|
|
135
185
|
inOptional = true;
|
|
186
|
+
optionalGroupId++;
|
|
187
|
+
parenDepth++;
|
|
136
188
|
i++;
|
|
137
189
|
continue;
|
|
138
190
|
}
|
|
139
191
|
|
|
140
192
|
if (char === options.optionalSegmentEndChar) {
|
|
193
|
+
if (parenDepth === 0) {
|
|
194
|
+
throw new Error(`Invalid pattern: unmatched '${char}' at position ${i}`);
|
|
195
|
+
}
|
|
141
196
|
inOptional = false;
|
|
197
|
+
parenDepth--;
|
|
142
198
|
i++;
|
|
143
199
|
continue;
|
|
144
200
|
}
|
|
@@ -148,7 +204,8 @@ const parsePattern = (pattern, options) => {
|
|
|
148
204
|
type: 'wildcard',
|
|
149
205
|
name: '_',
|
|
150
206
|
regex: '.*',
|
|
151
|
-
optional: inOptional
|
|
207
|
+
optional: inOptional,
|
|
208
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
152
209
|
});
|
|
153
210
|
i++;
|
|
154
211
|
continue;
|
|
@@ -158,8 +215,12 @@ const parsePattern = (pattern, options) => {
|
|
|
158
215
|
const remaining = pattern.slice(i + 1);
|
|
159
216
|
let nameEnd = 0;
|
|
160
217
|
const charset = options.segmentNameCharset || '';
|
|
161
|
-
|
|
218
|
+
const endChar = options.segmentNameEndChar;
|
|
219
|
+
|
|
162
220
|
for (let j = 0; j < remaining.length; j++) {
|
|
221
|
+
if (endChar && remaining[j] === endChar) {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
163
224
|
if (!charset.includes(remaining[j])) {
|
|
164
225
|
break;
|
|
165
226
|
}
|
|
@@ -167,54 +228,75 @@ const parsePattern = (pattern, options) => {
|
|
|
167
228
|
}
|
|
168
229
|
|
|
169
230
|
const name = remaining.slice(0, nameEnd);
|
|
170
|
-
|
|
231
|
+
const consumeEndChar = !!(endChar && remaining[nameEnd] === endChar);
|
|
232
|
+
|
|
171
233
|
if (name.length > 0) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
valueCharset = valueCharset.replace(/-/g, '');
|
|
175
|
-
valueCharset += '-';
|
|
176
|
-
}
|
|
177
|
-
const escapedValueCharset = valueCharset.replace(/\]/g, '\\]');
|
|
234
|
+
const valueCharset = options.segmentValueCharset || '';
|
|
235
|
+
const escapedValueCharset = escapeCharClass(valueCharset);
|
|
178
236
|
const valueRegex = `([${escapedValueCharset}]+)`;
|
|
179
|
-
|
|
237
|
+
|
|
180
238
|
segments.push({
|
|
181
239
|
type: 'named',
|
|
182
240
|
name,
|
|
183
241
|
regex: valueRegex,
|
|
184
|
-
optional: inOptional
|
|
242
|
+
optional: inOptional,
|
|
243
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
185
244
|
});
|
|
186
|
-
i += 1 + nameEnd;
|
|
245
|
+
i += 1 + nameEnd + (consumeEndChar ? 1 : 0);
|
|
187
246
|
continue;
|
|
188
247
|
}
|
|
248
|
+
|
|
249
|
+
// segmentNameStartChar at end of pattern, or followed by a non-charset
|
|
250
|
+
// character that is also a special char (e.g. ':)').
|
|
251
|
+
throw new Error(`Invalid pattern: '${char}' at position ${i} has no segment name`);
|
|
189
252
|
}
|
|
190
253
|
|
|
191
254
|
const literalEnd = findNextSpecialChar(pattern, i, options);
|
|
192
|
-
|
|
255
|
+
|
|
193
256
|
if (literalEnd > i) {
|
|
194
257
|
const literal = pattern.slice(i, literalEnd);
|
|
195
258
|
segments.push({
|
|
196
259
|
type: 'literal',
|
|
197
260
|
name: literal,
|
|
198
261
|
regex: escapeRegex(literal),
|
|
199
|
-
optional: inOptional
|
|
262
|
+
optional: inOptional,
|
|
263
|
+
optionalGroupId: inOptional ? optionalGroupId : undefined
|
|
200
264
|
});
|
|
201
265
|
i = literalEnd;
|
|
202
266
|
continue;
|
|
203
267
|
}
|
|
204
268
|
|
|
205
|
-
i
|
|
269
|
+
// literalEnd === i: current char itself is a special char with no
|
|
270
|
+
// name/value following. Throw for clarity.
|
|
271
|
+
throw new Error(`Invalid pattern: '${char}' at position ${i} has no segment name`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (parenDepth !== 0) {
|
|
275
|
+
throw new Error(`Invalid pattern: unclosed '${options.optionalSegmentStartChar}'`);
|
|
206
276
|
}
|
|
207
277
|
|
|
208
278
|
return segments;
|
|
209
279
|
};
|
|
210
280
|
|
|
281
|
+
/**
|
|
282
|
+
/**
|
|
283
|
+
* Returns true when a value should be treated as "absent" for an optional segment.
|
|
284
|
+
* @param {*} val - Value to inspect
|
|
285
|
+
* @returns {boolean}
|
|
286
|
+
*/
|
|
287
|
+
const isAbsentValue = (val) => {
|
|
288
|
+
if (val === undefined || val === null || val === '') return true;
|
|
289
|
+
if (Array.isArray(val) && val.length === 0) return true;
|
|
290
|
+
return false;
|
|
291
|
+
};
|
|
292
|
+
|
|
211
293
|
/**
|
|
212
294
|
* Compiles segments into a regex pattern
|
|
213
295
|
* @param {Array<ParsedSegment>} segments - Parsed segments
|
|
214
296
|
* @param {UrlPatternOptions} options - Options
|
|
215
297
|
* @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
|
|
216
298
|
*/
|
|
217
|
-
const compileRegex = (segments,
|
|
299
|
+
const compileRegex = (segments, _options) => {
|
|
218
300
|
let regex = '^';
|
|
219
301
|
let groupIndex = 0;
|
|
220
302
|
/** @type {Array<SegmentName>} */
|
|
@@ -227,8 +309,9 @@ const compileRegex = (segments, options) => {
|
|
|
227
309
|
if (segment.optional) {
|
|
228
310
|
let optionalPart = '';
|
|
229
311
|
let j = i;
|
|
230
|
-
|
|
231
|
-
|
|
312
|
+
const currentGroupId = segment.optionalGroupId;
|
|
313
|
+
|
|
314
|
+
while (j < segments.length && segments[j].optional && segments[j].optionalGroupId === currentGroupId) {
|
|
232
315
|
const seg = segments[j];
|
|
233
316
|
|
|
234
317
|
if (seg.type === 'wildcard') {
|
|
@@ -329,7 +412,11 @@ const match = (compiled, str) => {
|
|
|
329
412
|
const result = {};
|
|
330
413
|
compiled.keys.forEach((key, index) => {
|
|
331
414
|
const val = matchResult[index + 1];
|
|
332
|
-
|
|
415
|
+
// Undefined means the optional group didn't participate — omit the key
|
|
416
|
+
// so this is consistent with string-pattern behaviour.
|
|
417
|
+
if (val !== undefined) {
|
|
418
|
+
result[key] = val;
|
|
419
|
+
}
|
|
333
420
|
});
|
|
334
421
|
return result;
|
|
335
422
|
}
|
|
@@ -339,10 +426,22 @@ const match = (compiled, str) => {
|
|
|
339
426
|
const result = {};
|
|
340
427
|
const usedNames = new Set();
|
|
341
428
|
|
|
429
|
+
// Track which segment names are wildcards so their empty-string captures survive
|
|
430
|
+
// the cleanup loop. An empty wildcard result (e.g. '/files/*' on '/files/')
|
|
431
|
+
// is a valid match result.
|
|
432
|
+
const wildcardNames = new Set();
|
|
433
|
+
for (const seg of compiled.segmentNames) {
|
|
434
|
+
if (seg.type === 'wildcard') wildcardNames.add(seg.name);
|
|
435
|
+
}
|
|
436
|
+
|
|
342
437
|
for (let i = 0; i < compiled.segmentNames.length; i++) {
|
|
343
438
|
const segInfo = compiled.segmentNames[i];
|
|
344
|
-
const
|
|
345
|
-
|
|
439
|
+
const hasCapture = segInfo.index + 1 in matchResult;
|
|
440
|
+
// When an optional group didn't participate, the regex returns undefined.
|
|
441
|
+
// Normalize to '' so both non-isRegex and isRegex paths behave the same.
|
|
442
|
+
const rawValue = hasCapture ? matchResult[segInfo.index + 1] : '';
|
|
443
|
+
const value = rawValue === undefined ? '' : rawValue;
|
|
444
|
+
|
|
346
445
|
if (usedNames.has(segInfo.name)) {
|
|
347
446
|
if (!Array.isArray(result[segInfo.name])) {
|
|
348
447
|
result[segInfo.name] = [result[segInfo.name]];
|
|
@@ -354,8 +453,19 @@ const match = (compiled, str) => {
|
|
|
354
453
|
}
|
|
355
454
|
}
|
|
356
455
|
|
|
456
|
+
// Delete empty-string entries, but preserve wildcards — an empty capture is
|
|
457
|
+
// a valid match result for wildcards. Also clean null/undefined from arrays.
|
|
357
458
|
for (const key in result) {
|
|
358
|
-
|
|
459
|
+
const val = result[key];
|
|
460
|
+
if (Array.isArray(val)) {
|
|
461
|
+
// Filter out empty-string entries from arrays (but preserve wildcards).
|
|
462
|
+
const filtered = val.filter(v => v !== '' || wildcardNames.has(key));
|
|
463
|
+
if (filtered.length === 0) {
|
|
464
|
+
delete result[key];
|
|
465
|
+
} else {
|
|
466
|
+
result[key] = filtered;
|
|
467
|
+
}
|
|
468
|
+
} else if (val === '' && !wildcardNames.has(key)) {
|
|
359
469
|
delete result[key];
|
|
360
470
|
}
|
|
361
471
|
}
|
|
@@ -384,37 +494,38 @@ const stringify = (compiled, values = {}) => {
|
|
|
384
494
|
if (segment.optional) {
|
|
385
495
|
let optionalPart = '';
|
|
386
496
|
let j = i;
|
|
387
|
-
|
|
388
|
-
|
|
497
|
+
const currentGroupId = segment.optionalGroupId;
|
|
498
|
+
|
|
499
|
+
while (j < compiled.segments.length && compiled.segments[j].optional && compiled.segments[j].optionalGroupId === currentGroupId) {
|
|
389
500
|
const seg = compiled.segments[j];
|
|
390
|
-
|
|
501
|
+
|
|
391
502
|
if (seg.type === 'literal') {
|
|
392
503
|
optionalPart += seg.name;
|
|
393
504
|
} else if (seg.type === 'named') {
|
|
394
505
|
const val = values[seg.name];
|
|
395
|
-
if (val
|
|
396
|
-
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
397
|
-
} else {
|
|
506
|
+
if (isAbsentValue(val)) {
|
|
398
507
|
optionalPart = '';
|
|
399
508
|
break;
|
|
400
509
|
}
|
|
510
|
+
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
401
511
|
} else if (seg.type === 'wildcard') {
|
|
402
512
|
const val = values._;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
513
|
+
// Original behaviour: wildcards are skipped in optional groups (values._
|
|
514
|
+
// is never used here). If wildcard is absent, wipe the group.
|
|
515
|
+
if (isAbsentValue(val)) {
|
|
406
516
|
optionalPart = '';
|
|
407
517
|
break;
|
|
408
518
|
}
|
|
519
|
+
optionalPart += Array.isArray(val) ? val.join('/') : val;
|
|
409
520
|
}
|
|
410
|
-
|
|
521
|
+
|
|
411
522
|
j++;
|
|
412
523
|
}
|
|
413
|
-
|
|
524
|
+
|
|
414
525
|
if (optionalPart !== '') {
|
|
415
526
|
result += optionalPart;
|
|
416
527
|
}
|
|
417
|
-
|
|
528
|
+
|
|
418
529
|
if (i === j) {
|
|
419
530
|
i++;
|
|
420
531
|
} else {
|
|
@@ -427,13 +538,15 @@ const stringify = (compiled, values = {}) => {
|
|
|
427
538
|
result += segment.name;
|
|
428
539
|
} else if (segment.type === 'named') {
|
|
429
540
|
const value = values[segment.name];
|
|
430
|
-
if (value
|
|
541
|
+
if (isAbsentValue(value)) {
|
|
431
542
|
throw new Error(`Missing required value for segment: ${segment.name}`);
|
|
432
543
|
}
|
|
433
544
|
result += Array.isArray(value) ? value.join('/') : value;
|
|
434
545
|
} else if (segment.type === 'wildcard') {
|
|
435
546
|
const value = values._;
|
|
436
|
-
|
|
547
|
+
// Empty string is a valid wildcard match result (e.g. '/files/*' on '/files/')
|
|
548
|
+
// and should be allowed without throwing.
|
|
549
|
+
if (value === undefined || value === null) {
|
|
437
550
|
throw new Error('Missing required wildcard value');
|
|
438
551
|
}
|
|
439
552
|
result += Array.isArray(value) ? value.join('/') : value;
|
|
@@ -495,9 +608,11 @@ const urlPattern = (pattern, options = {}) => {
|
|
|
495
608
|
|
|
496
609
|
exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
|
|
497
610
|
exports.UrlPattern = UrlPattern;
|
|
498
|
-
exports.default =
|
|
611
|
+
exports.default = urlPattern;
|
|
499
612
|
exports.makePattern = makePattern;
|
|
500
613
|
exports.makePatternFromRegex = makePatternFromRegex;
|
|
501
614
|
exports.match = match;
|
|
502
615
|
exports.stringify = stringify;
|
|
503
616
|
exports.urlPattern = urlPattern;
|
|
617
|
+
|
|
618
|
+
module.exports = Object.assign(module.exports.default, module.exports);
|