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