blob: e485afec10cd8acc8ac1ce7d10a290d6e553091e [file] [log] [blame]
Paul Lewis40e28672020-11-27 09:51:37 +00001var isarray = require('isarray')
2
3/**
4 * Expose `pathToRegexp`.
5 */
6module.exports = pathToRegexp
7module.exports.parse = parse
8module.exports.compile = compile
9module.exports.tokensToFunction = tokensToFunction
10module.exports.tokensToRegExp = tokensToRegExp
11
12/**
13 * The main path matching regexp utility.
14 *
15 * @type {RegExp}
16 */
17var PATH_REGEXP = new RegExp([
18 // Match escaped characters that would otherwise appear in future matches.
19 // This allows the user to escape special characters that won't transform.
20 '(\\\\.)',
21 // Match Express-style parameters and un-named parameters with a prefix
22 // and optional suffixes. Matches appear as:
23 //
24 // "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?", undefined]
25 // "/route(\\d+)" => [undefined, undefined, undefined, "\d+", undefined, undefined]
26 // "/*" => ["/", undefined, undefined, undefined, undefined, "*"]
27 '([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?|(\\*))'
28].join('|'), 'g')
29
30/**
31 * Parse a string for the raw tokens.
32 *
33 * @param {string} str
34 * @param {Object=} options
35 * @return {!Array}
36 */
37function parse (str, options) {
38 var tokens = []
39 var key = 0
40 var index = 0
41 var path = ''
42 var defaultDelimiter = options && options.delimiter || '/'
43 var res
44
45 while ((res = PATH_REGEXP.exec(str)) != null) {
46 var m = res[0]
47 var escaped = res[1]
48 var offset = res.index
49 path += str.slice(index, offset)
50 index = offset + m.length
51
52 // Ignore already escaped sequences.
53 if (escaped) {
54 path += escaped[1]
55 continue
56 }
57
58 var next = str[index]
59 var prefix = res[2]
60 var name = res[3]
61 var capture = res[4]
62 var group = res[5]
63 var modifier = res[6]
64 var asterisk = res[7]
65
66 // Push the current path onto the tokens.
67 if (path) {
68 tokens.push(path)
69 path = ''
70 }
71
72 var partial = prefix != null && next != null && next !== prefix
73 var repeat = modifier === '+' || modifier === '*'
74 var optional = modifier === '?' || modifier === '*'
75 var delimiter = res[2] || defaultDelimiter
76 var pattern = capture || group
77
78 tokens.push({
79 name: name || key++,
80 prefix: prefix || '',
81 delimiter: delimiter,
82 optional: optional,
83 repeat: repeat,
84 partial: partial,
85 asterisk: !!asterisk,
86 pattern: pattern ? escapeGroup(pattern) : (asterisk ? '.*' : '[^' + escapeString(delimiter) + ']+?')
87 })
88 }
89
90 // Match any characters still remaining.
91 if (index < str.length) {
92 path += str.substr(index)
93 }
94
95 // If the path exists, push it onto the end.
96 if (path) {
97 tokens.push(path)
98 }
99
100 return tokens
101}
102
103/**
104 * Compile a string to a template function for the path.
105 *
106 * @param {string} str
107 * @param {Object=} options
108 * @return {!function(Object=, Object=)}
109 */
110function compile (str, options) {
111 return tokensToFunction(parse(str, options), options)
112}
113
114/**
115 * Prettier encoding of URI path segments.
116 *
117 * @param {string}
118 * @return {string}
119 */
120function encodeURIComponentPretty (str) {
121 return encodeURI(str).replace(/[\/?#]/g, function (c) {
122 return '%' + c.charCodeAt(0).toString(16).toUpperCase()
123 })
124}
125
126/**
127 * Encode the asterisk parameter. Similar to `pretty`, but allows slashes.
128 *
129 * @param {string}
130 * @return {string}
131 */
132function encodeAsterisk (str) {
133 return encodeURI(str).replace(/[?#]/g, function (c) {
134 return '%' + c.charCodeAt(0).toString(16).toUpperCase()
135 })
136}
137
138/**
139 * Expose a method for transforming tokens into the path function.
140 */
141function tokensToFunction (tokens, options) {
142 // Compile all the tokens into regexps.
143 var matches = new Array(tokens.length)
144
145 // Compile all the patterns before compilation.
146 for (var i = 0; i < tokens.length; i++) {
147 if (typeof tokens[i] === 'object') {
148 matches[i] = new RegExp('^(?:' + tokens[i].pattern + ')$', flags(options))
149 }
150 }
151
152 return function (obj, opts) {
153 var path = ''
154 var data = obj || {}
155 var options = opts || {}
156 var encode = options.pretty ? encodeURIComponentPretty : encodeURIComponent
157
158 for (var i = 0; i < tokens.length; i++) {
159 var token = tokens[i]
160
161 if (typeof token === 'string') {
162 path += token
163
164 continue
165 }
166
167 var value = data[token.name]
168 var segment
169
170 if (value == null) {
171 if (token.optional) {
172 // Prepend partial segment prefixes.
173 if (token.partial) {
174 path += token.prefix
175 }
176
177 continue
178 } else {
179 throw new TypeError('Expected "' + token.name + '" to be defined')
180 }
181 }
182
183 if (isarray(value)) {
184 if (!token.repeat) {
185 throw new TypeError('Expected "' + token.name + '" to not repeat, but received `' + JSON.stringify(value) + '`')
186 }
187
188 if (value.length === 0) {
189 if (token.optional) {
190 continue
191 } else {
192 throw new TypeError('Expected "' + token.name + '" to not be empty')
193 }
194 }
195
196 for (var j = 0; j < value.length; j++) {
197 segment = encode(value[j])
198
199 if (!matches[i].test(segment)) {
200 throw new TypeError('Expected all "' + token.name + '" to match "' + token.pattern + '", but received `' + JSON.stringify(segment) + '`')
201 }
202
203 path += (j === 0 ? token.prefix : token.delimiter) + segment
204 }
205
206 continue
207 }
208
209 segment = token.asterisk ? encodeAsterisk(value) : encode(value)
210
211 if (!matches[i].test(segment)) {
212 throw new TypeError('Expected "' + token.name + '" to match "' + token.pattern + '", but received "' + segment + '"')
213 }
214
215 path += token.prefix + segment
216 }
217
218 return path
219 }
220}
221
222/**
223 * Escape a regular expression string.
224 *
225 * @param {string} str
226 * @return {string}
227 */
228function escapeString (str) {
229 return str.replace(/([.+*?=^!:${}()[\]|\/\\])/g, '\\$1')
230}
231
232/**
233 * Escape the capturing group by escaping special characters and meaning.
234 *
235 * @param {string} group
236 * @return {string}
237 */
238function escapeGroup (group) {
239 return group.replace(/([=!:$\/()])/g, '\\$1')
240}
241
242/**
243 * Attach the keys as a property of the regexp.
244 *
245 * @param {!RegExp} re
246 * @param {Array} keys
247 * @return {!RegExp}
248 */
249function attachKeys (re, keys) {
250 re.keys = keys
251 return re
252}
253
254/**
255 * Get the flags for a regexp from the options.
256 *
257 * @param {Object} options
258 * @return {string}
259 */
260function flags (options) {
261 return options && options.sensitive ? '' : 'i'
262}
263
264/**
265 * Pull out keys from a regexp.
266 *
267 * @param {!RegExp} path
268 * @param {!Array} keys
269 * @return {!RegExp}
270 */
271function regexpToRegexp (path, keys) {
272 // Use a negative lookahead to match only capturing groups.
273 var groups = path.source.match(/\((?!\?)/g)
274
275 if (groups) {
276 for (var i = 0; i < groups.length; i++) {
277 keys.push({
278 name: i,
279 prefix: null,
280 delimiter: null,
281 optional: false,
282 repeat: false,
283 partial: false,
284 asterisk: false,
285 pattern: null
286 })
287 }
288 }
289
290 return attachKeys(path, keys)
291}
292
293/**
294 * Transform an array into a regexp.
295 *
296 * @param {!Array} path
297 * @param {Array} keys
298 * @param {!Object} options
299 * @return {!RegExp}
300 */
301function arrayToRegexp (path, keys, options) {
302 var parts = []
303
304 for (var i = 0; i < path.length; i++) {
305 parts.push(pathToRegexp(path[i], keys, options).source)
306 }
307
308 var regexp = new RegExp('(?:' + parts.join('|') + ')', flags(options))
309
310 return attachKeys(regexp, keys)
311}
312
313/**
314 * Create a path regexp from string input.
315 *
316 * @param {string} path
317 * @param {!Array} keys
318 * @param {!Object} options
319 * @return {!RegExp}
320 */
321function stringToRegexp (path, keys, options) {
322 return tokensToRegExp(parse(path, options), keys, options)
323}
324
325/**
326 * Expose a function for taking tokens and returning a RegExp.
327 *
328 * @param {!Array} tokens
329 * @param {(Array|Object)=} keys
330 * @param {Object=} options
331 * @return {!RegExp}
332 */
333function tokensToRegExp (tokens, keys, options) {
334 if (!isarray(keys)) {
335 options = /** @type {!Object} */ (keys || options)
336 keys = []
337 }
338
339 options = options || {}
340
341 var strict = options.strict
342 var end = options.end !== false
343 var route = ''
344
345 // Iterate over the tokens and create our regexp string.
346 for (var i = 0; i < tokens.length; i++) {
347 var token = tokens[i]
348
349 if (typeof token === 'string') {
350 route += escapeString(token)
351 } else {
352 var prefix = escapeString(token.prefix)
353 var capture = '(?:' + token.pattern + ')'
354
355 keys.push(token)
356
357 if (token.repeat) {
358 capture += '(?:' + prefix + capture + ')*'
359 }
360
361 if (token.optional) {
362 if (!token.partial) {
363 capture = '(?:' + prefix + '(' + capture + '))?'
364 } else {
365 capture = prefix + '(' + capture + ')?'
366 }
367 } else {
368 capture = prefix + '(' + capture + ')'
369 }
370
371 route += capture
372 }
373 }
374
375 var delimiter = escapeString(options.delimiter || '/')
376 var endsWithDelimiter = route.slice(-delimiter.length) === delimiter
377
378 // In non-strict mode we allow a slash at the end of match. If the path to
379 // match already ends with a slash, we remove it for consistency. The slash
380 // is valid at the end of a path match, not in the middle. This is important
381 // in non-ending mode, where "/test/" shouldn't match "/test//route".
382 if (!strict) {
383 route = (endsWithDelimiter ? route.slice(0, -delimiter.length) : route) + '(?:' + delimiter + '(?=$))?'
384 }
385
386 if (end) {
387 route += '$'
388 } else {
389 // In non-ending mode, we need the capturing groups to match as much as
390 // possible by using a positive lookahead to the end or next path segment.
391 route += strict && endsWithDelimiter ? '' : '(?=' + delimiter + '|$)'
392 }
393
394 return attachKeys(new RegExp('^' + route, flags(options)), keys)
395}
396
397/**
398 * Normalize the given path string, returning a regular expression.
399 *
400 * An empty array can be passed in for the keys, which will hold the
401 * placeholder key descriptions. For example, using `/user/:id`, `keys` will
402 * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`.
403 *
404 * @param {(string|RegExp|Array)} path
405 * @param {(Array|Object)=} keys
406 * @param {Object=} options
407 * @return {!RegExp}
408 */
409function pathToRegexp (path, keys, options) {
410 if (!isarray(keys)) {
411 options = /** @type {!Object} */ (keys || options)
412 keys = []
413 }
414
415 options = options || {}
416
417 if (path instanceof RegExp) {
418 return regexpToRegexp(path, /** @type {!Array} */ (keys))
419 }
420
421 if (isarray(path)) {
422 return arrayToRegexp(/** @type {!Array} */ (path), /** @type {!Array} */ (keys), options)
423 }
424
425 return stringToRegexp(/** @type {string} */ (path), /** @type {!Array} */ (keys), options)
426}