diff --git a/Readme.md b/Readme.md index f20eb28..0061eaf 100644 --- a/Readme.md +++ b/Readme.md @@ -16,59 +16,54 @@ npm install path-to-regexp --save ## Usage -```javascript +```js const { pathToRegexp, match, parse, compile } = require("path-to-regexp"); -// pathToRegexp(path, keys?, options?) -// match(path) -// parse(path) -// compile(path) +// pathToRegexp(path, options?) +// match(path, options?) +// parse(path, options?) +// compile(path, options?) ``` ### Path to regexp -The `pathToRegexp` function will return a regular expression object based on the provided `path` argument. It accepts the following arguments: +The `pathToRegexp` function returns a regular expression with `keys` as a property. It accepts the following arguments: -- **path** A string, array of strings, or a regular expression. -- **keys** _(optional)_ An array to populate with keys found in the path. +- **path** A string. - **options** _(optional)_ - - **sensitive** When `true` the regexp will be case sensitive. (default: `false`) - - **strict** When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) - - **end** When `true` the regexp will match to the end of the string. (default: `true`) - - **start** When `true` the regexp will match from the beginning of the string. (default: `true`) - - **delimiter** The default delimiter for segments, e.g. `[^/#?]` for `:named` patterns. (default: `'/#?'`) - - **endsWith** Optional character, or list of characters, to treat as "end" characters. - - **encode** A function to encode strings before inserting into `RegExp`. (default: `x => x`) - - **prefixes** List of characters to automatically consider prefixes when parsing. (default: `./`) - -```javascript -const keys = []; -const regexp = pathToRegexp("/foo/:bar", keys); -// regexp = /^\/foo(?:\/([^\/#\?]+?))[\/#\?]?$/i -// keys = [{ name: 'bar', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' }] + - **sensitive** Regexp will be case sensitive. (default: `false`) + - **trailing** Regexp allows an optional trailing delimiter to match. (default: `true`) + - **end** Match to the end of the string. (default: `true`) + - **start** Match from the beginning of the string. (default: `true`) + - **loose** Allow the delimiter to be repeated an arbitrary number of times. (default: `true`) + - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`) + - **encodePath** A function to encode strings before inserting into `RegExp`. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl)) + +```js +const regexp = pathToRegexp("/foo/:bar"); +// regexp = /^\/+foo(?:\/+([^\/]+?))(?:\/+)?$/i +// keys = [{ name: 'bar', prefix: '', suffix: '', pattern: '', modifier: '' }] ``` -**Please note:** The `RegExp` returned by `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc). When using paths that contain query strings, you need to escape the question mark (`?`) to ensure it does not flag the parameter as [optional](#optional). +**Please note:** The `RegExp` returned by `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc). ### Parameters The path argument is used to define parameters and populate keys. -#### Named Parameters +#### Named parameters -Named parameters are defined by prefixing a colon to the parameter name (`:foo`). +Named parameters are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid unicode identifier characters (similar to JavaScript). ```js const regexp = pathToRegexp("/:foo/:bar"); -// keys = [{ name: 'foo', prefix: '/', ... }, { name: 'bar', prefix: '/', ... }] +// keys = [{ name: 'foo', ... }, { name: 'bar', ... }] regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] +//=> [ '/test/route', 'test', 'route', index: 0 ] ``` -**Please note:** Parameter names must use "word characters" (`[A-Za-z0-9_]`). - -##### Custom Matching Parameters +##### Custom matching parameters Parameters can have a custom regexp, which overrides the default match (`[^/]+`). For example, you can match digits or names in a path: @@ -94,64 +89,49 @@ regexpWord.exec("/users"); **Tip:** Backslashes need to be escaped with another backslash in JavaScript strings. -##### Custom Prefix and Suffix +#### Unnamed parameters -Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment: +It is possible to define a parameter without a name. The name will be numerically indexed: ```js -const regexp = pathToRegexp("/:attr1?{-:attr2}?{-:attr3}?"); - -regexp.exec("/test"); -// => ['/test', 'test', undefined, undefined] +const regexp = pathToRegexp("/:foo/(.*)"); +// keys = [{ name: 'foo', ... }, { name: '0', ... }] -regexp.exec("/test-test"); -// => ['/test', 'test', 'test', undefined] +regexp.exec("/test/route"); +//=> [ '/test/route', 'test', 'route', index: 0 ] ``` -#### Unnamed Parameters +##### Custom prefix and suffix -It is possible to write an unnamed parameter that only consists of a regexp. It works the same the named parameter, except it will be numerically indexed: +Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment: ```js -const regexp = pathToRegexp("/:foo/(.*)"); -// keys = [{ name: 'foo', ... }, { name: 0, ... }] +const regexp = pathToRegexp("{/:attr1}?{-:attr2}?{-:attr3}?"); -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] +regexp.exec("/test"); +// => ['/test', 'test', undefined, undefined] + +regexp.exec("/test-test"); +// => ['/test', 'test', 'test', undefined] ``` #### Modifiers -Modifiers must be placed after the parameter (e.g. `/:foo?`, `/(test)?`, `/:foo(test)?`, or `{-:foo(test)}?`). +Modifiers are used after parameters with custom prefixes and suffixes (`{}`). ##### Optional Parameters can be suffixed with a question mark (`?`) to make the parameter optional. ```js -const regexp = pathToRegexp("/:foo/:bar?"); +const regexp = pathToRegexp("/:foo{/:bar}?"); // keys = [{ name: 'foo', ... }, { name: 'bar', prefix: '/', modifier: '?' }] regexp.exec("/test"); -//=> [ '/test', 'test', undefined, index: 0, input: '/test', groups: undefined ] +//=> [ '/test', 'test', undefined, index: 0 ] regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] -``` - -**Tip:** The prefix is also optional, escape the prefix `\/` to make it required. - -When dealing with query strings, escape the question mark (`?`) so it doesn't mark the parameter as optional. Handling unordered data is outside the scope of this library. - -```js -const regexp = pathToRegexp("/search/:tableName\\?useIndex=true&term=amazing"); - -regexp.exec("/search/people?useIndex=true&term=amazing"); -//=> [ '/search/people?useIndex=true&term=amazing', 'people', index: 0, input: '/search/people?useIndex=true&term=amazing', groups: undefined ] - -// This library does not handle query strings in different orders -regexp.exec("/search/people?term=amazing&useIndex=true"); -//=> null +//=> [ '/test/route', 'test', 'route', index: 0 ] ``` ##### Zero or more @@ -159,14 +139,14 @@ regexp.exec("/search/people?term=amazing&useIndex=true"); Parameters can be suffixed with an asterisk (`*`) to denote a zero or more parameter matches. ```js -const regexp = pathToRegexp("/:foo*"); +const regexp = pathToRegexp("{/:foo}*"); // keys = [{ name: 'foo', prefix: '/', modifier: '*' }] -regexp.exec("/"); -//=> [ '/', undefined, index: 0, input: '/', groups: undefined ] +regexp.exec("/foo"); +//=> [ '/foo', "foo", index: 0 ] regexp.exec("/bar/baz"); -//=> [ '/bar/baz', 'bar/baz', index: 0, input: '/bar/baz', groups: undefined ] +//=> [ '/bar/baz', 'bar/baz', index: 0 ] ``` ##### One or more @@ -174,165 +154,125 @@ regexp.exec("/bar/baz"); Parameters can be suffixed with a plus sign (`+`) to denote a one or more parameter matches. ```js -const regexp = pathToRegexp("/:foo+"); +const regexp = pathToRegexp("{/:foo}+"); // keys = [{ name: 'foo', prefix: '/', modifier: '+' }] regexp.exec("/"); //=> null regexp.exec("/bar/baz"); -//=> [ '/bar/baz','bar/baz', index: 0, input: '/bar/baz', groups: undefined ] +//=> [ '/bar/baz', 'bar/baz', index: 0 ] ``` -### Match - -The `match` function will return a function for transforming paths into parameters: - -```js -// Make sure you consistently `decode` segments. -const fn = match("/user/:id", { decode: decodeURIComponent }); - -fn("/user/123"); //=> { path: '/user/123', index: 0, params: { id: '123' } } -fn("/invalid"); //=> false -fn("/user/caf%C3%A9"); //=> { path: '/user/caf%C3%A9', index: 0, params: { id: 'café' } } -``` +#### Wildcard -The `match` function can be used to custom match named parameters. For example, this can be used to whitelist a small number of valid paths: +A wildcard can also be used. It is roughly equivalent to `(.*)` except when decoding in `match` below it splits on the delimiter. ```js -const urlMatch = match("/users/:id/:tab(home|photos|bio)", { - decode: decodeURIComponent, -}); - -urlMatch("/users/1234/photos"); -//=> { path: '/users/1234/photos', index: 0, params: { id: '1234', tab: 'photos' } } - -urlMatch("/users/1234/bio"); -//=> { path: '/users/1234/bio', index: 0, params: { id: '1234', tab: 'bio' } } - -urlMatch("/users/1234/otherstuff"); -//=> false -``` +const regexp = pathToRegexp("/*"); +// keys = [{ name: '0', pattern: '[^\\/]*', modifier: '*' }] -#### Process Pathname - -You should make sure variations of the same path match the expected `path`. Here's one possible solution using `encode`: - -```js -const fn = match("/café", { encode: encodeURI }); +regexp.exec("/"); +//=> [ '/', '', index: 0 ] -fn("/caf%C3%A9"); //=> { path: '/caf%C3%A9', index: 0, params: {} } +regexp.exec("/bar/baz"); +//=> [ '/bar/baz', 'bar/baz', index: 0 ] ``` -**Note:** [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) encodes paths, so `/café` would be normalized to `/caf%C3%A9` and match in the above example. - -##### Alternative Using Normalize - -Sometimes you won't have already normalized paths to use, so you could normalize it yourself before matching: - -```js -/** - * Normalize a pathname for matching, replaces multiple slashes with a single - * slash and normalizes unicode characters to "NFC". When using this method, - * `decode` should be an identity function so you don't decode strings twice. - */ -function normalizePathname(pathname: string) { - return ( - decodeURI(pathname) - // Replaces repeated slashes in the URL. - .replace(/\/+/g, "/") - // Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize - // Note: Missing native IE support, may want to skip this step. - .normalize() - ); -} - -// Two possible ways of writing `/café`: -const re = pathToRegexp("/caf\u00E9"); -const input = encodeURI("/cafe\u0301"); - -re.test(input); //=> false -re.test(normalizePathname(input)); //=> true -``` +### Match -### Parse +The `match` function returns a function for transforming paths into parameters: -The `parse` function will return a list of strings and keys from a path string: +- **path** A string. +- **options** _(optional)_ The same options as `pathToRegexp`, plus: + - **decode** Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) ```js -const tokens = parse("/route/:foo/(.*)"); - -console.log(tokens[0]); -//=> "/route" - -console.log(tokens[1]); -//=> { name: 'foo', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' } +// Make sure you consistently `decode` segments. +const fn = match("/user/:id", { decode: decodeURIComponent }); -console.log(tokens[2]); -//=> { name: 0, prefix: '/', suffix: '', pattern: '.*', modifier: '' } +fn("/user/123"); //=> { path: '/user/123', index: 0, params: { id: '123' } } +fn("/invalid"); //=> false +fn("/user/caf%C3%A9"); //=> { path: '/user/caf%C3%A9', index: 0, params: { id: 'café' } } ``` -**Note:** This method only works with strings. +**Note:** Setting `decode: false` disables the "splitting" behavior of repeated parameters, which is useful if you need the exactly matched parameter back. ### Compile ("Reverse" Path-To-RegExp) The `compile` function will return a function for transforming parameters into a valid path: +- **path** A string. +- **options** _(optional)_ Similar to `pathToRegexp` (`delimiter`, `encodePath`, `sensitive`, and `loose`), plus: + - **validate** When `false` the function can produce an invalid (unmatched) path. (default: `true`) + - **encode** Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) + ```js -// Make sure you encode your path segments consistently. -const toPath = compile("/user/:id", { encode: encodeURIComponent }); +const toPath = compile("/user/:id"); toPath({ id: 123 }); //=> "/user/123" toPath({ id: "café" }); //=> "/user/caf%C3%A9" toPath({ id: ":/" }); //=> "/user/%3A%2F" -// Without `encode`, you need to make sure inputs are encoded correctly. -// (Note: You can use `validate: false` to create an invalid paths.) -const toPathRaw = compile("/user/:id", { validate: false }); +// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted. +const toPathRaw = compile("/user/:id", { encode: false }); toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" -toPathRaw({ id: ":/" }); //=> "/user/:/" +toPathRaw({ id: ":/" }); //=> "/user/:/", throws when `validate: false` is not set. -const toPathRepeated = compile("/:segment+"); +const toPathRepeated = compile("{/:segment}+"); -toPathRepeated({ segment: "foo" }); //=> "/foo" +toPathRepeated({ segment: ["foo"] }); //=> "/foo" toPathRepeated({ segment: ["a", "b", "c"] }); //=> "/a/b/c" const toPathRegexp = compile("/user/:id(\\d+)"); -toPathRegexp({ id: 123 }); //=> "/user/123" toPathRegexp({ id: "123" }); //=> "/user/123" ``` -**Note:** The generated function will throw on invalid input. +## Developers -### Working with Tokens +- If you are rewriting paths with match and compiler, consider using `encode: false` and `decode: false` to keep raw paths passed around. +- To ensure matches work on paths containing characters usually encoded, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`. +- If matches are intended to be exact, you need to set `loose: ''`, `trailing: false`, and `sensitive: true`. -Path-To-RegExp exposes the two functions used internally that accept an array of tokens: +### Parse -- `tokensToRegexp(tokens, keys?, options?)` Transform an array of tokens into a matching regular expression. -- `tokensToFunction(tokens)` Transform an array of tokens into a path generator function. +A `parse` function is available and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can passed directly into `pathToRegexp`, `match`, and `compile`. It accepts only two options, `delimiter` and `encodePath`, which makes those options redundant in the above methods. -#### Token Information +### Token Information - `name` The name of the token (`string` for named or `number` for unnamed index) - `prefix` The prefix string for the segment (e.g. `"/"`) - `suffix` The suffix string for the segment (e.g. `""`) - `pattern` The RegExp used to match this token (`string`) - `modifier` The modifier character used for the segment (e.g. `?`) +- `separator` _(optional)_ The string used to separate repeated parameters (modifier is `+` or `*`) +- `optional` _(optional)_ A boolean used to indicate whether the parameter is optional (modifier is `?` or `*`) + +## Errors + +An effort has been made to ensure ambiguous paths from previous releases throw an error. This means you might be seeing an error when things worked before. + +### Unexpected `?`, `*`, or `+` + +In previous major versions, `/` or `.` were used as implicit prefixes of parameters. So `/:key?` was implicitly `{/:key}?`. + +This has been made explicit. Assuming `?` as the modifier, if you have a `/` or `.` before the parameter, you want `{.:ext}?` or `{/:ext}?`. If not, you want `{:ext}?`. -## Compatibility with Express <= 4.x +### Unexpected `!`, `@`, or `;` -Path-To-RegExp breaks compatibility with Express <= `4.x`: +These characters have been reserved for future use. -- RegExp special characters can only be used in a parameter - - Express.js 4.x supported `RegExp` special characters regardless of position - this is considered a bug -- Parameters have suffixes that augment meaning - `*`, `+` and `?`. E.g. `/:user*` -- No wildcard asterisk (`*`) - use parameters instead (`(.*)` or `:splat*`) +### Express <= 4.x -## Live Demo +Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways: -You can see a live demo of this library in use at [express-route-tester](http://forbeslindesay.github.io/express-route-tester/). +- The only part of the string that is a regex is within `()`. + - In Express.js 4.x, everything was passed as-is after a simple replacement, so you could write `/[a-z]+` to match `/test`. +- The `?` optional character must be used after `{}`. +- Some characters have new meaning or have been reserved (`{}?*+@!;`). +- The parameter name now supports all unicode identifier characters, previously it was only `[a-z0-9]`. ## License diff --git a/src/index.spec.ts b/src/index.spec.ts index a0b24c1..7f85628 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -37,25 +37,29 @@ const PARSER_TESTS: ParserTestSet[] = [ { path: "/:test", expected: [ - { name: "test", prefix: "/", suffix: "", pattern: "", modifier: "" }, + "/", + { name: "test", prefix: "", suffix: "", pattern: "", modifier: "" }, ], }, { path: "/:0", expected: [ - { name: "0", prefix: "/", suffix: "", pattern: "", modifier: "" }, + "/", + { name: "0", prefix: "", suffix: "", pattern: "", modifier: "" }, ], }, { path: "/:_", expected: [ - { name: "_", prefix: "/", suffix: "", pattern: "", modifier: "" }, + "/", + { name: "_", prefix: "", suffix: "", pattern: "", modifier: "" }, ], }, { path: "/:café", expected: [ - { name: "café", prefix: "/", suffix: "", pattern: "", modifier: "" }, + "/", + { name: "café", prefix: "", suffix: "", pattern: "", modifier: "" }, ], }, ]; @@ -143,7 +147,7 @@ const COMPILE_TESTS: CompileTestSet[] = [ ], }, { - path: "/:test?", + path: "{/:test}?", options: { encode: false }, tests: [ { input: undefined, expected: "" }, @@ -165,7 +169,7 @@ const COMPILE_TESTS: CompileTestSet[] = [ ], }, { - path: "/:test*", + path: "{/:test}*", tests: [ { input: undefined, expected: "" }, { input: {}, expected: "" }, @@ -177,7 +181,7 @@ const COMPILE_TESTS: CompileTestSet[] = [ ], }, { - path: "/:test*", + path: "{/:test}*", options: { encode: false }, tests: [ { input: undefined, expected: "" }, @@ -1115,7 +1119,7 @@ const MATCH_TESTS: MatchTestSet[] = [ * Optional. */ { - path: "/:test?", + path: "{/:test}?", tests: [ { input: "/route", @@ -1145,7 +1149,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test?", + path: "{/:test}?", options: { trailing: false, }, @@ -1165,7 +1169,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test?/bar", + path: "{/:test}?/bar", tests: [ { input: "/bar", @@ -1190,7 +1194,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test?-bar", + path: "{/:test}?-bar", tests: [ { input: "-bar", @@ -1209,12 +1213,32 @@ const MATCH_TESTS: MatchTestSet[] = [ }, ], }, + { + path: "/{:test}?-bar", + tests: [ + { + input: "/-bar", + matches: ["/-bar", undefined], + expected: { path: "/-bar", index: 0, params: {} }, + }, + { + input: "/foo-bar", + matches: ["/foo-bar", "foo"], + expected: { path: "/foo-bar", index: 0, params: { test: "foo" } }, + }, + { + input: "/foo-bar/", + matches: ["/foo-bar/", "foo"], + expected: { path: "/foo-bar/", index: 0, params: { test: "foo" } }, + }, + ], + }, /** * Zero or more times. */ { - path: "/:test*", + path: "{/:test}*", tests: [ { input: "/", @@ -1252,7 +1276,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test*-bar", + path: "{/:test}*-bar", tests: [ { input: "-bar", @@ -1285,7 +1309,7 @@ const MATCH_TESTS: MatchTestSet[] = [ * One or more times. */ { - path: "/:test+", + path: "{/:test}+", tests: [ { input: "/", @@ -1323,7 +1347,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test+-bar", + path: "{/:test}+-bar", tests: [ { input: "-bar", @@ -1479,7 +1503,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test(abc|xyz)*", + path: "{/:test(abc|xyz)}*", tests: [ { input: "/", @@ -1574,7 +1598,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: ":test?", + path: "{:test}?", tests: [ { input: "test", @@ -1589,7 +1613,10 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: ":test*", + path: "{:test}*", + testOptions: { + skip: true, + }, tests: [ { input: "test", @@ -1613,7 +1640,10 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: ":test+", + path: "{:test}+", + testOptions: { + skip: true, + }, tests: [ { input: "test", @@ -1752,7 +1782,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/test.:format(\\w+)?", + path: "/test{.:format(\\w+)}?", tests: [ { input: "/test", @@ -1767,7 +1797,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/test.:format(\\w+)+", + path: "/test{.:format(\\w+)}+", tests: [ { input: "/test", @@ -1855,7 +1885,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:test.:format?", + path: "/:test{.:format}?", tests: [ { input: "/route", @@ -1926,7 +1956,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/(\\d+)?", + path: "{/(\\d+)}?", tests: [ { input: "/", @@ -2029,7 +2059,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/test\\/:uid(u\\d+)?:cid(c\\d+)?", + path: "/test/{:uid(u\\d+)}?{:cid(c\\d+)}?", tests: [ { input: "/test/u123", @@ -2147,7 +2177,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:foo+bar", + path: "{/:foo}+bar", tests: [ { input: "/foobar", @@ -2171,7 +2201,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "\\/:pre?baz", + path: "/{:pre}?baz", tests: [ { input: "/foobaz", @@ -2186,7 +2216,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:foo\\(:bar?\\)", + path: "/:foo\\({:bar}?\\)", tests: [ { input: "/hello(world)", @@ -2209,7 +2239,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:postType(video|audio|text)(\\+.+)?", + path: "/:postType(video|audio|text){(\\+.+)}?", tests: [ { input: "/video", @@ -2233,7 +2263,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:foo?/:bar?-ext", + path: "{/:foo}?{/:bar}?-ext", tests: [ { input: "/-ext", @@ -2271,7 +2301,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:required/:optional?-ext", + path: "/:required{/:optional}?-ext", tests: [ { input: "/foo-ext", @@ -2405,7 +2435,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "mail.:domain?.com", + path: "mail{.:domain}?.com", options: { delimiter: ".", }, @@ -2507,8 +2537,13 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "name/:attr1?{-:attr2}?{-:attr3}?", + path: "name{/:attr1}?{-:attr2}?{-:attr3}?", tests: [ + { + input: "name", + matches: ["name", undefined, undefined, undefined], + expected: { path: "name", index: 0, params: {} }, + }, { input: "name/test", matches: ["name/test", "test", undefined, undefined], @@ -2557,6 +2592,53 @@ const MATCH_TESTS: MatchTestSet[] = [ }, ], }, + { + path: "name{/:attrs;-}*", + tests: [ + { + input: "name", + matches: ["name", undefined], + expected: { path: "name", index: 0, params: {} }, + }, + { + input: "name/1", + matches: ["name/1", "1"], + expected: { + path: "name/1", + index: 0, + params: { attrs: ["1"] }, + }, + }, + { + input: "name/1-2", + matches: ["name/1-2", "1-2"], + expected: { + path: "name/1-2", + index: 0, + params: { attrs: ["1", "2"] }, + }, + }, + { + input: "name/1-2-3", + matches: ["name/1-2-3", "1-2-3"], + expected: { + path: "name/1-2-3", + index: 0, + params: { attrs: ["1", "2", "3"] }, + }, + }, + { + input: "name/foo-bar/route", + matches: null, + expected: false, + }, + { + input: "name/test/route", + matches: null, + expected: false, + }, + ], + }, /** * Nested parentheses. @@ -2611,7 +2693,7 @@ const MATCH_TESTS: MatchTestSet[] = [ * https://github.com/pillarjs/path-to-regexp/issues/206 */ { - path: "/user(s)?/:user", + path: "/user{(s)}?/:user", tests: [ { input: "/user/123", @@ -2645,58 +2727,11 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, - /** - * https://github.com/pillarjs/path-to-regexp/issues/260 - */ - { - path: ":name*", - tests: [ - { - input: "foobar", - matches: ["foobar", "foobar"], - expected: { path: "foobar", index: 0, params: { name: ["foobar"] } }, - }, - { - input: "foo/bar", - matches: ["foo/bar", "foo/bar"], - expected: { - path: "foo/bar", - index: 0, - params: { name: ["foo", "bar"] }, - }, - }, - ], - }, - { - path: ":name+", - tests: [ - { - input: "", - matches: null, - expected: false, - }, - { - input: "foobar", - matches: ["foobar", "foobar"], - expected: { path: "foobar", index: 0, params: { name: ["foobar"] } }, - }, - { - input: "foo/bar", - matches: ["foo/bar", "foo/bar"], - expected: { - path: "foo/bar", - index: 0, - params: { name: ["foo", "bar"] }, - }, - }, - ], - }, - /** * https://github.com/pillarjs/path-to-regexp/pull/270 */ { - path: "/files/:path*.:ext*", + path: "/files{/:path}*{.:ext}*", tests: [ { input: "/files/hello/world.txt", @@ -2729,7 +2764,7 @@ const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/foo/:bar*", + path: "/foo{/:bar}*", tests: [ { input: "/foo/test1//test2", @@ -2851,7 +2886,7 @@ const MATCH_TESTS: MatchTestSet[] = [ */ { path: "/test", - options: { loose: "" }, + options: { loose: false }, tests: [ { input: "/test", @@ -2878,7 +2913,7 @@ describe("path-to-regexp", () => { const expectedKeys = [ { name: "id", - prefix: "/", + prefix: "", suffix: "", modifier: "", pattern: "", @@ -2928,7 +2963,11 @@ describe("path-to-regexp", () => { it("should throw on nested groups", () => { expect(() => { pathToRegexp.pathToRegexp("/{a{b:foo}}"); - }).toThrow(new TypeError("Unexpected { at 3, expected }")); + }).toThrow( + new TypeError( + "Unexpected { at 3, expected }: https://git.new/pathToRegexpError", + ), + ); }); }); @@ -2992,11 +3031,11 @@ describe("path-to-regexp", () => { expect(() => { toPath({ foo: "abc" }); - }).toThrow(new TypeError('Invalid value for "foo": "/abc"')); + }).toThrow(new TypeError('Invalid value for "foo": "abc"')); }); it("should throw when expecting a repeated value", () => { - const toPath = pathToRegexp.compile("/:foo+"); + const toPath = pathToRegexp.compile("{/:foo}+"); expect(() => { toPath({ foo: [] }); @@ -3012,7 +3051,7 @@ describe("path-to-regexp", () => { }); it("should throw when a repeated param is not an array", () => { - const toPath = pathToRegexp.compile("/:foo+"); + const toPath = pathToRegexp.compile("{/:foo}+"); expect(() => { toPath({ foo: "a" }); @@ -3020,7 +3059,7 @@ describe("path-to-regexp", () => { }); it("should throw when an array value is not a string", () => { - const toPath = pathToRegexp.compile("/:foo+"); + const toPath = pathToRegexp.compile("{/:foo}+"); expect(() => { toPath({ foo: [1, "a"] }); @@ -3028,7 +3067,7 @@ describe("path-to-regexp", () => { }); it("should throw when repeated value does not match", () => { - const toPath = pathToRegexp.compile("/:foo(\\d+)+"); + const toPath = pathToRegexp.compile("{/:foo(\\d+)}+"); expect(() => { toPath({ foo: ["1", "2", "3", "a"] }); diff --git a/src/index.ts b/src/index.ts index 82961d7..b42f6db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,10 +17,6 @@ export interface ParseOptions { * Set the default delimiter for repeat parameters. (default: `'/'`) */ delimiter?: string; - /** - * List of characters to automatically consider prefixes when parsing. - */ - prefixes?: string; /** * Function for encoding input strings for output into path. */ @@ -33,9 +29,9 @@ export interface PathToRegexpOptions extends ParseOptions { */ sensitive?: boolean; /** - * Set characters to treat as "loose" and allow arbitrarily repeated. (default: `/`) + * Allow delimiter to be arbitrarily repeated. (default: `true`) */ - loose?: string; + loose?: boolean; /** * When `true` the regexp will match to the end of the string. (default: `true`) */ @@ -52,7 +48,7 @@ export interface PathToRegexpOptions extends ParseOptions { export interface MatchOptions extends PathToRegexpOptions { /** - * Function for decoding strings for params. + * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) */ decode?: Decode | false; } @@ -63,15 +59,15 @@ export interface CompileOptions extends ParseOptions { */ sensitive?: boolean; /** - * Set characters to treat as "loose" and allow arbitrarily repeated. (default: `/`) + * Allow delimiter to be arbitrarily repeated. (default: `true`) */ - loose?: string; + loose?: boolean; /** * When `false` the function can produce an invalid (unmatched) path. (default: `true`) */ validate?: boolean; /** - * Function for encoding input strings for output into the path. (default: `encodeURIComponent`) + * Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) */ encode?: Encode | false; } @@ -90,6 +86,7 @@ type TokenType = // Reserved for use. | "!" | "@" + | "," | ";"; /** @@ -105,6 +102,7 @@ const SIMPLE_TOKENS: Record = { "!": "!", "@": "@", ";": ";", + ",": ",", "*": "*", "+": "+", "?": "?", @@ -215,7 +213,9 @@ class Iter { const value = this.tryConsume(type); if (value !== undefined) return value; const { type: nextType, index } = this.peek(); - throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); + throw new TypeError( + `Unexpected ${nextType} at ${index}, expected ${type}: https://git.new/pathToRegexpError`, + ); } text(): string { @@ -227,8 +227,10 @@ class Iter { return result; } - modifier(): string | undefined { - return this.tryConsume("?") || this.tryConsume("*") || this.tryConsume("+"); + modifier(): string { + return ( + this.tryConsume("?") || this.tryConsume("*") || this.tryConsume("+") || "" + ); } } @@ -246,73 +248,47 @@ export class TokenData { * Parse a string for the raw tokens. */ export function parse(str: string, options: ParseOptions = {}): TokenData { - const { - prefixes = "./", - delimiter = DEFAULT_DELIMITER, - encodePath = NOOP_VALUE, - } = options; + const { delimiter = DEFAULT_DELIMITER, encodePath = NOOP_VALUE } = options; const tokens: Token[] = []; const it = lexer(str); let key = 0; - let path = ""; do { - const char = it.tryConsume("CHAR"); + const path = it.text(); + if (path) tokens.push(encodePath(path)); + const name = it.tryConsume("NAME"); - const pattern = it.tryConsume("PATTERN"); + const pattern = it.tryConsume("PATTERN") || ""; if (name || pattern) { - let prefix = char || ""; - const modifier = it.modifier(); - - if (!prefixes.includes(prefix)) { - path += prefix; - prefix = ""; - } - - if (path) { - tokens.push(encodePath(path)); - path = ""; + tokens.push({ + name: name || String(key++), + prefix: "", + suffix: "", + pattern, + modifier: "", + }); + + const next = it.peek(); + if (next.type === "*") { + throw new TypeError( + `Unexpected * at ${next.index}, you probably want \`/*\` or \`{/:foo}*\`: https://git.new/pathToRegexpError`, + ); } - tokens.push( - toKey( - encodePath, - delimiter, - name || String(key++), - pattern, - prefix, - "", - modifier, - ), - ); - continue; - } - - const value = char || it.tryConsume("ESCAPED"); - if (value) { - path += value; continue; } - if (path) { - tokens.push(encodePath(path)); - path = ""; - } - const asterisk = it.tryConsume("*"); if (asterisk) { - tokens.push( - toKey( - encodePath, - delimiter, - String(key++), - `[^${escape(delimiter)}]*`, - "", - "", - asterisk, - ), - ); + tokens.push({ + name: String(key++), + prefix: "", + suffix: "", + pattern: `[^${escape(delimiter)}]*`, + modifier: "*", + separator: delimiter, + }); continue; } @@ -320,22 +296,22 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { if (open) { const prefix = it.text(); const name = it.tryConsume("NAME"); - const pattern = it.tryConsume("PATTERN"); + const pattern = it.tryConsume("PATTERN") || ""; const suffix = it.text(); + const separator = it.tryConsume(";") ? it.text() : prefix + suffix; it.consume("}"); - tokens.push( - toKey( - encodePath, - delimiter, - name || (pattern ? String(key++) : ""), - pattern, - prefix, - suffix, - it.modifier(), - ), - ); + const modifier = it.modifier(); + + tokens.push({ + name: name || (pattern ? String(key++) : ""), + prefix: encodePath(prefix), + suffix: encodePath(suffix), + pattern, + modifier, + separator, + }); continue; } @@ -346,32 +322,14 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { return new TokenData(tokens, delimiter); } -function toKey( - encode: Encode, - delimiter: string, - name: string, - pattern = "", - inputPrefix = "", - inputSuffix = "", - modifier = "", -): Key { - const prefix = encode(inputPrefix); - const suffix = encode(inputSuffix); - const separator = - modifier === "*" || modifier === "+" - ? prefix + suffix || delimiter - : undefined; - return { name, prefix, suffix, pattern, modifier, separator }; -} - /** * Compile a string to a template function for the path. */ export function compile

