@peter.naydenov/url-pattern 1.0.0 → 1.0.1

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.
@@ -0,0 +1,503 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ /**
6
+ * @fileoverview URL pattern matching library
7
+ * @module url-pattern
8
+ */
9
+
10
+ /**
11
+ * @typedef {Object} UrlPatternOptions
12
+ * @property {string} [escapeChar='\\'] - Character used for escaping special characters
13
+ * @property {string} [segmentNameStartChar=':'] - Character that starts a named segment
14
+ * @property {string} [segmentNameCharset='a-zA-Z0-9'] - Characters allowed in segment names
15
+ * @property {string} [segmentValueCharset='a-zA-Z0-9-_~ %'] - Characters allowed in segment values
16
+ * @property {string} [optionalSegmentStartChar='('] - Character that starts an optional segment
17
+ * @property {string} [optionalSegmentEndChar=')'] - Character that ends an optional segment
18
+ * @property {string} [wildcardChar='*'] - Character that denotes a wildcard
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} ParsedSegment
23
+ * @property {string} name - Segment name
24
+ * @property {string} type - Segment type ('named' | 'wildcard' | 'literal')
25
+ * @property {boolean} [optional=false] - Whether the segment is optional
26
+ * @property {string} regex - Compiled regex string
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} SegmentName
31
+ * @property {string} name - Segment name
32
+ * @property {number} index - Capture group index
33
+ * @property {string} type - Segment type ('named' | 'wildcard')
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} CompiledPattern
38
+ * @property {string} regex - Compiled regex string
39
+ * @property {RegExp} regexObj - Compiled regex object
40
+ * @property {Array<ParsedSegment>} segments - Parsed segments
41
+ * @property {Array<SegmentName>} segmentNames - Segment name mappings
42
+ * @property {UrlPatternOptions} options - Options used
43
+ * @property {boolean} isRegex - Whether pattern was created from regex
44
+ * @property {string} [pattern] - Original pattern string
45
+ * @property {Array<string>} [keys] - Keys for regex patterns
46
+ */
47
+
48
+ /**
49
+ * Default options for URL pattern matching
50
+ * @type {UrlPatternOptions}
51
+ */
52
+ const DEFAULT_OPTIONS = {
53
+ escapeChar: '\\',
54
+ segmentNameStartChar: ':',
55
+ segmentNameCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
56
+ segmentValueCharset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~ %',
57
+ optionalSegmentStartChar: '(',
58
+ optionalSegmentEndChar: ')',
59
+ wildcardChar: '*'
60
+ };
61
+
62
+ /**
63
+ * Escapes special regex characters in a string
64
+ * @param {string} str - String to escape
65
+ * @returns {string} Escaped string
66
+ */
67
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+
69
+ /**
70
+ * Merges default options with user provided options
71
+ * @param {UrlPatternOptions} [userOptions={}] - User provided options
72
+ * @returns {UrlPatternOptions} Merged options
73
+ */
74
+ const mergeOptions = (userOptions = {}) => ({
75
+ ...DEFAULT_OPTIONS,
76
+ ...userOptions
77
+ });
78
+
79
+ /**
80
+ * Finds the position of the next special character in the pattern
81
+ * @param {string} pattern - Pattern string
82
+ * @param {number} start - Starting position
83
+ * @param {UrlPatternOptions} options - Parsing options
84
+ * @returns {number} Position of next special character
85
+ */
86
+ const findNextSpecialChar = (pattern, start, options) => {
87
+ const chars = [
88
+ options.escapeChar,
89
+ options.optionalSegmentStartChar,
90
+ options.optionalSegmentEndChar,
91
+ options.wildcardChar,
92
+ options.segmentNameStartChar
93
+ ];
94
+
95
+ let minPos = pattern.length;
96
+
97
+ for (let i = 0; i < chars.length; i++) {
98
+ const char = chars[i];
99
+ if (!char) continue;
100
+ const pos = pattern.indexOf(char, start);
101
+ if (pos !== -1 && pos < minPos) {
102
+ minPos = pos;
103
+ }
104
+ }
105
+
106
+ return minPos;
107
+ };
108
+
109
+ /**
110
+ * Parses a pattern string into segments
111
+ * @param {string} pattern - Pattern string to parse
112
+ * @param {UrlPatternOptions} options - Parsing options
113
+ * @returns {Array<ParsedSegment>} Parsed segments
114
+ */
115
+ const parsePattern = (pattern, options) => {
116
+ const segments = [];
117
+ let i = 0;
118
+ let inOptional = false;
119
+
120
+ while (i < pattern.length) {
121
+ const char = pattern[i];
122
+
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;
132
+ }
133
+
134
+ if (char === options.optionalSegmentStartChar) {
135
+ inOptional = true;
136
+ i++;
137
+ continue;
138
+ }
139
+
140
+ if (char === options.optionalSegmentEndChar) {
141
+ inOptional = false;
142
+ i++;
143
+ continue;
144
+ }
145
+
146
+ if (char === options.wildcardChar) {
147
+ segments.push({
148
+ type: 'wildcard',
149
+ name: '_',
150
+ regex: '.*',
151
+ optional: inOptional
152
+ });
153
+ i++;
154
+ continue;
155
+ }
156
+
157
+ if (char === options.segmentNameStartChar && i + 1 < pattern.length) {
158
+ const remaining = pattern.slice(i + 1);
159
+ let nameEnd = 0;
160
+ const charset = options.segmentNameCharset || '';
161
+
162
+ for (let j = 0; j < remaining.length; j++) {
163
+ if (!charset.includes(remaining[j])) {
164
+ break;
165
+ }
166
+ nameEnd = j + 1;
167
+ }
168
+
169
+ const name = remaining.slice(0, nameEnd);
170
+
171
+ 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, '\\]');
178
+ const valueRegex = `([${escapedValueCharset}]+)`;
179
+
180
+ segments.push({
181
+ type: 'named',
182
+ name,
183
+ regex: valueRegex,
184
+ optional: inOptional
185
+ });
186
+ i += 1 + nameEnd;
187
+ continue;
188
+ }
189
+ }
190
+
191
+ const literalEnd = findNextSpecialChar(pattern, i, options);
192
+
193
+ if (literalEnd > i) {
194
+ const literal = pattern.slice(i, literalEnd);
195
+ segments.push({
196
+ type: 'literal',
197
+ name: literal,
198
+ regex: escapeRegex(literal),
199
+ optional: inOptional
200
+ });
201
+ i = literalEnd;
202
+ continue;
203
+ }
204
+
205
+ i++;
206
+ }
207
+
208
+ return segments;
209
+ };
210
+
211
+ /**
212
+ * Compiles segments into a regex pattern
213
+ * @param {Array<ParsedSegment>} segments - Parsed segments
214
+ * @param {UrlPatternOptions} options - Options
215
+ * @returns {{regex: string, segmentNames: Array<SegmentName>}} Compiled regex and segment names
216
+ */
217
+ const compileRegex = (segments, options) => {
218
+ let regex = '^';
219
+ let groupIndex = 0;
220
+ /** @type {Array<SegmentName>} */
221
+ const segmentNames = [];
222
+ let i = 0;
223
+
224
+ while (i < segments.length) {
225
+ const segment = segments[i];
226
+
227
+ if (segment.optional) {
228
+ let optionalPart = '';
229
+ let j = i;
230
+
231
+ while (j < segments.length && segments[j].optional) {
232
+ const seg = segments[j];
233
+
234
+ if (seg.type === 'wildcard') {
235
+ optionalPart += '(.*)';
236
+ segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
237
+ groupIndex++;
238
+ } else if (seg.type === 'named') {
239
+ optionalPart += seg.regex;
240
+ segmentNames.push({ name: seg.name, index: groupIndex, type: 'named' });
241
+ groupIndex++;
242
+ } else {
243
+ optionalPart += seg.regex;
244
+ }
245
+
246
+ j++;
247
+ }
248
+
249
+ regex += `(?:${optionalPart})?`;
250
+ i = j;
251
+ continue;
252
+ }
253
+
254
+ if (segment.type === 'wildcard') {
255
+ regex += '(.*)';
256
+ segmentNames.push({ name: '_', index: groupIndex, type: 'wildcard' });
257
+ groupIndex++;
258
+ } else if (segment.type === 'named') {
259
+ regex += segment.regex;
260
+ segmentNames.push({ name: segment.name, index: groupIndex, type: 'named' });
261
+ groupIndex++;
262
+ } else {
263
+ regex += segment.regex;
264
+ }
265
+
266
+ i++;
267
+ }
268
+
269
+ regex += '$';
270
+
271
+ return { regex, segmentNames };
272
+ };
273
+
274
+ /**
275
+ * Creates a compiled pattern from a string
276
+ * @param {string} pattern - Pattern string
277
+ * @param {UrlPatternOptions} [options={}] - Options
278
+ * @returns {CompiledPattern} Compiled pattern
279
+ */
280
+ const makePattern = (pattern, options = {}) => {
281
+ const mergedOptions = mergeOptions(options);
282
+ const segments = parsePattern(pattern, mergedOptions);
283
+ const { regex, segmentNames } = compileRegex(segments);
284
+
285
+ return {
286
+ regex,
287
+ regexObj: new RegExp(regex),
288
+ segments,
289
+ segmentNames,
290
+ options: mergedOptions,
291
+ isRegex: false,
292
+ pattern
293
+ };
294
+ };
295
+
296
+ /**
297
+ * Creates a compiled pattern from a regex
298
+ * @param {RegExp} regex - Regex pattern
299
+ * @param {Array<string>} [keys=[]] - Array of key names for captured groups
300
+ * @returns {CompiledPattern} Compiled pattern
301
+ */
302
+ const makePatternFromRegex = (regex, keys = []) => {
303
+ return {
304
+ regex: regex.source,
305
+ regexObj: regex,
306
+ segments: [],
307
+ segmentNames: keys.map((name, index) => ({ name, index, type: 'named' })),
308
+ options: DEFAULT_OPTIONS,
309
+ isRegex: true,
310
+ keys
311
+ };
312
+ };
313
+
314
+ /**
315
+ * Matches a string against a compiled pattern
316
+ * @param {CompiledPattern} compiled - Compiled pattern
317
+ * @param {string} str - String to match
318
+ * @returns {Object|null} Extracted values or null if no match
319
+ */
320
+ const match = (compiled, str) => {
321
+ const matchResult = compiled.regexObj.exec(str);
322
+
323
+ if (!matchResult) {
324
+ return null;
325
+ }
326
+
327
+ if (compiled.isRegex) {
328
+ if (compiled.keys && compiled.keys.length > 0) {
329
+ const result = {};
330
+ compiled.keys.forEach((key, index) => {
331
+ const val = matchResult[index + 1];
332
+ result[key] = val !== undefined ? val : null;
333
+ });
334
+ return result;
335
+ }
336
+ return matchResult.slice(1);
337
+ }
338
+
339
+ const result = {};
340
+ const usedNames = new Set();
341
+
342
+ for (let i = 0; i < compiled.segmentNames.length; i++) {
343
+ const segInfo = compiled.segmentNames[i];
344
+ const value = matchResult[segInfo.index + 1] || '';
345
+
346
+ if (usedNames.has(segInfo.name)) {
347
+ if (!Array.isArray(result[segInfo.name])) {
348
+ result[segInfo.name] = [result[segInfo.name]];
349
+ }
350
+ result[segInfo.name].push(value);
351
+ } else {
352
+ usedNames.add(segInfo.name);
353
+ result[segInfo.name] = value;
354
+ }
355
+ }
356
+
357
+ for (const key in result) {
358
+ if (result[key] === '') {
359
+ delete result[key];
360
+ }
361
+ }
362
+
363
+ return result;
364
+ };
365
+
366
+ /**
367
+ * Stringifies a pattern with given values
368
+ * @param {CompiledPattern} compiled - Compiled pattern
369
+ * @param {Object} [values={}] - Values to stringify
370
+ * @returns {string} Generated string
371
+ * @throws {Error} If required values are missing
372
+ */
373
+ const stringify = (compiled, values = {}) => {
374
+ if (compiled.isRegex) {
375
+ throw new Error('Cannot stringify a pattern created from regex');
376
+ }
377
+
378
+ let result = '';
379
+ let i = 0;
380
+
381
+ while (i < compiled.segments.length) {
382
+ const segment = compiled.segments[i];
383
+
384
+ if (segment.optional) {
385
+ let optionalPart = '';
386
+ let j = i;
387
+
388
+ while (j < compiled.segments.length && compiled.segments[j].optional) {
389
+ const seg = compiled.segments[j];
390
+
391
+ if (seg.type === 'literal') {
392
+ optionalPart += seg.name;
393
+ } else if (seg.type === 'named') {
394
+ const val = values[seg.name];
395
+ if (val !== undefined && val !== null && val !== '') {
396
+ optionalPart += Array.isArray(val) ? val.join('/') : val;
397
+ } else {
398
+ optionalPart = '';
399
+ break;
400
+ }
401
+ } else if (seg.type === 'wildcard') {
402
+ const val = values._;
403
+ if (val !== undefined && val !== null && val !== '') {
404
+ optionalPart += Array.isArray(val) ? val.join('/') : val;
405
+ } else {
406
+ optionalPart = '';
407
+ break;
408
+ }
409
+ }
410
+
411
+ j++;
412
+ }
413
+
414
+ if (optionalPart !== '') {
415
+ result += optionalPart;
416
+ }
417
+
418
+ if (i === j) {
419
+ i++;
420
+ } else {
421
+ i = j;
422
+ }
423
+ continue;
424
+ }
425
+
426
+ if (segment.type === 'literal') {
427
+ result += segment.name;
428
+ } else if (segment.type === 'named') {
429
+ const value = values[segment.name];
430
+ if (value === undefined || value === null || value === '') {
431
+ throw new Error(`Missing required value for segment: ${segment.name}`);
432
+ }
433
+ result += Array.isArray(value) ? value.join('/') : value;
434
+ } else if (segment.type === 'wildcard') {
435
+ const value = values._;
436
+ if (value === undefined || value === null || value === '') {
437
+ throw new Error('Missing required wildcard value');
438
+ }
439
+ result += Array.isArray(value) ? value.join('/') : value;
440
+ }
441
+
442
+ i++;
443
+ }
444
+
445
+ return result;
446
+ };
447
+
448
+ /**
449
+ * UrlPattern class for matching and generating URLs
450
+ */
451
+ class UrlPattern {
452
+ /**
453
+ * @param {string|RegExp} pattern - Pattern string or regex
454
+ * @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys (for regex)
455
+ */
456
+ constructor(pattern, options = {}) {
457
+ if (pattern instanceof RegExp) {
458
+ const keys = Array.isArray(options) ? options : [];
459
+ /** @type {CompiledPattern} */
460
+ this.compiled = makePatternFromRegex(pattern, keys);
461
+ } else {
462
+ /** @type {CompiledPattern} */
463
+ this.compiled = makePattern(pattern, /** @type {UrlPatternOptions} */ (options));
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Match a string against the pattern
469
+ * @param {string} str - String to match
470
+ * @returns {Object|null} Extracted values or null if no match
471
+ */
472
+ match(str) {
473
+ return match(this.compiled, str);
474
+ }
475
+
476
+ /**
477
+ * Generate a string from the pattern
478
+ * @param {Object} [values={}] - Values to stringify
479
+ * @returns {string} Generated string
480
+ */
481
+ stringify(values) {
482
+ return stringify(this.compiled, values);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Creates a new UrlPattern instance (functional API)
488
+ * @param {string|RegExp} pattern - Pattern string or regex
489
+ * @param {UrlPatternOptions|Array<string>} [options={}] - Options or keys
490
+ * @returns {UrlPattern} UrlPattern instance
491
+ */
492
+ const urlPattern = (pattern, options = {}) => {
493
+ return new UrlPattern(pattern, options);
494
+ };
495
+
496
+ exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
497
+ exports.UrlPattern = UrlPattern;
498
+ exports.default = UrlPattern;
499
+ exports.makePattern = makePattern;
500
+ exports.makePatternFromRegex = makePatternFromRegex;
501
+ exports.match = match;
502
+ exports.stringify = stringify;
503
+ exports.urlPattern = urlPattern;