@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.
@@ -5,13 +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
- * @property {string} [segmentNameCharset='a-zA-Z0-9'] - Characters allowed in segment names
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
- segmentNameCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
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 && i + 1 < pattern.length) {
120
- segments.push({
121
- type: 'literal',
122
- name: pattern[i + 1],
123
- regex: escapeRegex(pattern[i + 1]),
124
- optional: inOptional
125
- });
126
- i += 2;
127
- continue;
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
- let valueCharset = options.segmentValueCharset || '';
169
- if (valueCharset.includes('-') && valueCharset.indexOf('-') > 0 && valueCharset.indexOf('-') < valueCharset.length - 1) {
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, _options) => {
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
- while (j < segments.length && segments[j].optional) {
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: '_', index: groupIndex, type: 'wildcard' });
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: '_', index: groupIndex, type: 'wildcard' });
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: regex,
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
- result[key] = val !== undefined ? val : null;
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 value = matchResult[segInfo.index + 1] || '';
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
- if (result[key] === '') {
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
- while (j < compiled.segments.length && compiled.segments[j].optional) {
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 !== undefined && val !== null && 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
- if (val !== undefined && val !== null && val !== '') {
400
- optionalPart += Array.isArray(val) ? val.join('/') : val;
401
- } else {
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 === undefined || value === null || 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
- if (value === undefined || value === null || value === '') {
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, UrlPattern as default, makePattern, makePatternFromRegex, match, stringify, urlPattern };
639
+ export { DEFAULT_OPTIONS, UrlPattern, urlPattern as default, makePattern, makePatternFromRegex, match, stringify, urlPattern };
@@ -1 +1 @@
1
- !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).urlPattern={})}(this,function(e){"use strict";const n={escapeChar:"\\",segmentNameStartChar:":",segmentNameCharset:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",segmentValueCharset:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %",optionalSegmentStartChar:"(",optionalSegmentEndChar:")",wildcardChar:"*"},t=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),r=(e,n,t)=>{const r=[t.escapeChar,t.optionalSegmentStartChar,t.optionalSegmentEndChar,t.wildcardChar,t.segmentNameStartChar];let a=e.length;for(let t=0;t<r.length;t++){const s=r[t];if(!s)continue;const i=e.indexOf(s,n);-1!==i&&i<a&&(a=i)}return a},a=(e,a={})=>{const s=((e={})=>({...n,...e}))(a),i=((e,n)=>{const a=[];let s=0,i=!1;for(;s<e.length;){const o=e[s];if(o===n.escapeChar&&s+1<e.length){a.push({type:"literal",name:e[s+1],regex:t(e[s+1]),optional:i}),s+=2;continue}if(o===n.optionalSegmentStartChar){i=!0,s++;continue}if(o===n.optionalSegmentEndChar){i=!1,s++;continue}if(o===n.wildcardChar){a.push({type:"wildcard",name:"_",regex:".*",optional:i}),s++;continue}if(o===n.segmentNameStartChar&&s+1<e.length){const t=e.slice(s+1);let r=0;const o=n.segmentNameCharset||"";for(let e=0;e<t.length&&o.includes(t[e]);e++)r=e+1;const l=t.slice(0,r);if(l.length>0){let e=n.segmentValueCharset||"";e.includes("-")&&e.indexOf("-")>0&&e.indexOf("-")<e.length-1&&(e=e.replace(/-/g,""),e+="-");const t=`([${e.replace(/\]/g,"\\]")}]+)`;a.push({type:"named",name:l,regex:t,optional:i}),s+=1+r;continue}}const l=r(e,s,n);if(l>s){const n=e.slice(s,l);a.push({type:"literal",name:n,regex:t(n),optional:i}),s=l;continue}s++}return a})(e,s),{regex:o,segmentNames:l}=(e=>{let n="^",t=0;const r=[];let a=0;for(;a<e.length;){const s=e[a];if(s.optional){let s="",i=a;for(;i<e.length&&e[i].optional;){const n=e[i];"wildcard"===n.type?(s+="(.*)",r.push({name:"_",index:t,type:"wildcard"}),t++):"named"===n.type?(s+=n.regex,r.push({name:n.name,index:t,type:"named"}),t++):s+=n.regex,i++}n+=`(?:${s})?`,a=i;continue}"wildcard"===s.type?(n+="(.*)",r.push({name:"_",index:t,type:"wildcard"}),t++):"named"===s.type?(n+=s.regex,r.push({name:s.name,index:t,type:"named"}),t++):n+=s.regex,a++}return n+="$",{regex:n,segmentNames:r}})(i);return{regex:o,regexObj:new RegExp(o),segments:i,segmentNames:l,options:s,isRegex:!1,pattern:e}},s=(e,t=[])=>({regex:e.source,regexObj:e,segments:[],segmentNames:t.map((e,n)=>({name:e,index:n,type:"named"})),options:n,isRegex:!0,keys:t}),i=(e,n)=>{const t=e.regexObj.exec(n);if(!t)return null;if(e.isRegex){if(e.keys&&e.keys.length>0){const n={};return e.keys.forEach((e,r)=>{const a=t[r+1];n[e]=void 0!==a?a:null}),n}return t.slice(1)}const r={},a=new Set;for(let n=0;n<e.segmentNames.length;n++){const s=e.segmentNames[n],i=t[s.index+1]||"";a.has(s.name)?(Array.isArray(r[s.name])||(r[s.name]=[r[s.name]]),r[s.name].push(i)):(a.add(s.name),r[s.name]=i)}for(const e in r)""===r[e]&&delete r[e];return r},o=(e,n={})=>{if(e.isRegex)throw new Error("Cannot stringify a pattern created from regex");let t="",r=0;for(;r<e.segments.length;){const a=e.segments[r];if(a.optional){let a="",s=r;for(;s<e.segments.length&&e.segments[s].optional;){const t=e.segments[s];if("literal"===t.type)a+=t.name;else if("named"===t.type){const e=n[t.name];if(null==e||""===e){a="";break}a+=Array.isArray(e)?e.join("/"):e}else if("wildcard"===t.type){const e=n._;if(null==e||""===e){a="";break}a+=Array.isArray(e)?e.join("/"):e}s++}""!==a&&(t+=a),r===s?r++:r=s;continue}if("literal"===a.type)t+=a.name;else if("named"===a.type){const e=n[a.name];if(null==e||""===e)throw new Error(`Missing required value for segment: ${a.name}`);t+=Array.isArray(e)?e.join("/"):e}else if("wildcard"===a.type){const e=n._;if(null==e||""===e)throw new Error("Missing required wildcard value");t+=Array.isArray(e)?e.join("/"):e}r++}return t};class l{constructor(e,n={}){if(e instanceof RegExp){const t=Array.isArray(n)?n:[];this.compiled=s(e,t)}else this.compiled=a(e,n)}match(e){return i(this.compiled,e)}stringify(e){return o(this.compiled,e)}}e.DEFAULT_OPTIONS=n,e.UrlPattern=l,e.default=l,e.makePattern=a,e.makePatternFromRegex=s,e.match=i,e.stringify=o,e.urlPattern=(e,n={})=>new l(e,n),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,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.naydenov/url-pattern",
3
- "version": "1.0.2",
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.7",
50
- "oxlint": "^1.68.0",
51
- "rollup": "^4.61.1",
52
- "typescript": "^6.0.3",
53
- "vitest": "^4.1.7"
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
@@ -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: { '*': 'images/photo.jpg' }
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 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
- - **segmentNameEndChar** `{string}` - Character that ends a named segment (default: `undefined`)
112
- - **segmentNameCharset** `{string}` - Characters allowed in segment names (default: `'a-zA-Z0-9'`)
113
- - **segmentValueCharset** `{string}` - Characters allowed in segment values (default: `'a-zA-Z0-9-_~ %'`)
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.compile()`
129
+ #### `pattern.compiled`
129
130
 
130
- Returns an object with `regex` (compiled RegExp), `segments` (parsed segments array), and `segmentNames` (segment name mappings).
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