@peter.naydenov/url-pattern 1.0.3 → 1.0.5

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,14 +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
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
15
  * @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
16
16
  * @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
17
17
  * @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
18
18
  * @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
19
- * @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
20
21
  */
21
22
 
22
23
  /**
@@ -59,7 +60,8 @@ const DEFAULT_OPTIONS = {
59
60
  segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
60
61
  optionalSegmentStartChar: '(',
61
62
  optionalSegmentEndChar: ')',
62
- wildcardChar: '*'
63
+ wildcardChar: '*',
64
+ wildcardName: '_'
63
65
  };
64
66
 
65
67
  /**
@@ -153,10 +155,10 @@ const parsePattern = (pattern, options) => {
153
155
  }
154
156
  const nextChar = pattern[i + 1];
155
157
  // 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 = '^$\.*+?()[]{}|\\';
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 = '^$.*+?()[]{}|\\';
160
162
  if (!regexMetachars.includes(nextChar)) {
161
163
  // Not a regex metachar: treat the backslash as a literal character,
162
164
  // advance past it so the next character is processed normally.
@@ -168,6 +170,7 @@ const parsePattern = (pattern, options) => {
168
170
  optionalGroupId: inOptional ? optionalGroupId : undefined
169
171
  });
170
172
  i += 1;
173
+ continue;
171
174
  } else {
172
175
  segments.push({
173
176
  type: 'literal',
@@ -202,7 +205,7 @@ const parsePattern = (pattern, options) => {
202
205
  if (char === options.wildcardChar) {
203
206
  segments.push({
204
207
  type: 'wildcard',
205
- name: '_',
208
+ name: options.wildcardName,
206
209
  regex: '.*',
207
210
  optional: inOptional,
208
211
  optionalGroupId: inOptional ? optionalGroupId : undefined
@@ -278,7 +281,6 @@ const parsePattern = (pattern, options) => {
278
281
  return segments;
279
282
  };
280
283
 
281
- /**
282
284
  /**
283
285
  * Returns true when a value should be treated as "absent" for an optional segment.
284
286
  * @param {*} val - Value to inspect
@@ -286,6 +288,9 @@ const parsePattern = (pattern, options) => {
286
288
  */
287
289
  const isAbsentValue = (val) => {
288
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;
289
294
  if (Array.isArray(val) && val.length === 0) return true;
290
295
  return false;
291
296
  };