( - value: Path, + path: Path, options: CompileOptions = {}, ) { - const data = value instanceof TokenData ? value : parse(value, options); + const data = path instanceof TokenData ? path : parse(path, options); return compileTokens

(data, options); } @@ -389,10 +347,11 @@ function tokenToFunction( return () => token; } - const optional = token.modifier === "?" || token.modifier === "*"; const encodeValue = encode || NOOP_VALUE; + const repeated = token.modifier === "+" || token.modifier === "*"; + const optional = token.modifier === "?" || token.modifier === "*"; - if (encode && token.separator) { + if (encode && repeated) { const stringify = (value: string, index: number) => { if (typeof value !== "string") { throw new TypeError(`Expected "${token.name}/${index}" to be a string`); @@ -456,11 +415,11 @@ function compileTokens

( ): PathFunction

{ const { encode = encodeURIComponent, - loose = DEFAULT_DELIMITER, + loose = true, validate = true, } = options; const reFlags = flags(options); - const stringify = toStringify(loose); + const stringify = toStringify(loose, data.delimiter); const keyToRegexp = toKeyRegexp(stringify, data.delimiter); // Compile all the tokens into regexps. @@ -514,26 +473,18 @@ export type MatchFunction

= (path: string) => Match

; * Create path match function from `path-to-regexp` spec. */ export function match

( - str: Path, + path: Path, options: MatchOptions = {}, ): MatchFunction

{ - const re = pathToRegexp(str, options); - return matchRegexp

(re, options); -} - -/** - * Create a path match function from `path-to-regexp` output. - */ -function matchRegexp

( - re: PathRegExp, - options: MatchOptions, -): MatchFunction

{ - const { decode = decodeURIComponent, loose = DEFAULT_DELIMITER } = options; - const stringify = toStringify(loose); + const { decode = decodeURIComponent, loose = true } = options; + const data = path instanceof TokenData ? path : parse(path, options); + const stringify = toStringify(loose, data.delimiter); + const keys: Key[] = []; + const re = tokensToRegexp(data, keys, options); - const decoders = re.keys.map((key) => { - if (decode && key.separator) { - const re = new RegExp(stringify(key.separator), "g"); + const decoders = keys.map((key) => { + if (decode && (key.modifier === "+" || key.modifier === "*")) { + const re = new RegExp(stringify(key.separator || ""), "g"); return (value: string) => value.split(re).map(decode); } @@ -550,7 +501,7 @@ function matchRegexp

( for (let i = 1; i < m.length; i++) { if (m[i] === undefined) continue; - const key = re.keys[i - 1]; + const key = keys[i - 1]; const decoder = decoders[i - 1]; params[key.name] = decoder(m[i]); } @@ -576,10 +527,10 @@ function looseReplacer(value: string, loose: string) { /** * Encode all non-delimiter characters using the encode function. */ -function toStringify(loose: string) { +function toStringify(loose: boolean, delimiter: string) { if (!loose) return escape; - const re = new RegExp(`[^${escape(loose)}]+|(.)`, "g"); + const re = new RegExp(`[^${escape(delimiter)}]+|(.)`, "g"); return (value: string) => value.replace(re, looseReplacer); } @@ -615,13 +566,8 @@ function tokensToRegexp( keys: Key[], options: PathToRegexpOptions, ): RegExp { - const { - trailing = true, - start = true, - end = true, - loose = DEFAULT_DELIMITER, - } = options; - const stringify = toStringify(loose); + const { trailing = true, start = true, end = true, loose = true } = options; + const stringify = toStringify(loose, data.delimiter); const keyToRegexp = toKeyRegexp(stringify, data.delimiter); let pattern = start ? "^" : ""; @@ -652,13 +598,12 @@ function toKeyRegexp(stringify: Encode, delimiter: string) { if (key.name) { const pattern = key.pattern || segmentPattern; - if (key.separator) { + if (key.modifier === "+" || key.modifier === "*") { const mod = key.modifier === "*" ? "?" : ""; - const split = stringify(key.separator); + const split = stringify(key.separator || ""); return `(?:${prefix}((?:${pattern})(?:${split}(?:${pattern}))*)${suffix})${mod}`; - } else { - return `(?:${prefix}(${pattern})${suffix})${key.modifier}`; } + return `(?:${prefix}(${pattern})${suffix})${key.modifier}`; } return `(?:${prefix}${suffix})${key.modifier}`;