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