@@ -296,7 +301,7 @@ const isAbsentValue = (val) => {
296
301
  * @param {UrlPatternOptions} options - Options
297
302
  * @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
298
303
  */
299
- const compileRegex = (segments, _options) => {
304
+ const compileRegex = (segments, options) => {
300
305
  let regex = '^';
301
306
  let groupIndex = 0;
302
307
  /** @type {Array<SegmentName>} */
@@ -316,7 +321,7 @@ const compileRegex = (segments, _options) => {
316
321
 
317
322
  if (seg.type === 'wildcard') {
318
323
  optionalPart += '(.*)';
319
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
324
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
320
325
  groupIndex++;
321
326
  } else if (seg.type === 'named') {
322
327
  optionalPart += seg.regex;
@@ -336,7 +341,7 @@ const compileRegex = (segments, _options) => {
336
341
 
337
342
  if (segment.type === 'wildcard') {
338
343
  regex += '(.*)';
339
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
344
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
340
345
  groupIndex++;
341
346
  } else if (segment.type === 'named') {
342
347
  regex += segment.regex;
@@ -363,7 +368,7 @@ const compileRegex = (segments, _options) => {
363
368
  const makePattern = (pattern, options = {}) => {
364
369
  const mergedOptions = mergeOptions(options);
365
370
  const segments = parsePattern(pattern, mergedOptions);
366
- const { regex, segmentNames } = compileRegex(segments);
371
+ const { regex, segmentNames } = compileRegex(segments, mergedOptions);
367
372
 
368
373
  return {
369
374
  regex,
@@ -383,9 +388,20 @@ const makePattern = (pattern, options = {}) => {
383
388
  * @returns {CompiledPattern} Compiled pattern
384
389
  */
385
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
+
386
402
  return {
387
403
  regex: regex.source,
388
- regexObj: regex,
404
+ regexObj,
389
405
  segments: [],
390
406
  segmentNames: keys.map((name, index) => ({ name, index, type: 'named' })),
391
407
  options: DEFAULT_OPTIONS,
@@ -401,6 +417,14 @@ const makePatternFromRegex = (regex, keys = []) => {
401
417
  * @returns {Object|null} Extracted values or null if no match
402
418
  */
403
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
+
404
428
  const matchResult = compiled.regexObj.exec(str);
405
429
 
406
430
  if (!matchResult) {
@@ -420,6 +444,10 @@ const match = (compiled, str) => {
420
444
  });
421
445
  return result;
422
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.
423
451
  return matchResult.slice(1);
424
452
  }
425
453
 
@@ -509,9 +537,9 @@ const stringify = (compiled, values = {}) => {
509
537
  }
510
538
  optionalPart += Array.isArray(val) ? val.join('/') : val;
511
539
  } else if (seg.type === 'wildcard') {
512
- const val = values._;
513
- // Original behaviour: wildcards are skipped in optional groups (values._
514
- // is never used here). If wildcard is absent, wipe the group.
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.
515
543
  if (isAbsentValue(val)) {
516
544
  optionalPart = '';
517
545
  break;
@@ -543,7 +571,7 @@ const stringify = (compiled, values = {}) => {
543
571
  }
544
572
  result += Array.isArray(value) ? value.join('/') : value;
545
573
  } else if (segment.type === 'wildcard') {
546
- const value = values._;
574
+ const value = values[compiled.options.wildcardName];
547
575
  // Empty string is a valid wildcard match result (e.g. '/files/*' on '/files/')
548
576
  // and should be allowed without throwing.
549
577
  if (value === undefined || value === null) {
@@ -575,6 +603,12 @@ class UrlPattern {
575
603
  /** @type {CompiledPattern} */
576
604
  this.compiled = makePattern(pattern, /** @type {UrlPatternOptions} */ (options));
577
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);
578
612
  }
579
613
 
580
614
  /**
@@ -5,14 +5,15 @@
5
5
 
6
6
  /**
7
7
  * @typedef {Object} UrlPatternOptions
8
- * @property {string} [escapeChar='\\'] - Character used for escaping special characters
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
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
11
  * @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
12
12
  * @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
13
13
  * @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
14
14
  * @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
15
- * @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
16
17
  */
17
18
 
18
19
  /**
@@ -55,7 +56,8 @@ const DEFAULT_OPTIONS = {
55
56
  segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
56
57
  optionalSegmentStartChar: '(',
57
58
  optionalSegmentEndChar: ')',
58
- wildcardChar: '*'
59
+ wildcardChar: '*',
60
+ wildcardName: '_'
59
61
  };
60
62
 
61
63
  /**
@@ -149,10 +151,10 @@ const parsePattern = (pattern, options) => {
149
151
  }
150
152
  const nextChar = pattern[i + 1];
151
153
  // Only treat \ as an escape when followed by a regex metacharacter.
152
- // For all other characters the backslash is literal (e.g. '\)' in a URL
153
- // is just a backslash followed by ')'). This prevents the escape handler
154
- // from accidentally consuming the optional-group close delimiter ')'.
155
- const regexMetachars = '^$\.*+?()[]{}|\\';
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 = '^$.*+?()[]{}|\\';
156
158
  if (!regexMetachars.includes(nextChar)) {
157
159
  // Not a regex metachar: treat the backslash as a literal character,
158
160
  // advance past it so the next character is processed normally.
@@ -164,6 +166,7 @@ const parsePattern = (pattern, options) => {
164
166
  optionalGroupId: inOptional ? optionalGroupId : undefined
165
167
  });
166
168
  i += 1;
169
+ continue;
167
170
  } else {
168
171
  segments.push({
169
172
  type: 'literal',
@@ -198,7 +201,7 @@ const parsePattern = (pattern, options) => {
198
201
  if (char === options.wildcardChar) {
199
202
  segments.push({
200
203
  type: 'wildcard',
201
- name: '_',
204
+ name: options.wildcardName,
202
205
  regex: '.*',
203
206
  optional: inOptional,
204
207
  optionalGroupId: inOptional ? optionalGroupId : undefined
@@ -274,7 +277,6 @@ const parsePattern = (pattern, options) => {
274
277
  return segments;
275
278
  };
276
279
 
277
- /**
278
280
  /**
279
281
  * Returns true when a value should be treated as "absent" for an optional segment.
280
282
  * @param {*} val - Value to inspect
@@ -282,6 +284,9 @@ const parsePattern = (pattern, options) => {
282
284
  */
283
285
  const isAbsentValue = (val) => {
284
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;
285
290
  if (Array.isArray(val) && val.length === 0) return true;
286
291
  return false;
287
292
  };
@@ -292,7 +297,7 @@ const isAbsentValue = (val) => {
292
297
  * @param {UrlPatternOptions} options - Options
293
298
  * @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
294
299
  */
295
- const compileRegex = (segments, _options) => {
300
+ const compileRegex = (segments, options) => {
296
301
  let regex = '^';
297
302
  let groupIndex = 0;
298
303
  /** @type {Array<SegmentName>} */
@@ -312,7 +317,7 @@ const compileRegex = (segments, _options) => {
312
317
 
313
318
  if (seg.type === 'wildcard') {
314
319
  optionalPart += '(.*)';
315
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
320
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
316
321
  groupIndex++;
317
322
  } else if (seg.type === 'named') {
318
323
  optionalPart += seg.regex;
@@ -332,7 +337,7 @@ const compileRegex = (segments, _options) => {
332
337
 
333
338
  if (segment.type === 'wildcard') {
334
339
  regex += '(.*)';
335
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
340
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
336
341
  groupIndex++;
337
342
  } else if (segment.type === 'named') {
338
343
  regex += segment.regex;
@@ -359,7 +364,7 @@ const compileRegex = (segments, _options) => {
359
364
  const makePattern = (pattern, options = {}) => {
360
365
  const mergedOptions = mergeOptions(options);
361
366
  const segments = parsePattern(pattern, mergedOptions);
362
- const { regex, segmentNames } = compileRegex(segments);
367
+ const { regex, segmentNames } = compileRegex(segments, mergedOptions);
363
368
 
364
369
  return {
365
370
  regex,
@@ -379,9 +384,20 @@ const makePattern = (pattern, options = {}) => {
379
384
  * @returns {CompiledPattern} Compiled pattern
380
385
  */
381
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
+
382
398
  return {
383
399
  regex: regex.source,
384
- regexObj: regex,
400
+ regexObj,
385
401
  segments: [],
386
402
  segmentNames: keys.map((name, index) => ({ name, index, type: 'named' })),
387
403
  options: DEFAULT_OPTIONS,
@@ -397,6 +413,14 @@ const makePatternFromRegex = (regex, keys = []) => {
397
413
  * @returns {Object|null} Extracted values or null if no match
398
414
  */
399
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
+
400
424
  const matchResult = compiled.regexObj.exec(str);
401
425
 
402
426
  if (!matchResult) {
@@ -416,6 +440,10 @@ const match = (compiled, str) => {
416
440
  });
417
441
  return result;
418
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.
419
447
  return matchResult.slice(1);
420
448
  }
421
449
 
@@ -505,9 +533,9 @@ const stringify = (compiled, values = {}) => {
505
533
  }
506
534
  optionalPart += Array.isArray(val) ? val.join('/') : val;
507
535
  } else if (seg.type === 'wildcard') {
508
- const val = values._;
509
- // Original behaviour: wildcards are skipped in optional groups (values._
510
- // is never used here). If wildcard is absent, wipe the group.
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.
511
539
  if (isAbsentValue(val)) {
512
540
  optionalPart = '';
513
541
  break;
@@ -539,7 +567,7 @@ const stringify = (compiled, values = {}) => {
539
567
  }
540
568
  result += Array.isArray(value) ? value.join('/') : value;
541
569
  } else if (segment.type === 'wildcard') {
542
- const value = values._;
570
+ const value = values[compiled.options.wildcardName];
543
571
  // Empty string is a valid wildcard match result (e.g. '/files/*' on '/files/')
544
572
  // and should be allowed without throwing.
545
573
  if (value === undefined || value === null) {
@@ -571,6 +599,12 @@ class UrlPattern {
571
599
  /** @type {CompiledPattern} */
572
600
  this.compiled = makePattern(pattern, /** @type {UrlPatternOptions} */ (options));
573
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);
574
608
  }
575
609
 
576
610
  /**
@@ -1 +1 @@
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:"*"},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||!(!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 m=e[i];if(m===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}if(m===t.optionalSegmentStartChar){s=!0,l++,p++,i++;continue}if(m===t.optionalSegmentEndChar){if(0===p)throw new Error(`Invalid pattern: unmatched '${m}' at position ${i}`);s=!1,p--,i++;continue}if(m===t.wildcardChar){o.push({type:"wildcard",name:"_",regex:".*",optional:s,optionalGroupId:s?l:void 0}),i++;continue}if(m===t.segmentNameStartChar&&i+1<e.length){const n=e.slice(i+1);let a=0;const p=t.segmentNameCharset||"",d=t.segmentNameEndChar;for(let e=0;e<n.length&&(!d||n[e]!==d)&&p.includes(n[e]);e++)a=e+1;const c=n.slice(0,a),g=!(!d||n[a]!==d);if(c.length>0){const e=t.segmentValueCharset||"",n=`([${r(e)}]+)`;o.push({type:"named",name:c,regex:n,optional:s,optionalGroupId:s?l:void 0}),i+=1+a+(g?1:0);continue}throw new Error(`Invalid pattern: '${m}' at position ${i} has no segment name`)}const d=a(e,i,t);if(d>i){const t=e.slice(i,d);o.push({type:"literal",name:t,regex:n(t),optional:s,optionalGroupId:s?l:void 0}),i=d;continue}throw new Error(`Invalid pattern: '${m}' 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=>{let t="^",n=0;const r=[];let a=0;for(;a<e.length;){const o=e[a];if(o.optional){let i="",s=a;const l=o.optionalGroupId;for(;s<e.length&&e[s].optional&&e[s].optionalGroupId===l;){const t=e[s];"wildcard"===t.type?(i+="(.*)",r.push({name:"_",index:n,type:"wildcard"}),n++):"named"===t.type?(i+=t.regex,r.push({name:t.name,index:n,type:"named"}),n++):i+=t.regex,s++}t+=`(?:${i})?`,a=s;continue}"wildcard"===o.type?(t+="(.*)",r.push({name:"_",index:n,type:"wildcard"}),n++):"named"===o.type?(t+=o.regex,r.push({name:o.name,index:n,type:"named"}),n++):t+=o.regex,a++}return t+="$",{regex:t,segmentNames:r}})(s);return{regex:l,regexObj:new RegExp(l),segments:s,segmentNames:p,options:i,isRegex:!1,pattern:e}},s=(e,n=[])=>({regex:e.source,regexObj:e,segments:[],segmentNames:n.map((e,t)=>({name:e,index:t,type:"named"})),options:t,isRegex:!0,keys:n}),l=(e,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 e=t._;if(o(e)){i="";break}i+=Array.isArray(e)?e.join("/"):e}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 e=t._;if(null==e)throw new Error("Missing required wildcard value");n+=Array.isArray(e)?e.join("/"):e}r++}return n};class m{constructor(e,t={}){if(e instanceof RegExp){const n=Array.isArray(t)?t:[];this.compiled=s(e,n)}else this.compiled=i(e,t)}match(e){return l(this.compiled,e)}stringify(e){return p(this.compiled,e)}}const d=(e,t={})=>new m(e,t);e.DEFAULT_OPTIONS=t,e.UrlPattern=m,e.default=d,e.makePattern=i,e.makePatternFromRegex=s,e.match=l,e.stringify=p,e.urlPattern=d,Object.defineProperty(e,"__esModule",{value:!0})});
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,10 +1,10 @@
1
1
  {
2
2
  "name": "@peter.naydenov/url-pattern",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",
7
- "module": "./dist/url-pattern.es.js",
7
+ "module": "./src/main.js",
8
8
  "types": "./types/main.d.ts",
9
9
  "files": [
10
10
  "dist",
@@ -13,9 +13,9 @@
13
13
  ],
14
14
  "exports": {
15
15
  ".": {
16
- "import": "./dist/url-pattern.es.js",
17
- "require": "./dist/url-pattern.cjs.js",
18
- "default": "./dist/url-pattern.es.js"
16
+ "import": "./src/main.js",
17
+ "require": "./dist/url-pattern.cjs",
18
+ "default": "./src/main.js"
19
19
  },
20
20
  "./package.json": "./package.json",
21
21
  "./src/*": "./src/*",
package/readme.md CHANGED
@@ -106,20 +106,21 @@ Creates a new pattern instance.
106
106
 
107
107
  #### Options
108
108
 
109
- - **escapeChar** `{string}` - Character used for escaping special characters (default: `'\\'`)
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
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
112
  - **segmentNameCharset** `{string}` - Characters allowed in segment names (default: `'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'`). Treated as a list of explicit characters; range notation is not interpreted.
113
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
 
@@ -127,14 +128,29 @@ Generates a URL string from provided data object.
127
128
 
128
129
  #### `pattern.compiled`
129
130
 
130
- 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). Useful for introspection; do not mutate.
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/index.d.ts)
153
+ - [TypeScript definitions](types/main.d.ts)
138
154
 
139
155
 
140
156
 
package/src/main.js CHANGED
@@ -5,14 +5,15 @@
5
5
 
6
6
  /**
7
7
  * @typedef {Object} UrlPatternOptions
8
- * @property {string} [escapeChar='\\'] - Character used for escaping special characters
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
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
11
  * @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
12
12
  * @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
13
13
  * @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
14
14
  * @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
15
- * @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
16
17
  */
17
18
 
18
19
  /**
@@ -55,7 +56,8 @@ const DEFAULT_OPTIONS = {
55
56
  segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
56
57
  optionalSegmentStartChar: '(',
57
58
  optionalSegmentEndChar: ')',
58
- wildcardChar: '*'
59
+ wildcardChar: '*',
60
+ wildcardName: '_'
59
61
  };
60
62
 
61
63
  /**
@@ -149,10 +151,10 @@ const parsePattern = (pattern, options) => {
149
151
  }
150
152
  const nextChar = pattern[i + 1];
151
153
  // Only treat \ as an escape when followed by a regex metacharacter.
152
- // For all other characters the backslash is literal (e.g. '\)' in a URL
153
- // is just a backslash followed by ')'). This prevents the escape handler
154
- // from accidentally consuming the optional-group close delimiter ')'.
155
- const regexMetachars = '^$\.*+?()[]{}|\\';
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 = '^$.*+?()[]{}|\\';
156
158
  if (!regexMetachars.includes(nextChar)) {
157
159
  // Not a regex metachar: treat the backslash as a literal character,
158
160
  // advance past it so the next character is processed normally.
@@ -164,6 +166,7 @@ const parsePattern = (pattern, options) => {
164
166
  optionalGroupId: inOptional ? optionalGroupId : undefined
165
167
  });
166
168
  i += 1;
169
+ continue;
167
170
  } else {
168
171
  segments.push({
169
172
  type: 'literal',
@@ -198,7 +201,7 @@ const parsePattern = (pattern, options) => {
198
201
  if (char === options.wildcardChar) {
199
202
  segments.push({
200
203
  type: 'wildcard',
201
- name: '_',
204
+ name: options.wildcardName,
202
205
  regex: '.*',
203
206
  optional: inOptional,
204
207
  optionalGroupId: inOptional ? optionalGroupId : undefined
@@ -274,7 +277,6 @@ const parsePattern = (pattern, options) => {
274
277
  return segments;
275
278
  };
276
279
 
277
- /**
278
280
  /**
279
281
  * Returns true when a value should be treated as "absent" for an optional segment.
280
282
  * @param {*} val - Value to inspect
@@ -282,6 +284,9 @@ const parsePattern = (pattern, options) => {
282
284
  */
283
285
  const isAbsentValue = (val) => {
284
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;
285
290
  if (Array.isArray(val) && val.length === 0) return true;
286
291
  return false;
287
292
  };
@@ -292,7 +297,7 @@ const isAbsentValue = (val) => {
292
297
  * @param {UrlPatternOptions} options - Options
293
298
  * @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
294
299
  */
295
- const compileRegex = (segments, _options) => {
300
+ const compileRegex = (segments, options) => {
296
301
  let regex = '^';
297
302
  let groupIndex = 0;
298
303
  /** @type {Array<SegmentName>} */
@@ -312,7 +317,7 @@ const compileRegex = (segments, _options) => {
312
317
 
313
318
  if (seg.type === 'wildcard') {
314
319
  optionalPart += '(.*)';
315
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
320
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
316
321
  groupIndex++;
317
322
  } else if (seg.type === 'named') {
318
323
  optionalPart += seg.regex;
@@ -332,7 +337,7 @@ const compileRegex = (segments, _options) => {
332
337
 
333
338
  if (segment.type === 'wildcard') {
334
339
  regex += '(.*)';
335
- segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
340
+ segmentNames.push({ name: options.wildcardName, index: groupIndex, type: 'wildcard' });
336
341
  groupIndex++;
337
342
  } else if (segment.type === 'named') {
338
343
  regex += segment.regex;
@@ -379,9 +384,20 @@ const makePattern = (pattern, options = {}) => {
379
384
  * @returns {CompiledPattern} Compiled pattern
380
385
  */
381
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
+
382
398
  return {
383
399
  regex: regex.source,
384
- regexObj: regex,
400
+ regexObj,
385
401
  segments: [],
386
402
  segmentNames: keys.map((name, index) => ({ name, index, type: 'named' })),
387
403
  options: DEFAULT_OPTIONS,
@@ -397,6 +413,14 @@ const makePatternFromRegex = (regex, keys = []) => {
397
413
  * @returns {Object|null} Extracted values or null if no match
398
414
  */
399
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
+
400
424
  const matchResult = compiled.regexObj.exec(str);
401
425
 
402
426
  if (!matchResult) {
@@ -416,6 +440,10 @@ const match = (compiled, str) => {
416
440
  });
417
441
  return result;
418
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.
419
447
  return matchResult.slice(1);
420
448
  }
421
449
 
@@ -505,9 +533,9 @@ const stringify = (compiled, values = {}) => {
505
533
  }
506
534
  optionalPart += Array.isArray(val) ? val.join('/') : val;
507
535
  } else if (seg.type === 'wildcard') {
508
- const val = values._;
509
- // Original behaviour: wildcards are skipped in optional groups (values._
510
- // is never used here). If wildcard is absent, wipe the group.
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.
511
539
  if (isAbsentValue(val)) {
512
540
  optionalPart = '';
513
541
  break;
@@ -539,7 +567,7 @@ const stringify = (compiled, values = {}) => {
539
567
  }
540
568
  result += Array.isArray(value) ? value.join('/') : value;
541
569
  } else if (segment.type === 'wildcard') {
542
- const value = values._;
570
+ const value = values[compiled.options.wildcardName];
543
571
  // Empty string is a valid wildcard match result (e.g. '/files/*' on '/files/')
544
572
  // and should be allowed without throwing.
545
573
  if (value === undefined || value === null) {
@@ -571,6 +599,12 @@ class UrlPattern {
571
599
  /** @type {CompiledPattern} */
572
600
  this.compiled = makePattern(pattern, /** @type {UrlPatternOptions} */ (options));
573
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);
574
608
  }
575
609
 
576
610
  /**
package/types/main.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export type UrlPatternOptions = {
6
6
  /**
7
- * - Character used for escaping special characters
7
+ * - Character used for escaping. Only escapes regex metacharacters (`^$.*+?()[]{}|\`); for any other character the backslash is treated as a literal.
8
8
  */
9
9
  escapeChar?: string;
10
10
  /**
@@ -32,9 +32,13 @@ export type UrlPatternOptions = {
32
32
  */
33
33
  optionalSegmentEndChar?: string;
34
34
  /**
35
- * - Character that denotes a wildcard
35
+ * - Character that denotes a wildcard in the pattern
36
36
  */
37
37
  wildcardChar?: string;
38
+ /**
39
+ * - Key under which the wildcard value is stored in the match result
40
+ */
41
+ wildcardName?: string;
38
42
  };
39
43
  export type ParsedSegment = {
40
44
  /**
@@ -108,14 +112,15 @@ export type CompiledPattern = {
108
112
  };
109
113
  /**
110
114
  * @typedef {Object} UrlPatternOptions
111
- * @property {string} [escapeChar='\\'] - Character used for escaping special characters
115
+ * @property {string} [escapeChar='\\'] - Character used for escaping. Only escapes regex metacharacters (`^$.*+?()[]{}|\`); for any other character the backslash is treated as a literal.
112
116
  * @property {string} [segmentNameStartChar=':'] - Character that starts a named segment
113
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`).
114
118
  * @property {string} [segmentNameCharset='a-zA-Z0-9_'] - Characters allowed in segment names
115
119
  * @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
116
120
  * @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
117
121
  * @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
118
- * @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
119
124
  */
120
125
  /**
121
126
  * @typedef {Object} ParsedSegment