@peter.naydenov/url-pattern 1.0.2 → 1.0.3

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