diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35714de..23c96d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: - push - pull_request +permissions: + contents: read jobs: test: name: Node.js ${{ matrix.node-version }} @@ -22,7 +24,6 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - run: npm install -g npm@8 - run: npm ci - run: npm test - uses: codecov/codecov-action@v1 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..5fcb48c --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,74 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security + +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: "16 21 * * 1" + push: + branches: ["master"] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@2f93e4319b2f04a2efc38fa7f78bd681bc3f7b2f # v2.23.2 + with: + sarif_file: results.sarif diff --git a/Readme.md b/Readme.md index f20eb28..455c935 100644 --- a/Readme.md +++ b/Readme.md @@ -16,323 +16,188 @@ npm install path-to-regexp --save ## Usage -```javascript -const { pathToRegexp, match, parse, compile } = require("path-to-regexp"); - -// pathToRegexp(path, keys?, options?) -// match(path) -// parse(path) -// compile(path) -``` - -### Path to regexp - -The `pathToRegexp` function will return a regular expression object based on the provided `path` argument. 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. -- **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: '' }] +```js +const { + match, + pathToRegexp, + compile, + parse, + stringify, +} = require("path-to-regexp"); ``` -**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). - ### Parameters -The path argument is used to define parameters and populate keys. - -#### Named Parameters - -Named parameters are defined by prefixing a colon to the parameter name (`:foo`). +Parameters match arbitrary strings in a path by matching up to the end of the segment, or up to any proceeding tokens. They are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid JavaScript identifier, or be double quoted to use other characters (`:"param-name"`). ```js -const regexp = pathToRegexp("/:foo/:bar"); -// keys = [{ name: 'foo', prefix: '/', ... }, { name: 'bar', prefix: '/', ... }] +const fn = match("/:foo/:bar"); -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] +fn("/test/route"); +//=> { path: '/test/route', params: { foo: 'test', bar: 'route' } } ``` -**Please note:** Parameter names must use "word characters" (`[A-Za-z0-9_]`). +### Wildcard -##### 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: +Wildcard parameters match one or more characters across multiple segments. They are defined the same way as regular parameters, but are prefixed with an asterisk (`*foo`). ```js -const regexpNumbers = pathToRegexp("/icon-:foo(\\d+).png"); -// keys = [{ name: 'foo', ... }] - -regexpNumbers.exec("/icon-123.png"); -//=> ['/icon-123.png', '123'] +const fn = match("/*splat"); -regexpNumbers.exec("/icon-abc.png"); -//=> null - -const regexpWord = pathToRegexp("/(user|u)"); -// keys = [{ name: 0, ... }] - -regexpWord.exec("/u"); -//=> ['/u', 'u'] - -regexpWord.exec("/users"); -//=> null +fn("/bar/baz"); +//=> { path: '/bar/baz', params: { splat: [ 'bar', 'baz' ] } } ``` -**Tip:** Backslashes need to be escaped with another backslash in JavaScript strings. - -##### Custom Prefix and Suffix +### Optional -Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment: +Braces can be used to define parts of the path that are optional. ```js -const regexp = pathToRegexp("/:attr1?{-:attr2}?{-:attr3}?"); +const fn = match("/users{/:id}/delete"); -regexp.exec("/test"); -// => ['/test', 'test', undefined, undefined] +fn("/users/delete"); +//=> { path: '/users/delete', params: {} } -regexp.exec("/test-test"); -// => ['/test', 'test', 'test', undefined] +fn("/users/123/delete"); +//=> { path: '/users/123/delete', params: { id: '123' } } ``` -#### Unnamed Parameters +## Match -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: +The `match` function returns a function for matching strings against a path: -```js -const regexp = pathToRegexp("/:foo/(.*)"); -// keys = [{ name: 'foo', ... }, { name: 0, ... }] - -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] -``` - -#### Modifiers - -Modifiers must be placed after the parameter (e.g. `/:foo?`, `/(test)?`, `/:foo(test)?`, or `{-:foo(test)}?`). - -##### Optional - -Parameters can be suffixed with a question mark (`?`) to make the parameter optional. +- **path** String or array of strings. +- **options** _(optional)_ (Extends [pathToRegexp](#pathToRegexp) options) + - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`) ```js -const regexp = pathToRegexp("/:foo/:bar?"); -// keys = [{ name: 'foo', ... }, { name: 'bar', prefix: '/', modifier: '?' }] - -regexp.exec("/test"); -//=> [ '/test', 'test', undefined, index: 0, input: '/test', groups: undefined ] - -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ] +const fn = match("/foo/:bar"); ``` -**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 ] +**Please note:** `path-to-regexp` is intended for ordered data (e.g. paths, hosts). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc). -// This library does not handle query strings in different orders -regexp.exec("/search/people?term=amazing&useIndex=true"); -//=> null -``` +## PathToRegexp -##### Zero or more +The `pathToRegexp` function returns a regular expression for matching strings against paths. It -Parameters can be suffixed with an asterisk (`*`) to denote a zero or more parameter matches. +- **path** String or array of strings. +- **options** _(optional)_ (See [parse](#parse) for more options) + - **sensitive** Regexp will be case sensitive. (default: `false`) + - **end** Validate the match reaches the end of the string. (default: `true`) + - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`) + - **trailing** Allows optional trailing delimiter to match. (default: `true`) ```js -const regexp = pathToRegexp("/:foo*"); -// keys = [{ name: 'foo', prefix: '/', modifier: '*' }] - -regexp.exec("/"); -//=> [ '/', undefined, index: 0, input: '/', groups: undefined ] - -regexp.exec("/bar/baz"); -//=> [ '/bar/baz', 'bar/baz', index: 0, input: '/bar/baz', groups: undefined ] +const { regexp, keys } = pathToRegexp("/foo/:bar"); ``` -##### One or more +## Compile ("Reverse" Path-To-RegExp) -Parameters can be suffixed with a plus sign (`+`) to denote a one or more parameter matches. - -```js -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 ] -``` - -### Match +The `compile` function will return a function for transforming parameters into a valid path: -The `match` function will return a function for transforming paths into parameters: +- **path** A string. +- **options** (See [parse](#parse) for more options) + - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`) + - **encode** Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) ```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é' } } -``` +const toPath = compile("/user/:id"); -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: +toPath({ id: "name" }); //=> "/user/name" +toPath({ id: "café" }); //=> "/user/caf%C3%A9" -```js -const urlMatch = match("/users/:id/:tab(home|photos|bio)", { - decode: decodeURIComponent, -}); +const toPathRepeated = compile("/*segment"); -urlMatch("/users/1234/photos"); -//=> { path: '/users/1234/photos', index: 0, params: { id: '1234', tab: 'photos' } } +toPathRepeated({ segment: ["foo"] }); //=> "/foo" +toPathRepeated({ segment: ["a", "b", "c"] }); //=> "/a/b/c" -urlMatch("/users/1234/bio"); -//=> { path: '/users/1234/bio', index: 0, params: { id: '1234', tab: 'bio' } } +// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted. +const toPathRaw = compile("/user/:id", { encode: false }); -urlMatch("/users/1234/otherstuff"); -//=> false +toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" ``` -#### Process Pathname +## Stringify + +Transform `TokenData` (a sequence of tokens) back into a Path-to-RegExp string. -You should make sure variations of the same path match the expected `path`. Here's one possible solution using `encode`: +- **data** A `TokenData` instance ```js -const fn = match("/café", { encode: encodeURI }); +const data = new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "foo" }, +]); -fn("/caf%C3%A9"); //=> { path: '/caf%C3%A9', index: 0, params: {} } +const path = stringify(data); //=> "/:foo" ``` -**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. +## Developers -##### 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 -``` +- If you are rewriting paths with match and compile, consider using `encode: false` and `decode: false` to keep raw paths passed around. +- To ensure matches work on paths containing characters usually encoded, such as emoji, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`. ### Parse -The `parse` function will return a list of strings and keys from a path string: - -```js -const tokens = parse("/route/:foo/(.*)"); - -console.log(tokens[0]); -//=> "/route" +The `parse` function accepts a string and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can used with `match` and `compile`. -console.log(tokens[1]); -//=> { name: 'foo', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' } +- **path** A string. +- **options** _(optional)_ + - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl)) -console.log(tokens[2]); -//=> { name: 0, prefix: '/', suffix: '', pattern: '.*', modifier: '' } -``` +### Tokens -**Note:** This method only works with strings. +`TokenData` is a sequence of tokens, currently of types `text`, `parameter`, `wildcard`, or `group`. -### Compile ("Reverse" Path-To-RegExp) +### Custom path -The `compile` function will return a function for transforming parameters into a valid path: +In some applications, you may not be able to use the `path-to-regexp` syntax, but still want to use this library for `match` and `compile`. For example: ```js -// Make sure you encode your path segments consistently. -const toPath = compile("/user/:id", { encode: encodeURIComponent }); +import { TokenData, match } from "path-to-regexp"; -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 }); - -toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" -toPathRaw({ id: ":/" }); //=> "/user/:/" +const tokens = [ + { type: "text", value: "/" }, + { type: "parameter", name: "foo" }, +]; +const path = new TokenData(tokens); +const fn = match(path); -const toPathRepeated = compile("/:segment+"); +fn("/test"); //=> { path: '/test', index: 0, params: { foo: 'test' } } +``` -toPathRepeated({ segment: "foo" }); //=> "/foo" -toPathRepeated({ segment: ["a", "b", "c"] }); //=> "/a/b/c" +## Errors -const toPathRegexp = compile("/user/:id(\\d+)"); +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. -toPathRegexp({ id: 123 }); //=> "/user/123" -toPathRegexp({ id: "123" }); //=> "/user/123" -``` +### Unexpected `?` or `+` -**Note:** The generated function will throw on invalid input. +In past releases, `?`, `*`, and `+` were used to denote optional or repeating parameters. As an alternative, try these: -### Working with Tokens +- For optional (`?`), use an empty segment in a group such as `/:file{.:ext}`. +- For repeating (`+`), only wildcard matching is supported, such as `/*path`. +- For optional repeating (`*`), use a group and a wildcard parameter such as `/files{/*path}`. -Path-To-RegExp exposes the two functions used internally that accept an array of tokens: +### Unexpected `(`, `)`, `[`, `]`, etc. -- `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. +Previous versions of Path-to-RegExp used these for RegExp features. This version no longer supports them so they've been reserved to avoid ambiguity. To use these characters literally, escape them with a backslash, e.g. `"\\("`. -#### Token Information +### Missing parameter name -- `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. `?`) +Parameter names, the part after `:` or `*`, must be a valid JavaScript identifier. For example, it cannot start with a number or contain a dash. If you want a parameter name that uses these characters you can wrap the name in quotes, e.g. `:"my-name"`. -## Compatibility with Express <= 4.x +### Unterminated quote -Path-To-RegExp breaks compatibility with Express <= `4.x`: +Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character. -- 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/). +- Regexp characters can no longer be provided. +- The optional character `?` is no longer supported, use braces instead: `/:file{.:ext}`. +- Some characters have new meaning or have been reserved (`{}?*+@!;`). +- The parameter name now supports all JavaScript identifier characters, previously it was only `[a-z0-9]`. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4aa608f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Security contact information + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. diff --git a/package-lock.json b/package-lock.json index eeeb609..ab36824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "path-to-regexp", - "version": "6.2.2", + "version": "8.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "path-to-regexp", - "version": "6.2.2", + "version": "8.1.0", "license": "MIT", "devDependencies": { "@borderless/ts-scripts": "^0.15.0", @@ -14,9 +14,12 @@ "@types/node": "^20.4.9", "@types/semver": "^7.3.1", "@vitest/coverage-v8": "^1.4.0", - "semver": "^7.3.5", + "recheck": "^4.4.5", "size-limit": "^11.1.2", - "typescript": "^5.1.6" + "typescript": "^5.5.3" + }, + "engines": { + "node": ">=16" } }, "node_modules/@ampproject/remapping": { @@ -1192,12 +1195,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1583,9 +1586,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -2682,6 +2685,67 @@ "node": ">=8.10.0" } }, + "node_modules/recheck": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck/-/recheck-4.4.5.tgz", + "integrity": "sha512-J80Ykhr+xxWtvWrfZfPpOR/iw2ijvb4WY8d9AVoN8oHsPP07JT1rCAalUSACMGxM1cvSocb6jppWFjVS6eTTrA==", + "dev": true, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "recheck-jar": "4.4.5", + "recheck-linux-x64": "4.4.5", + "recheck-macos-x64": "4.4.5", + "recheck-windows-x64": "4.4.5" + } + }, + "node_modules/recheck-jar": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-jar/-/recheck-jar-4.4.5.tgz", + "integrity": "sha512-a2kMzcfr+ntT0bObNLY22EUNV6Z6WeZ+DybRmPOUXVWzGcqhRcrK74tpgrYt3FdzTlSh85pqoryAPmrNkwLc0g==", + "dev": true, + "optional": true + }, + "node_modules/recheck-linux-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-linux-x64/-/recheck-linux-x64-4.4.5.tgz", + "integrity": "sha512-s8OVPCpiSGw+tLCxH3eei7Zp2AoL22kXqLmEtWXi0AnYNwfuTjZmeLn2aQjW8qhs8ZPSkxS7uRIRTeZqR5Fv/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/recheck-macos-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-macos-x64/-/recheck-macos-x64-4.4.5.tgz", + "integrity": "sha512-Ouup9JwwoKCDclt3Na8+/W2pVbt8FRpzjkDuyM32qTR2TOid1NI+P1GA6/VQAKEOjvaxgGjxhcP/WqAjN+EULA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/recheck-windows-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-windows-x64/-/recheck-windows-x64-4.4.5.tgz", + "integrity": "sha512-mkpzLHu9G9Ztjx8HssJh9k/Xm1d1d/4OoT7etHqFk+k1NGzISCRXBD22DqYF9w8+J4QEzTAoDf8icFt0IGhOEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -3305,9 +3369,9 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -4390,12 +4454,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "bytes-iec": { @@ -4688,9 +4752,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -5422,6 +5486,46 @@ "picomatch": "^2.2.1" } }, + "recheck": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck/-/recheck-4.4.5.tgz", + "integrity": "sha512-J80Ykhr+xxWtvWrfZfPpOR/iw2ijvb4WY8d9AVoN8oHsPP07JT1rCAalUSACMGxM1cvSocb6jppWFjVS6eTTrA==", + "dev": true, + "requires": { + "recheck-jar": "4.4.5", + "recheck-linux-x64": "4.4.5", + "recheck-macos-x64": "4.4.5", + "recheck-windows-x64": "4.4.5" + } + }, + "recheck-jar": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-jar/-/recheck-jar-4.4.5.tgz", + "integrity": "sha512-a2kMzcfr+ntT0bObNLY22EUNV6Z6WeZ+DybRmPOUXVWzGcqhRcrK74tpgrYt3FdzTlSh85pqoryAPmrNkwLc0g==", + "dev": true, + "optional": true + }, + "recheck-linux-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-linux-x64/-/recheck-linux-x64-4.4.5.tgz", + "integrity": "sha512-s8OVPCpiSGw+tLCxH3eei7Zp2AoL22kXqLmEtWXi0AnYNwfuTjZmeLn2aQjW8qhs8ZPSkxS7uRIRTeZqR5Fv/Q==", + "dev": true, + "optional": true + }, + "recheck-macos-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-macos-x64/-/recheck-macos-x64-4.4.5.tgz", + "integrity": "sha512-Ouup9JwwoKCDclt3Na8+/W2pVbt8FRpzjkDuyM32qTR2TOid1NI+P1GA6/VQAKEOjvaxgGjxhcP/WqAjN+EULA==", + "dev": true, + "optional": true + }, + "recheck-windows-x64": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/recheck-windows-x64/-/recheck-windows-x64-4.4.5.tgz", + "integrity": "sha512-mkpzLHu9G9Ztjx8HssJh9k/Xm1d1d/4OoT7etHqFk+k1NGzISCRXBD22DqYF9w8+J4QEzTAoDf8icFt0IGhOEQ==", + "dev": true, + "optional": true + }, "restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -5860,9 +5964,9 @@ "peer": true }, "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true }, "ufo": { diff --git a/package.json b/package.json index df50fe3..b9a6d43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "path-to-regexp", - "version": "6.2.2", + "version": "8.1.0", "description": "Express style path to RegExp utility", "keywords": [ "express", @@ -13,15 +13,14 @@ "url": "https://github.com/pillarjs/path-to-regexp.git" }, "license": "MIT", - "sideEffects": false, + "exports": "./dist/index.js", "main": "dist/index.js", - "module": "dist.es2015/index.js", "typings": "dist/index.d.ts", "files": [ - "dist.es2015/", "dist/" ], "scripts": { + "bench": "vitest bench", "build": "ts-scripts build", "format": "ts-scripts format", "lint": "ts-scripts lint", @@ -36,27 +35,28 @@ "@types/node": "^20.4.9", "@types/semver": "^7.3.1", "@vitest/coverage-v8": "^1.4.0", - "semver": "^7.3.5", + "recheck": "^4.4.5", "size-limit": "^11.1.2", - "typescript": "^5.1.6" + "typescript": "^5.5.3" + }, + "engines": { + "node": ">=16" }, "publishConfig": { "access": "public" }, "size-limit": [ { - "path": "dist.es2015/index.js", - "limit": "2 kB" + "path": "dist/index.js", + "limit": "2.2 kB" } ], "ts-scripts": { "dist": [ - "dist", - "dist.es2015" + "dist" ], "project": [ - "tsconfig.build.json", - "tsconfig.es2015.json" + "tsconfig.build.json" ] } } diff --git a/scripts/redos.ts b/scripts/redos.ts new file mode 100644 index 0000000..9f0b4bc --- /dev/null +++ b/scripts/redos.ts @@ -0,0 +1,22 @@ +import { checkSync } from "recheck"; +import { pathToRegexp } from "../src/index.js"; +import { MATCH_TESTS } from "../src/cases.spec.js"; + +let safe = 0; +let fail = 0; + +const TESTS = MATCH_TESTS.map((x) => x.path); + +for (const path of TESTS) { + const { regexp } = pathToRegexp(path); + const result = checkSync(regexp.source, regexp.flags); + if (result.status === "safe") { + safe++; + console.log("Safe:", path, String(regexp)); + } else { + fail++; + console.log("Fail:", path, String(regexp)); + } +} + +console.log("Safe:", safe, "Fail:", fail); diff --git a/src/cases.spec.ts b/src/cases.spec.ts new file mode 100644 index 0000000..6a7aeec --- /dev/null +++ b/src/cases.spec.ts @@ -0,0 +1,1612 @@ +import { + type MatchOptions, + type Match, + type ParseOptions, + type Token, + type CompileOptions, + type ParamData, + TokenData, +} from "./index.js"; + +export interface ParserTestSet { + path: string; + options?: ParseOptions; + expected: TokenData; +} + +export interface StringifyTestSet { + data: TokenData; + options?: ParseOptions; + expected: string; +} + +export interface CompileTestSet { + path: string; + options?: CompileOptions & ParseOptions; + tests: Array<{ + input: ParamData | undefined; + expected: string | null; + }>; +} + +export interface MatchTestSet { + path: string; + options?: MatchOptions & ParseOptions; + tests: Array<{ + input: string; + expected: Match; + }>; +} + +export const PARSER_TESTS: ParserTestSet[] = [ + { + path: "/", + expected: new TokenData([{ type: "text", value: "/" }]), + }, + { + path: "/:test", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + ]), + }, + { + path: '/:"0"', + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "0" }, + ]), + }, + { + path: "/:_", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "_" }, + ]), + }, + { + path: "/:café", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "café" }, + ]), + }, + { + path: '/:"123"', + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "123" }, + ]), + }, + { + path: '/:"1\\"\\2\\"3"', + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: '1"2"3' }, + ]), + }, + { + path: "/*path", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "wildcard", name: "path" }, + ]), + }, + { + path: '/:"test"stuff', + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + { type: "text", value: "stuff" }, + ]), + }, +]; + +export const STRINGIFY_TESTS: StringifyTestSet[] = [ + { + data: new TokenData([{ type: "text", value: "/" }]), + expected: "/", + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + ]), + expected: "/:test", + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "café" }, + ]), + expected: "/:café", + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "0" }, + ]), + expected: '/:"0"', + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "wildcard", name: "test" }, + ]), + expected: "/*test", + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "wildcard", name: "0" }, + ]), + expected: '/*"0"', + }, + { + data: new TokenData([ + { type: "text", value: "/users" }, + { + type: "group", + tokens: [ + { type: "text", value: "/" }, + { type: "param", name: "id" }, + ], + }, + { type: "text", value: "/delete" }, + ]), + expected: "/users{/:id}/delete", + }, + { + data: new TokenData([{ type: "text", value: "/:+?*" }]), + expected: "/\\:\\+\\?\\*", + }, + { + data: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + { type: "text", value: "stuff" }, + ]), + expected: '/:"test"stuff', + }, +]; + +export const COMPILE_TESTS: CompileTestSet[] = [ + { + path: "/", + tests: [ + { input: undefined, expected: "/" }, + { input: {}, expected: "/" }, + { input: { id: "123" }, expected: "/" }, + ], + }, + { + path: "/test", + tests: [ + { input: undefined, expected: "/test" }, + { input: {}, expected: "/test" }, + { input: { id: "123" }, expected: "/test" }, + ], + }, + { + path: "/test/", + tests: [ + { input: undefined, expected: "/test/" }, + { input: {}, expected: "/test/" }, + { input: { id: "123" }, expected: "/test/" }, + ], + }, + { + path: '/:"0"', + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { 0: "123" }, expected: "/123" }, + ], + }, + { + path: "/:test", + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { test: "123" }, expected: "/123" }, + { input: { test: "123/xyz" }, expected: "/123%2Fxyz" }, + ], + }, + { + path: "/:test", + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { test: "123" }, expected: "/123" }, + { input: { test: "123/xyz" }, expected: "/123%2Fxyz" }, + ], + }, + { + path: "/:test", + options: { encode: false }, + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { test: "123" }, expected: "/123" }, + { input: { test: "123/xyz" }, expected: "/123/xyz" }, + ], + }, + { + path: "/:test", + options: { encode: () => "static" }, + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { test: "123" }, expected: "/static" }, + { input: { test: "123/xyz" }, expected: "/static" }, + ], + }, + { + path: "{/:test}", + options: { encode: false }, + tests: [ + { input: undefined, expected: "" }, + { input: {}, expected: "" }, + { input: { test: undefined }, expected: "" }, + { input: { test: "123" }, expected: "/123" }, + { input: { test: "123/xyz" }, expected: "/123/xyz" }, + ], + }, + { + path: "/*test", + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { test: [] }, expected: null }, + { input: { test: ["123"] }, expected: "/123" }, + { input: { test: ["123", "xyz"] }, expected: "/123/xyz" }, + ], + }, + { + path: "/*test", + options: { encode: false }, + tests: [ + { input: { test: "123" }, expected: "/123" }, + { input: { test: "123/xyz" }, expected: "/123/xyz" }, + ], + }, +]; + +/** + * An array of test cases with expected inputs and outputs. + */ +export const MATCH_TESTS: MatchTestSet[] = [ + /** + * Simple paths. + */ + { + path: "/", + tests: [ + { + input: "/", + expected: { path: "/", params: {} }, + }, + { input: "/route", expected: false }, + ], + }, + { + path: "/test", + tests: [ + { + input: "/test", + expected: { path: "/test", params: {} }, + }, + { input: "/route", expected: false }, + { input: "/test/route", expected: false }, + { + input: "/test/", + expected: { path: "/test/", params: {} }, + }, + { + input: "/TEST/", + expected: { path: "/TEST/", params: {} }, + }, + ], + }, + { + path: "/test/", + tests: [ + { + input: "/test/", + expected: { path: "/test/", params: {} }, + }, + { input: "/route", expected: false }, + { input: "/test", expected: false }, + { + input: "/test//", + expected: { path: "/test//", params: {} }, + }, + ], + }, + { + path: "/:test", + tests: [ + { + input: "/route", + expected: { path: "/route", params: { test: "route" } }, + }, + { + input: "/route/", + expected: { path: "/route/", params: { test: "route" } }, + }, + { + input: "/route.json", + expected: { + path: "/route.json", + params: { test: "route.json" }, + }, + }, + { + input: "/route.json/", + expected: { + path: "/route.json/", + params: { test: "route.json" }, + }, + }, + { + input: "/route/test", + expected: false, + }, + { + input: "/caf%C3%A9", + expected: { + path: "/caf%C3%A9", + params: { test: "café" }, + }, + }, + { + input: "/;,:@&=+$-_.!~*()", + expected: { + path: "/;,:@&=+$-_.!~*()", + params: { test: ";,:@&=+$-_.!~*()" }, + }, + }, + { + input: "/param%2523", + expected: { + path: "/param%2523", + params: { test: "param%23" }, + }, + }, + ], + }, + + /** + * Case-sensitive paths. + */ + { + path: "/test", + options: { + sensitive: true, + }, + tests: [ + { + input: "/test", + expected: { path: "/test", params: {} }, + }, + { input: "/TEST", expected: false }, + ], + }, + { + path: "/TEST", + options: { + sensitive: true, + }, + tests: [ + { input: "/test", expected: false }, + { + input: "/TEST", + expected: { path: "/TEST", params: {} }, + }, + ], + }, + + /** + * Non-ending mode. + */ + { + path: "/test", + options: { + end: false, + }, + tests: [ + { + input: "/test", + expected: { path: "/test", params: {} }, + }, + { + input: "/test/", + expected: { path: "/test/", params: {} }, + }, + { + input: "/test////", + expected: { path: "/test", params: {} }, + }, + { + input: "/route/test", + expected: false, + }, + { + input: "/test/route", + expected: { path: "/test", params: {} }, + }, + { + input: "/route", + expected: false, + }, + ], + }, + { + path: "/test/", + options: { + end: false, + }, + tests: [ + { + input: "/test", + expected: false, + }, + { + input: "/test/", + expected: { path: "/test/", params: {} }, + }, + { + input: "/test//", + expected: { path: "/test//", params: {} }, + }, + { + input: "/test/route", + expected: false, + }, + { + input: "/route/test/deep", + expected: false, + }, + ], + }, + { + path: "/:test", + options: { + end: false, + }, + tests: [ + { + input: "/route", + expected: { path: "/route", params: { test: "route" } }, + }, + { + input: "/route/", + expected: { path: "/route/", params: { test: "route" } }, + }, + { + input: "/route.json", + expected: { + path: "/route.json", + params: { test: "route.json" }, + }, + }, + { + input: "/route.json/", + expected: { + path: "/route.json/", + params: { test: "route.json" }, + }, + }, + { + input: "/route/test", + expected: { path: "/route", params: { test: "route" } }, + }, + { + input: "/route.json/test", + expected: { + path: "/route.json", + params: { test: "route.json" }, + }, + }, + { + input: "/caf%C3%A9", + expected: { + path: "/caf%C3%A9", + params: { test: "café" }, + }, + }, + ], + }, + { + path: "/:test/", + options: { + end: false, + }, + tests: [ + { + input: "/route", + expected: false, + }, + { + input: "/route/", + expected: { path: "/route/", params: { test: "route" } }, + }, + { + input: "/route/test", + expected: false, + }, + { + input: "/route/test/", + expected: false, + }, + { + input: "/route//test", + expected: { path: "/route/", params: { test: "route" } }, + }, + ], + }, + { + path: "", + options: { + end: false, + }, + tests: [ + { + input: "", + expected: { path: "", params: {} }, + }, + { + input: "/", + expected: { path: "/", params: {} }, + }, + { + input: "route", + expected: false, + }, + { + input: "/route", + expected: { path: "", params: {} }, + }, + { + input: "/route/", + expected: { path: "", params: {} }, + }, + ], + }, + + /** + * Optional. + */ + { + path: "{/route}", + tests: [ + { + input: "", + expected: { path: "", params: {} }, + }, + { + input: "/", + expected: { path: "/", params: {} }, + }, + { + input: "/foo", + expected: false, + }, + { + input: "/route", + expected: { path: "/route", params: {} }, + }, + ], + }, + { + path: "{/:test}", + tests: [ + { + input: "/route", + expected: { path: "/route", params: { test: "route" } }, + }, + { + input: "", + expected: { path: "", params: {} }, + }, + { + input: "/", + expected: { path: "/", params: {} }, + }, + ], + }, + { + path: "{/:test}/bar", + tests: [ + { + input: "/bar", + expected: { path: "/bar", params: {} }, + }, + { + input: "/foo/bar", + expected: { path: "/foo/bar", params: { test: "foo" } }, + }, + { + input: "/foo/bar/", + expected: { path: "/foo/bar/", params: { test: "foo" } }, + }, + ], + }, + { + path: "{/:test}-bar", + tests: [ + { + input: "-bar", + expected: { path: "-bar", params: {} }, + }, + { + input: "/foo-bar", + expected: { path: "/foo-bar", params: { test: "foo" } }, + }, + { + input: "/foo-bar/", + expected: { path: "/foo-bar/", params: { test: "foo" } }, + }, + ], + }, + { + path: "/{:test}-bar", + tests: [ + { + input: "/-bar", + expected: { path: "/-bar", params: {} }, + }, + { + input: "/foo-bar", + expected: { path: "/foo-bar", params: { test: "foo" } }, + }, + { + input: "/foo-bar/", + expected: { path: "/foo-bar/", params: { test: "foo" } }, + }, + ], + }, + + /** + * No prefix characters. + */ + { + path: "test", + tests: [ + { + input: "test", + expected: { path: "test", params: {} }, + }, + { + input: "/test", + expected: false, + }, + ], + }, + { + path: ":test", + tests: [ + { + input: "route", + expected: { path: "route", params: { test: "route" } }, + }, + { + input: "/route", + expected: false, + }, + { + input: "route/", + expected: { path: "route/", params: { test: "route" } }, + }, + ], + }, + { + path: "{:test}", + tests: [ + { + input: "test", + expected: { path: "test", params: { test: "test" } }, + }, + { + input: "", + expected: { path: "", params: {} }, + }, + ], + }, + + /** + * Formats. + */ + { + path: "/test.json", + tests: [ + { + input: "/test.json", + expected: { path: "/test.json", params: {} }, + }, + { + input: "/test", + expected: false, + }, + ], + }, + { + path: "/:test.json", + tests: [ + { + input: "/.json", + expected: false, + }, + { + input: "/test.json", + expected: { path: "/test.json", params: { test: "test" } }, + }, + { + input: "/route.json", + expected: { path: "/route.json", params: { test: "route" } }, + }, + { + input: "/route.json.json", + expected: { path: "/route.json.json", params: { test: "route.json" } }, + }, + ], + }, + + /** + * Format and path params. + */ + { + path: "/:test.:format", + tests: [ + { + input: "/route.html", + expected: { + path: "/route.html", + params: { test: "route", format: "html" }, + }, + }, + { + input: "/route", + expected: false, + }, + { + input: "/route.html.json", + expected: { + path: "/route.html.json", + params: { test: "route.html", format: "json" }, + }, + }, + ], + }, + { + path: "/:test{.:format}", + tests: [ + { + input: "/route", + expected: { path: "/route", params: { test: "route" } }, + }, + { + input: "/route.json", + expected: { + path: "/route.json", + params: { test: "route", format: "json" }, + }, + }, + { + input: "/route.json.html", + expected: { + path: "/route.json.html", + params: { test: "route.json", format: "html" }, + }, + }, + ], + }, + { + path: "/:test.:format\\z", + tests: [ + { + input: "/route.htmlz", + expected: { + path: "/route.htmlz", + params: { test: "route", format: "html" }, + }, + }, + { + input: "/route.html", + expected: false, + }, + ], + }, + + /** + * Escaped characters. + */ + { + path: "/\\(testing\\)", + tests: [ + { + input: "/testing", + expected: false, + }, + { + input: "/(testing)", + expected: { path: "/(testing)", params: {} }, + }, + ], + }, + { + path: "/.\\+\\*\\?\\{\\}=^\\!\\:$\\[\\]\\|", + tests: [ + { + input: "/.+*?{}=^!:$[]|", + expected: { path: "/.+*?{}=^!:$[]|", params: {} }, + }, + ], + }, + + /** + * Random examples. + */ + { + path: "/:foo/:bar", + tests: [ + { + input: "/match/route", + expected: { + path: "/match/route", + params: { foo: "match", bar: "route" }, + }, + }, + ], + }, + { + path: "/:foo\\(test\\)/bar", + tests: [ + { + input: "/foo(test)/bar", + expected: { path: "/foo(test)/bar", params: { foo: "foo" } }, + }, + { + input: "/foo/bar", + expected: false, + }, + ], + }, + { + path: "/:foo\\?", + tests: [ + { + input: "/route?", + expected: { path: "/route?", params: { foo: "route" } }, + }, + { + input: "/route", + expected: false, + }, + ], + }, + { + path: "/{:pre}baz", + tests: [ + { + input: "/foobaz", + expected: { path: "/foobaz", params: { pre: "foo" } }, + }, + { + input: "/baz", + expected: { path: "/baz", params: { pre: undefined } }, + }, + ], + }, + { + path: "/:foo\\(:bar\\)", + tests: [ + { + input: "/hello(world)", + expected: { + path: "/hello(world)", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "/hello()", + expected: false, + }, + ], + }, + { + path: "/:foo\\({:bar}\\)", + tests: [ + { + input: "/hello(world)", + expected: { + path: "/hello(world)", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "/hello()", + expected: { + path: "/hello()", + params: { foo: "hello", bar: undefined }, + }, + }, + ], + }, + { + path: "{/:foo}{/:bar}-ext", + tests: [ + { + input: "/-ext", + expected: false, + }, + { + input: "-ext", + expected: { + path: "-ext", + params: { foo: undefined, bar: undefined }, + }, + }, + { + input: "/foo-ext", + expected: { path: "/foo-ext", params: { foo: "foo" } }, + }, + { + input: "/foo/bar-ext", + expected: { + path: "/foo/bar-ext", + params: { foo: "foo", bar: "bar" }, + }, + }, + { + input: "/foo/-ext", + expected: false, + }, + ], + }, + { + path: "/:required{/:optional}-ext", + tests: [ + { + input: "/foo-ext", + expected: { path: "/foo-ext", params: { required: "foo" } }, + }, + { + input: "/foo/bar-ext", + expected: { + path: "/foo/bar-ext", + params: { required: "foo", optional: "bar" }, + }, + }, + { + input: "/foo/-ext", + expected: false, + }, + ], + }, + + /** + * Unicode matches. + */ + { + path: "/:foo", + tests: [ + { + input: "/café", + expected: { path: "/café", params: { foo: "café" } }, + }, + ], + }, + { + path: "/:foo", + options: { + decode: false, + }, + tests: [ + { + input: "/caf%C3%A9", + expected: { + path: "/caf%C3%A9", + params: { foo: "caf%C3%A9" }, + }, + }, + ], + }, + { + path: "/café", + tests: [ + { + input: "/café", + expected: { path: "/café", params: {} }, + }, + ], + }, + { + path: "/café", + options: { + encodePath: encodeURI, + }, + tests: [ + { + input: "/caf%C3%A9", + expected: { path: "/caf%C3%A9", params: {} }, + }, + ], + }, + + /** + * Hostnames. + */ + { + path: ":domain.com", + options: { + delimiter: ".", + }, + tests: [ + { + input: "example.com", + expected: { + path: "example.com", + params: { domain: "example" }, + }, + }, + { + input: "github.com", + expected: { + path: "github.com", + params: { domain: "github" }, + }, + }, + ], + }, + { + path: "mail.:domain.com", + options: { + delimiter: ".", + }, + tests: [ + { + input: "mail.example.com", + expected: { + path: "mail.example.com", + params: { domain: "example" }, + }, + }, + { + input: "mail.github.com", + expected: { + path: "mail.github.com", + params: { domain: "github" }, + }, + }, + ], + }, + { + path: "mail{.:domain}.com", + options: { + delimiter: ".", + }, + tests: [ + { + input: "mail.com", + expected: { path: "mail.com", params: { domain: undefined } }, + }, + { + input: "mail.example.com", + expected: { + path: "mail.example.com", + params: { domain: "example" }, + }, + }, + { + input: "mail.github.com", + expected: { + path: "mail.github.com", + params: { domain: "github" }, + }, + }, + ], + }, + { + path: "example.:ext", + options: { + delimiter: ".", + }, + tests: [ + { + input: "example.com", + expected: { path: "example.com", params: { ext: "com" } }, + }, + { + input: "example.org", + expected: { path: "example.org", params: { ext: "org" } }, + }, + ], + }, + { + path: "this is", + options: { + delimiter: " ", + end: false, + }, + tests: [ + { + input: "this is a test", + expected: { path: "this is", params: {} }, + }, + { + input: "this isn't", + expected: false, + }, + ], + }, + + /** + * Prefixes. + */ + { + path: "$:foo{$:bar}", + tests: [ + { + input: "$x", + expected: { path: "$x", params: { foo: "x" } }, + }, + { + input: "$x$y", + expected: { path: "$x$y", params: { foo: "x", bar: "y" } }, + }, + ], + }, + { + path: "name{/:attr1}{-:attr2}{-:attr3}", + tests: [ + { + input: "name", + expected: { path: "name", params: {} }, + }, + { + input: "name/test", + expected: { + path: "name/test", + params: { attr1: "test" }, + }, + }, + { + input: "name/1", + expected: { + path: "name/1", + params: { attr1: "1" }, + }, + }, + { + input: "name/1-2", + expected: { + path: "name/1-2", + params: { attr1: "1", attr2: "2" }, + }, + }, + { + input: "name/1-2-3", + expected: { + path: "name/1-2-3", + params: { attr1: "1", attr2: "2", attr3: "3" }, + }, + }, + { + input: "name/foo-bar/route", + expected: false, + }, + { + input: "name/test/route", + expected: false, + }, + ], + }, + + /** + * https://github.com/pillarjs/path-to-regexp/issues/206 + */ + { + path: "/user{s}/:user", + tests: [ + { + input: "/user/123", + expected: { path: "/user/123", params: { user: "123" } }, + }, + { + input: "/users/123", + expected: { path: "/users/123", params: { user: "123" } }, + }, + ], + }, + + /** + * Wildcard. + */ + { + path: "/*path", + tests: [ + { + input: "/", + expected: false, + }, + { + input: "/route", + expected: { path: "/route", params: { path: ["route"] } }, + }, + { + input: "/route/nested", + expected: { + path: "/route/nested", + params: { path: ["route", "nested"] }, + }, + }, + ], + }, + { + path: "*path", + tests: [ + { + input: "/", + expected: { path: "/", params: { path: ["", ""] } }, + }, + { + input: "/test", + expected: { path: "/test", params: { path: ["", "test"] } }, + }, + ], + }, + { + path: "*path", + options: { decode: false }, + tests: [ + { + input: "/", + expected: { path: "/", params: { path: "/" } }, + }, + { + input: "/test", + expected: { path: "/test", params: { path: "/test" } }, + }, + ], + }, + { + path: "/*path.:ext", + tests: [ + { + input: "/test.html", + expected: { + path: "/test.html", + params: { path: ["test"], ext: "html" }, + }, + }, + { + input: "/test.html/nested", + expected: false, + }, + { + input: "/test.html/nested.json", + expected: { + path: "/test.html/nested.json", + params: { path: ["test.html", "nested"], ext: "json" }, + }, + }, + ], + }, + { + path: "/:path.*ext", + tests: [ + { + input: "/test.html", + expected: { + path: "/test.html", + params: { path: "test", ext: ["html"] }, + }, + }, + { + input: "/test.html/nested", + expected: { + path: "/test.html/nested", + params: { path: "test", ext: ["html", "nested"] }, + }, + }, + { + input: "/test.html/nested.json", + expected: { + path: "/test.html/nested.json", + params: { path: "test", ext: ["html", "nested.json"] }, + }, + }, + ], + }, + { + path: "/*path{.:ext}", + tests: [ + { + input: "/test.html", + expected: { + path: "/test.html", + params: { path: ["test"], ext: "html" }, + }, + }, + { + input: "/test.html/nested", + expected: { + params: { + path: ["test.html", "nested"], + }, + path: "/test.html/nested", + }, + }, + ], + }, + { + path: "/entity/:id/*path", + tests: [ + { + input: "/entity/foo", + expected: false, + }, + { + input: "/entity/foo/path", + expected: { + path: "/entity/foo/path", + params: { id: "foo", path: ["path"] }, + }, + }, + ], + }, + { + path: "/*foo/:bar/*baz", + tests: [ + { + input: "/x/y/z", + expected: { + path: "/x/y/z", + params: { foo: ["x"], bar: "y", baz: ["z"] }, + }, + }, + { + input: "/1/2/3/4/5", + expected: { + path: "/1/2/3/4/5", + params: { foo: ["1", "2", "3"], bar: "4", baz: ["5"] }, + }, + }, + ], + }, + + /** + * Longer prefix. + */ + { + path: "/:foo{/test/:bar}", + tests: [ + { + input: "/route", + expected: { path: "/route", params: { foo: "route" } }, + }, + { + input: "/route/test/again", + expected: { + path: "/route/test/again", + params: { foo: "route", bar: "again" }, + }, + }, + ], + }, + + /** + * Backtracking tests. + */ + { + path: "{:foo/}{:bar.}", + tests: [ + { + input: "", + expected: { path: "", params: {} }, + }, + { + input: "test/", + expected: { + path: "test/", + params: { foo: "test" }, + }, + }, + { + input: "a/b.", + expected: { path: "a/b.", params: { foo: "a", bar: "b" } }, + }, + ], + }, + { + path: "/abc{abc:foo}", + tests: [ + { + input: "/abc", + expected: { path: "/abc", params: {} }, + }, + { + input: "/abcabc", + expected: false, + }, + { + input: "/abcabc123", + expected: { path: "/abcabc123", params: { foo: "123" } }, + }, + { + input: "/abcabcabc123", + expected: { + path: "/abcabcabc123", + params: { foo: "abc123" }, + }, + }, + { + input: "/abcabcabc", + expected: { path: "/abcabcabc", params: { foo: "abc" } }, + }, + ], + }, + { + path: "/:foo{abc:bar}", + tests: [ + { + input: "/abc", + expected: { + params: { foo: "abc" }, + path: "/abc", + }, + }, + { + input: "/abcabc", + expected: { + params: { foo: "abcabc" }, + path: "/abcabc", + }, + }, + { + input: "/abcabc123", + expected: { + params: { foo: "abc", bar: "123" }, + path: "/abcabc123", + }, + }, + { + input: "/acb", + expected: { + path: "/acb", + params: { foo: "acb" }, + }, + }, + { + input: "/123", + expected: { + path: "/123", + params: { foo: "123" }, + }, + }, + { + input: "/123abcabc", + expected: { + path: "/123abcabc", + params: { foo: "123abcabc" }, + }, + }, + ], + }, + { + path: "/:foo\\abc:bar", + tests: [ + { + input: "/abc", + expected: false, + }, + { + input: "/abcabc", + expected: false, + }, + { + input: "/abcabc123", + expected: { + path: "/abcabc123", + params: { foo: "abc", bar: "123" }, + }, + }, + { + input: "/123abcabc", + expected: false, + }, + ], + }, + { + path: "/route|:param|", + tests: [ + { + input: "/route|world|", + expected: { + path: "/route|world|", + params: { param: "world" }, + }, + }, + { + input: "/route||", + expected: false, + }, + ], + }, + { + path: "/:foo|:bar|", + tests: [ + { + input: "/hello|world|", + expected: { + path: "/hello|world|", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "/hello||", + expected: false, + }, + ], + }, + { + path: "/:foo{|:bar|}", + tests: [ + { + input: "/hello|world|", + expected: { + path: "/hello|world|", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "/hello||", + expected: { path: "/hello||", params: { foo: "hello||" } }, + }, + ], + }, + { + path: ":foo\\@:bar", + tests: [ + { + input: "x@y", + expected: { path: "x@y", params: { foo: "x", bar: "y" } }, + }, + { + input: "x@", + expected: false, + }, + ], + }, + + /** + * Multi character delimiters. + */ + { + path: "%25:foo{%25:bar}", + options: { + delimiter: "%25", + }, + tests: [ + { + input: "%25hello", + expected: { path: "%25hello", params: { foo: "hello" } }, + }, + { + input: "%25hello%25world", + expected: { + path: "%25hello%25world", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "%25555%25222", + expected: { + path: "%25555%25222", + params: { foo: "555", bar: "222" }, + }, + }, + ], + }, +]; diff --git a/src/index.bench.ts b/src/index.bench.ts new file mode 100644 index 0000000..9d39219 --- /dev/null +++ b/src/index.bench.ts @@ -0,0 +1,42 @@ +import { bench } from "vitest"; +import { match } from "./index.js"; + +const PATHS: string[] = [ + "/xyz", + "/user", + "/user/123", + "/" + "a".repeat(32_000), + "/-" + "-a".repeat(8_000) + "/-", + "/||||\x00|" + "||".repeat(27387) + "|\x00".repeat(27387) + "/||/", +]; + +const STATIC_PATH_MATCH = match("/user"); +const SIMPLE_PATH_MATCH = match("/user/:id"); +const MULTI_SEGMENT_MATCH = match("/:x/:y"); +const MULTI_PATTERN_MATCH = match("/:x-:y"); +const TRICKY_PATTERN_MATCH = match("/:foo|:bar|"); +const ASTERISK_MATCH = match("/*foo"); + +bench("static path", () => { + for (const path of PATHS) STATIC_PATH_MATCH(path); +}); + +bench("simple path", () => { + for (const path of PATHS) SIMPLE_PATH_MATCH(path); +}); + +bench("multi segment", () => { + for (const path of PATHS) MULTI_SEGMENT_MATCH(path); +}); + +bench("multi pattern", () => { + for (const path of PATHS) MULTI_PATTERN_MATCH(path); +}); + +bench("tricky pattern", () => { + for (const path of PATHS) TRICKY_PATTERN_MATCH(path); +}); + +bench("asterisk", () => { + for (const path of PATHS) ASTERISK_MATCH(path); +}); diff --git a/src/index.spec.ts b/src/index.spec.ts index f681e87..cef557f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,2943 +1,141 @@ import { describe, it, expect } from "vitest"; -import * as util from "util"; -import * as pathToRegexp from "./index"; -import semver from "semver"; - -type Test = [ - pathToRegexp.Path, - (pathToRegexp.TokensToRegexpOptions & pathToRegexp.ParseOptions) | undefined, - pathToRegexp.Token[], - Array< - [ - string, - (string | undefined)[] | null, - pathToRegexp.Match?, - pathToRegexp.RegexpToFunctionOptions?, - ] - >, - Array<[any, string | null, pathToRegexp.TokensToFunctionOptions?]>, -]; - -/** - * An array of test cases with expected inputs and outputs. - */ -const TESTS: Test[] = [ - /** - * Simple paths. - */ - [ - "/", - undefined, - ["/"], - [ - ["/", ["/"], { path: "/", index: 0, params: {} }], - ["/route", null, false], - ], - [ - [null, "/"], - [{}, "/"], - [{ id: 123 }, "/"], - ], - ], - [ - "/test", - undefined, - ["/test"], - [ - ["/test", ["/test"], { path: "/test", index: 0, params: {} }], - ["/route", null, false], - ["/test/route", null, false], - ["/test/", ["/test/"], { path: "/test/", index: 0, params: {} }], - ], - [ - [null, "/test"], - [{}, "/test"], - ], - ], - [ - "/test/", - undefined, - ["/test/"], - [ - ["/test", null], - ["/test/", ["/test/"]], - ["/test//", ["/test//"]], - ], - [[null, "/test/"]], - ], - - /** - * Case-sensitive paths. - */ - [ - "/test", - { - sensitive: true, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/TEST", null], - ], - [[null, "/test"]], - ], - [ - "/TEST", - { - sensitive: true, - }, - ["/TEST"], - [ - ["/test", null], - ["/TEST", ["/TEST"]], - ], - [[null, "/TEST"]], - ], - - /** - * Strict mode. - */ - [ - "/test", - { - strict: true, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test/", null], - ["/TEST", ["/TEST"]], - ], - [[null, "/test"]], - ], - [ - "/test/", - { - strict: true, - }, - ["/test/"], - [ - ["/test", null], - ["/test/", ["/test/"]], - ["/test//", null], - ], - [[null, "/test/"]], - ], - - /** - * Non-ending mode. - */ - [ - "/test", - { - end: false, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test/", ["/test/"]], - ["/test/route", ["/test"]], - ["/route", null], - ], - [[null, "/test"]], - ], - [ - "/test/", - { - end: false, - }, - ["/test/"], - [ - ["/test", null], - ["/test/route", ["/test/"]], - ["/test//", ["/test//"]], - ["/test//route", ["/test/"]], - ], - [[null, "/test/"]], - ], - [ - "/:test", - { - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - [ - "/route", - ["/route", "route"], - { path: "/route", index: 0, params: { test: "route" } }, - ], - [ - "/caf%C3%A9", - ["/caf%C3%A9", "caf%C3%A9"], - { path: "/caf%C3%A9", index: 0, params: { test: "caf%C3%A9" } }, - ], - [ - "/caf%C3%A9", - ["/caf%C3%A9", "caf%C3%A9"], - { path: "/caf%C3%A9", index: 0, params: { test: "café" } }, - { decode: decodeURIComponent }, - ], - ], - [ - [{}, null], - [{ test: "abc" }, "/abc"], - [{ test: "a+b" }, "/a+b"], - [{ test: "a+b" }, "/test", { encode: (_, token) => String(token.name) }], - [{ test: "a+b" }, "/a%2Bb", { encode: encodeURIComponent }], - ], - ], - [ - "/:test/", - { - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route", null], - ["/route/", ["/route/", "route"]], - ], - [[{ test: "abc" }, "/abc/"]], - ], - [ - "", - { - end: false, - }, - [], - [ - ["", [""]], - ["/", ["/"]], - ["route", [""]], - ["/route", [""]], - ["/route/", [""]], - ], - [[null, ""]], - ], - - /** - * Non-starting mode. - */ - [ - "/test", - { - start: false, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test/", ["/test/"]], - ["/route/test", ["/test"]], - ["/test/route", null], - ["/route/test/deep", null], - ["/route", null], - ], - [[null, "/test"]], - ], - [ - "/test/", - { - start: false, - }, - ["/test/"], - [ - ["/test", null], - ["/test/route", null], - ["/test//route", null], - ["/test//", ["/test//"]], - ["/route/test/", ["/test/"]], - ], - [[null, "/test/"]], - ], - [ - "/:test", - { - start: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [["/route", ["/route", "route"]]], - [ - [{}, null], - [{ test: "abc" }, "/abc"], - [{ test: "a+b" }, "/a+b"], - [{ test: "a+b" }, "/test", { encode: (_, token) => String(token.name) }], - [{ test: "a+b" }, "/a%2Bb", { encode: encodeURIComponent }], - ], - ], - [ - "/:test/", - { - start: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route", null], - ["/route/", ["/route/", "route"]], - ], - [[{ test: "abc" }, "/abc/"]], - ], - [ - "", - { - start: false, - }, - [], - [ - ["", [""]], - ["/", ["/"]], - ["route", [""]], - ["/route", [""]], - ["/route/", ["/"]], - ], - [[null, ""]], - ], - - /** - * Combine modes. - */ - [ - "/test", - { - end: false, - strict: true, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test/", ["/test"]], - ["/test/route", ["/test"]], - ], - [[null, "/test"]], - ], - [ - "/test/", - { - end: false, - strict: true, - }, - ["/test/"], - [ - ["/test", null], - ["/test/", ["/test/"]], - ["/test//", ["/test/"]], - ["/test/route", ["/test/"]], - ], - [[null, "/test/"]], - ], - [ - "/test.json", - { - end: false, - strict: true, - }, - ["/test.json"], - [ - ["/test.json", ["/test.json"]], - ["/test.json.hbs", null], - ["/test.json/route", ["/test.json"]], - ], - [[null, "/test.json"]], - ], - [ - "/:test", - { - end: false, - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/route/", ["/route", "route"]], - ], - [ - [{}, null], - [{ test: "abc" }, "/abc"], - ], - ], - [ - "/:test/", - { - end: false, - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route", null], - ["/route/", ["/route/", "route"]], - ], - [[{ test: "foobar" }, "/foobar/"]], - ], - [ - "/test", - { - start: false, - end: false, - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test/", ["/test/"]], - ["/test/route", ["/test"]], - ["/route/test/deep", ["/test"]], - ], - [[null, "/test"]], - ], - [ - "/test/", - { - start: false, - end: false, - }, - ["/test/"], - [ - ["/test", null], - ["/test/", ["/test/"]], - ["/test//", ["/test//"]], - ["/test/route", ["/test/"]], - ["/route/test/deep", ["/test/"]], - ], - [[null, "/test/"]], - ], - [ - "/test.json", - { - start: false, - end: false, - }, - ["/test.json"], - [ - ["/test.json", ["/test.json"]], - ["/test.json.hbs", null], - ["/test.json/route", ["/test.json"]], - ["/route/test.json/deep", ["/test.json"]], - ], - [[null, "/test.json"]], - ], - [ - "/:test", - { - start: false, - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/route/", ["/route/", "route"]], - ], - [ - [{}, null], - [{ test: "abc" }, "/abc"], - ], - ], - [ - "/:test/", - { - end: false, - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route", null], - ["/route/", ["/route/", "route"]], - ], - [[{ test: "foobar" }, "/foobar/"]], - ], - - /** - * Arrays of simple paths. - */ - [ - ["/one", "/two"], - undefined, - [], - [ - ["/one", ["/one"]], - ["/two", ["/two"]], - ["/three", null], - ["/one/two", null], - ], - [], - ], - - /** - * Non-ending simple path. - */ - [ - "/test", - { - end: false, - }, - ["/test"], - [["/test/route", ["/test"]]], - [[null, "/test"]], - ], - - /** - * Single named parameter. - */ - [ - "/:test", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/another", ["/another", "another"]], - ["/something/else", null], - ["/route.json", ["/route.json", "route.json"]], - ["/something%2Felse", ["/something%2Felse", "something%2Felse"]], - [ - "/something%2Felse%2Fmore", - ["/something%2Felse%2Fmore", "something%2Felse%2Fmore"], - ], - ["/;,:@&=+$-_.!~*()", ["/;,:@&=+$-_.!~*()", ";,:@&=+$-_.!~*()"]], - ], - [ - [{ test: "route" }, "/route"], - [ - { test: "something/else" }, - "/something%2Felse", - { encode: encodeURIComponent }, - ], - [ - { test: "something/else/more" }, - "/something%2Felse%2Fmore", - { encode: encodeURIComponent }, - ], - ], - ], - [ - "/:test", - { - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/route/", null], - ], - [[{ test: "route" }, "/route"]], - ], - [ - "/:test/", - { - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route/", ["/route/", "route"]], - ["/route//", null], - ], - [[{ test: "route" }, "/route/"]], - ], - [ - "/:test", - { - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route.json", ["/route.json", "route.json"]], - ["/route//", ["/route", "route"]], - ], - [[{ test: "route" }, "/route"]], - ], - - /** - * Optional named parameter. - */ - [ - "/:test?", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - [ - "/route", - ["/route", "route"], - { path: "/route", index: 0, params: { test: "route" } }, - ], - ["/route/nested", null, false], - ["/", ["/", undefined], { path: "/", index: 0, params: {} }], - ["//", null], - ], - [ - [null, ""], - [{ test: "foobar" }, "/foobar"], - ], - ], - [ - "/:test?", - { - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/", null], // Questionable behaviour. - ["//", null], - ], - [ - [null, ""], - [{ test: "foobar" }, "/foobar"], - ], - ], - [ - "/:test?/", - { - strict: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "/", - ], - [ - ["/route", null], - ["/route/", ["/route/", "route"]], - ["/", ["/", undefined]], - ["//", null], - ], - [ - [null, "/"], - [{ test: "foobar" }, "/foobar/"], - ], - ], - [ - "/:test?/bar", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "/bar", - ], - [ - ["/bar", ["/bar", undefined]], - ["/foo/bar", ["/foo/bar", "foo"]], - ], - [ - [null, "/bar"], - [{ test: "foo" }, "/foo/bar"], - ], - ], - [ - "/:test?-bar", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "-bar", - ], - [ - ["-bar", ["-bar", undefined]], - ["/-bar", null], - ["/foo-bar", ["/foo-bar", "foo"]], - ], - [ - [undefined, "-bar"], - [{ test: "foo" }, "/foo-bar"], - ], - ], - [ - "/:test*-bar", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "*", - pattern: "[^\\/#\\?]+?", - }, - "-bar", - ], - [ - ["-bar", ["-bar", undefined]], - ["/-bar", null], - ["/foo-bar", ["/foo-bar", "foo"]], - ["/foo/baz-bar", ["/foo/baz-bar", "foo/baz"]], - ], - [[{ test: "foo" }, "/foo-bar"]], - ], - - /** - * Repeated one or more times parameters. - */ - [ - "/:test+", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "+", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/", null, false], - [ - "/route", - ["/route", "route"], - { path: "/route", index: 0, params: { test: ["route"] } }, - ], - [ - "/some/basic/route", - ["/some/basic/route", "some/basic/route"], - { - path: "/some/basic/route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - ], - ["//", null, false], - ], - [ - [{}, null], - [{ test: "foobar" }, "/foobar"], - [{ test: ["a", "b", "c"] }, "/a/b/c"], - ], - ], - [ - "/:test(\\d+)+", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "+", - pattern: "\\d+", - }, - ], - [ - ["/abc/456/789", null], - ["/123/456/789", ["/123/456/789", "123/456/789"]], - ], - [ - [{ test: "abc" }, null], - [{ test: 123 }, "/123"], - [{ test: [1, 2, 3] }, "/1/2/3"], - ], - ], - [ - "/route.:ext(json|xml)+", - undefined, - [ - "/route", - { - name: "ext", - prefix: ".", - suffix: "", - modifier: "+", - pattern: "json|xml", - }, - ], - [ - ["/route", null], - ["/route.json", ["/route.json", "json"]], - ["/route.xml.json", ["/route.xml.json", "xml.json"]], - ["/route.html", null], - ], - [ - [{ ext: "foobar" }, null], - [{ ext: "xml" }, "/route.xml"], - [{ ext: ["xml", "json"] }, "/route.xml.json"], - ], - ], - [ - "/route.:ext(\\w+)/test", - undefined, - [ - "/route", - { - name: "ext", - prefix: ".", - suffix: "", - modifier: "", - pattern: "\\w+", - }, - "/test", - ], - [ - ["/route", null], - ["/route.json", null], - ["/route.xml/test", ["/route.xml/test", "xml"]], - ["/route.json.gz/test", null], - ], - [[{ ext: "xml" }, "/route.xml/test"]], - ], - - /** - * Repeated zero or more times parameters. - */ - [ - "/:test*", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "*", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/", ["/", undefined], { path: "/", index: 0, params: {} }], - ["//", null, false], - [ - "/route", - ["/route", "route"], - { path: "/route", index: 0, params: { test: ["route"] } }, - ], - [ - "/some/basic/route", - ["/some/basic/route", "some/basic/route"], - { - path: "/some/basic/route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - ], - ], - [ - [{}, ""], - [{ test: [] }, ""], - [{ test: "foobar" }, "/foobar"], - [{ test: ["foo", "bar"] }, "/foo/bar"], - ], - ], - [ - "/route.:ext([a-z]+)*", - undefined, - [ - "/route", - { - name: "ext", - prefix: ".", - suffix: "", - modifier: "*", - pattern: "[a-z]+", - }, - ], - [ - ["/route", ["/route", undefined]], - ["/route.json", ["/route.json", "json"]], - ["/route.json.xml", ["/route.json.xml", "json.xml"]], - ["/route.123", null], - ], - [ - [{}, "/route"], - [{ ext: [] }, "/route"], - [{ ext: "123" }, null], - [{ ext: "foobar" }, "/route.foobar"], - [{ ext: ["foo", "bar"] }, "/route.foo.bar"], - ], - ], - - /** - * Custom named parameters. - */ - [ - "/:test(\\d+)", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - ], - [ - ["/123", ["/123", "123"]], - ["/abc", null], - ["/123/abc", null], - ], - [ - [{ test: "abc" }, null], - [{ test: "abc" }, "/abc", { validate: false }], - [{ test: "123" }, "/123"], - ], - ], - [ - "/:test(\\d+)", - { - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - ], - [ - ["/123", ["/123", "123"]], - ["/abc", null], - ["/123/abc", ["/123", "123"]], - ], - [[{ test: "123" }, "/123"]], - ], - [ - "/:test(.*)", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: ".*", - }, - ], - [ - ["/anything/goes/here", ["/anything/goes/here", "anything/goes/here"]], - ["/;,:@&=/+$-_.!/~*()", ["/;,:@&=/+$-_.!/~*()", ";,:@&=/+$-_.!/~*()"]], - ], - [ - [{ test: "" }, "/"], - [{ test: "abc" }, "/abc"], - [{ test: "abc/123" }, "/abc%2F123", { encode: encodeURIComponent }], - [ - { test: "abc/123/456" }, - "/abc%2F123%2F456", - { encode: encodeURIComponent }, - ], - ], - ], - [ - "/:route([a-z]+)", - undefined, - [ - { - name: "route", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[a-z]+", - }, - ], - [ - ["/abcde", ["/abcde", "abcde"]], - ["/12345", null], - ], - [ - [{ route: "" }, null], - [{ route: "" }, "/", { validate: false }], - [{ route: "123" }, null], - [{ route: "123" }, "/123", { validate: false }], - [{ route: "abc" }, "/abc"], - ], - ], - [ - "/:route(this|that)", - undefined, - [ - { - name: "route", - prefix: "/", - suffix: "", - modifier: "", - pattern: "this|that", - }, - ], - [ - ["/this", ["/this", "this"]], - ["/that", ["/that", "that"]], - ["/foo", null], - ], - [ - [{ route: "this" }, "/this"], - [{ route: "foo" }, null], - [{ route: "foo" }, "/foo", { validate: false }], - [{ route: "that" }, "/that"], - ], - ], - [ - "/:path(abc|xyz)*", - undefined, - [ - { - name: "path", - prefix: "/", - suffix: "", - modifier: "*", - pattern: "abc|xyz", - }, - ], - [ - ["/abc", ["/abc", "abc"]], - ["/abc/abc", ["/abc/abc", "abc/abc"]], - ["/xyz/xyz", ["/xyz/xyz", "xyz/xyz"]], - ["/abc/xyz", ["/abc/xyz", "abc/xyz"]], - ["/abc/xyz/abc/xyz", ["/abc/xyz/abc/xyz", "abc/xyz/abc/xyz"]], - ["/xyzxyz", null], - ], - [ - [{ path: "abc" }, "/abc"], - [{ path: ["abc", "xyz"] }, "/abc/xyz"], - [{ path: ["xyz", "abc", "xyz"] }, "/xyz/abc/xyz"], - [{ path: "abc123" }, null], - [{ path: "abc123" }, "/abc123", { validate: false }], - [{ path: "abcxyz" }, null], - [{ path: "abcxyz" }, "/abcxyz", { validate: false }], - ], - ], - - /** - * Prefixed slashes could be omitted. - */ - [ - "test", - undefined, - ["test"], - [ - ["test", ["test"]], - ["/test", null], - ], - [[null, "test"]], - ], - [ - ":test", - undefined, - [ - { - name: "test", - prefix: "", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["route", ["route", "route"]], - ["/route", null], - ["route/", ["route/", "route"]], - ], - [ - [{ test: "" }, null], - [{}, null], - [{ test: null }, null], - [{ test: "route" }, "route"], - ], - ], - [ - ":test", - { - strict: true, - }, - [ - { - name: "test", - prefix: "", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["route", ["route", "route"]], - ["/route", null], - ["route/", null], - ], - [[{ test: "route" }, "route"]], - ], - [ - ":test", - { - end: false, - }, - [ - { - name: "test", - prefix: "", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["route", ["route", "route"]], - ["/route", null], - ["route/", ["route/", "route"]], - ["route/foobar", ["route", "route"]], - ], - [[{ test: "route" }, "route"]], - ], - [ - ":test?", - undefined, - [ - { - name: "test", - prefix: "", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["route", ["route", "route"]], - ["/route", null], - ["", ["", undefined]], - ["route/foobar", null], - ], - [ - [{}, ""], - [{ test: "" }, null], - [{ test: "route" }, "route"], - ], - ], - [ - "{:test/}+", - undefined, - [ - { - name: "test", - prefix: "", - suffix: "/", - modifier: "+", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["route/", ["route/", "route"]], - ["/route", null], - ["", null], - ["foo/bar/", ["foo/bar/", "foo/bar"]], - ], - [ - [{}, null], - [{ test: "" }, null], - [{ test: ["route"] }, "route/"], - [{ test: ["foo", "bar"] }, "foo/bar/"], - ], - ], - - /** - * Formats. - */ - [ - "/test.json", - undefined, - ["/test.json"], - [ - ["/test.json", ["/test.json"]], - ["/route.json", null], - ], - [[{}, "/test.json"]], - ], - [ - "/:test.json", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ".json", - ], - [ - ["/.json", null], - ["/test.json", ["/test.json", "test"]], - ["/route.json", ["/route.json", "route"]], - ["/route.json.json", ["/route.json.json", "route.json"]], - ], - [ - [{ test: "" }, null], - [{ test: "foo" }, "/foo.json"], - ], - ], - - /** - * Format params. - */ - [ - "/test.:format(\\w+)", - undefined, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "\\w+", - }, - ], - [ - ["/test.html", ["/test.html", "html"]], - ["/test.hbs.html", null], - ], - [ - [{}, null], - [{ format: "" }, null], - [{ format: "foo" }, "/test.foo"], - ], - ], - [ - "/test.:format(\\w+).:format(\\w+)", - undefined, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "\\w+", - }, - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "\\w+", - }, - ], - [ - ["/test.html", null], - ["/test.hbs.html", ["/test.hbs.html", "hbs", "html"]], - ], - [ - [{ format: "foo.bar" }, null], - [{ format: "foo" }, "/test.foo.foo"], - ], - ], - [ - "/test{.:format}+", - undefined, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "+", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/test.html", ["/test.html", "html"]], - ["/test.hbs.html", ["/test.hbs.html", "hbs.html"]], - ], - [ - [{ format: [] }, null], - [{ format: "foo" }, "/test.foo"], - [{ format: ["foo", "bar"] }, "/test.foo.bar"], - ], - ], - [ - "/test.:format(\\w+)", - { - end: false, - }, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "\\w+", - }, - ], - [ - ["/test.html", ["/test.html", "html"]], - ["/test.hbs.html", null], - ], - [[{ format: "foo" }, "/test.foo"]], - ], - [ - "/test.:format.", - undefined, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ".", - ], - [ - ["/test.html.", ["/test.html.", "html"]], - ["/test.hbs.html", null], - ], - [ - [{ format: "" }, null], - [{ format: "foo" }, "/test.foo."], - ], - ], - - /** - * Format and path params. - */ - [ - "/:test.:format", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route.html", ["/route.html", "route", "html"]], - ["/route", null], - ["/route.html.json", ["/route.html.json", "route", "html.json"]], - ], - [ - [{}, null], - [{ test: "route", format: "foo" }, "/route.foo"], - ], - ], - [ - "/:test{.:format}?", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "format", - prefix: ".", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route", undefined]], - ["/route.json", ["/route.json", "route", "json"]], - ["/route.json.html", ["/route.json.html", "route", "json.html"]], - ], - [ - [{ test: "route" }, "/route"], - [{ test: "route", format: "" }, null], - [{ test: "route", format: "foo" }, "/route.foo"], - ], - ], - [ - "/:test.:format?", - { - end: false, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "format", - prefix: ".", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/route", ["/route", "route", undefined]], - ["/route.json", ["/route.json", "route", "json"]], - ["/route.json.html", ["/route.json.html", "route", "json.html"]], - ], - [ - [{ test: "route" }, "/route"], - [{ test: "route", format: undefined }, "/route"], - [{ test: "route", format: "" }, null], - [{ test: "route", format: "foo" }, "/route.foo"], - ], - ], - [ - "/test.:format(.*)z", - { - end: false, - }, - [ - "/test", - { - name: "format", - prefix: ".", - suffix: "", - modifier: "", - pattern: ".*", - }, - "z", - ], - [ - ["/test.abc", null], - ["/test.z", ["/test.z", ""]], - ["/test.abcz", ["/test.abcz", "abc"]], - ], - [ - [{}, null], - [{ format: "" }, "/test.z"], - [{ format: "foo" }, "/test.fooz"], - ], - ], - - /** - * Unnamed params. - */ - [ - "/(\\d+)", - undefined, - [ - { - name: 0, - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - ], - [ - ["/123", ["/123", "123"]], - ["/abc", null], - ["/123/abc", null], - ], - [ - [{}, null], - [{ "0": "123" }, "/123"], - ], - ], - [ - "/(\\d+)", - { - end: false, - }, - [ - { - name: 0, - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - ], - [ - ["/123", ["/123", "123"]], - ["/abc", null], - ["/123/abc", ["/123", "123"]], - ["/123/", ["/123/", "123"]], - ], - [[{ "0": "123" }, "/123"]], - ], - [ - "/(\\d+)?", - undefined, - [ - { - name: 0, - prefix: "/", - suffix: "", - modifier: "?", - pattern: "\\d+", - }, - ], - [ - ["/", ["/", undefined]], - ["/123", ["/123", "123"]], - ], - [ - [{}, ""], - [{ "0": "123" }, "/123"], - ], - ], - [ - "/(.*)", - undefined, - [ - { - name: 0, - prefix: "/", - suffix: "", - modifier: "", - pattern: ".*", - }, - ], - [ - ["/", ["/", ""]], - ["/route", ["/route", "route"]], - ["/route/nested", ["/route/nested", "route/nested"]], - ], - [ - [{ "0": "" }, "/"], - [{ "0": "123" }, "/123"], - ], - ], - [ - "/route\\(\\\\(\\d+\\\\)\\)", - undefined, - [ - "/route(\\", - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "\\d+\\\\", - }, - ")", - ], - [["/route(\\123\\)", ["/route(\\123\\)", "123\\"]]], - [[["123\\"], "/route(\\123\\)"]], - ], - [ - "{/login}?", - undefined, - [ - { - name: "", - prefix: "/login", - suffix: "", - modifier: "?", - pattern: "", - }, - ], - [ - ["/", ["/"]], - ["/login", ["/login"]], - ], - [ - [null, ""], - [{ "": "" }, "/login"], - ], - ], - [ - "{/login}", - undefined, - [ - { - name: "", - prefix: "/login", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/", null], - ["/login", ["/login"]], - ], - [[{ "": "" }, "/login"]], - ], - [ - "{/(.*)}", - undefined, - [ - { - name: 0, - prefix: "/", - suffix: "", - modifier: "", - pattern: ".*", - }, - ], - [ - ["/", ["/", ""]], - ["/login", ["/login", "login"]], - ], - [[{ 0: "test" }, "/test"]], - ], - - /** - * Regexps. - */ - [/.*/, undefined, [], [["/match/anything", ["/match/anything"]]], []], - [ - /(.*)/, - undefined, - [ - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [["/match/anything", ["/match/anything", "/match/anything"]]], - [], - ], - [ - /\/(\d+)/, - undefined, - [ - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/abc", null], - ["/123", ["/123", "123"]], - ], - [], - ], - - /** - * Mixed arrays. - */ - [ - ["/test", /\/(\d+)/], - undefined, - [ - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [["/test", ["/test", undefined]]], - [], - ], - [ - ["/:test(\\d+)", /(.*)/], - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/123", ["/123", "123", undefined]], - ["/abc", ["/abc", undefined, "/abc"]], - ], - [], - ], - - /** - * Correct names and indexes. - */ - [ - ["/:test", "/route/:test"], - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/test", ["/test", "test", undefined]], - ["/route/test", ["/route/test", undefined, "test"]], - ], - [], - ], - [ - [/^\/([^/]+)$/, /^\/route\/([^/]+)$/], - undefined, - [ - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/test", ["/test", "test", undefined]], - ["/route/test", ["/route/test", undefined, "test"]], - ], - [], - ], - - /** - * Ignore non-matching groups in regexps. - */ - [ - /(?:.*)/, - undefined, - [], - [["/anything/you/want", ["/anything/you/want"]]], - [], - ], - - /** - * Respect escaped characters. - */ - [ - "/\\(testing\\)", - undefined, - ["/(testing)"], - [ - ["/testing", null], - ["/(testing)", ["/(testing)"]], - ], - [[null, "/(testing)"]], - ], - [ - "/.\\+\\*\\?\\{\\}=^!\\:$[]|", - undefined, - ["/.+*?{}=^!:$[]|"], - [["/.+*?{}=^!:$[]|", ["/.+*?{}=^!:$[]|"]]], - [[null, "/.+*?{}=^!:$[]|"]], - ], - [ - "/test\\/:uid(u\\d+)?:cid(c\\d+)?", - undefined, - [ - "/test/", - { - name: "uid", - prefix: "", - suffix: "", - modifier: "?", - pattern: "u\\d+", - }, - { - name: "cid", - prefix: "", - suffix: "", - modifier: "?", - pattern: "c\\d+", - }, - ], - [ - ["/test", null], - ["/test/", ["/test/", undefined, undefined]], - ["/test/u123", ["/test/u123", "u123", undefined]], - ["/test/c123", ["/test/c123", undefined, "c123"]], - ], - [ - [{ uid: "u123" }, "/test/u123"], - [{ cid: "c123" }, "/test/c123"], - [{ cid: "u123" }, null], - ], - ], - - /** - * Unnamed group prefix. - */ - [ - "/{apple-}?icon-:res(\\d+).png", - undefined, - [ - "/", - { - name: "", - prefix: "apple-", - suffix: "", - modifier: "?", - pattern: "", - }, - "icon-", - { - name: "res", - prefix: "", - suffix: "", - modifier: "", - pattern: "\\d+", - }, - ".png", - ], - [ - ["/icon-240.png", ["/icon-240.png", "240"]], - ["/apple-icon-240.png", ["/apple-icon-240.png", "240"]], - ], - [[{ res: "240" }, "/icon-240.png"]], - ], - - /** - * Random examples. - */ - [ - "/:foo/:bar", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "bar", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [["/match/route", ["/match/route", "match", "route"]]], - [[{ foo: "a", bar: "b" }, "/a/b"]], - ], - [ - "/:foo\\(test\\)/bar", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "(test)/bar", - ], - [ - ["/foo(test)/bar", ["/foo(test)/bar", "foo"]], - ["/another/bar", null], - ], - [[{ foo: "foo" }, "/foo(test)/bar"]], - ], - [ - "/:remote([\\w-.]+)/:user([\\w-]+)", - undefined, - [ - { - name: "remote", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[\\w-.]+", - }, - { - name: "user", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[\\w-]+", - }, - ], - [ - ["/endpoint/user", ["/endpoint/user", "endpoint", "user"]], - ["/endpoint/user-name", ["/endpoint/user-name", "endpoint", "user-name"]], - ["/foo.bar/user-name", ["/foo.bar/user-name", "foo.bar", "user-name"]], - ], - [ - [{ remote: "foo", user: "bar" }, "/foo/bar"], - [{ remote: "foo.bar", user: "uno" }, "/foo.bar/uno"], - ], - ], - [ - "/:foo\\?", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "?", - ], - [["/route?", ["/route?", "route"]]], - [[{ foo: "bar" }, "/bar?"]], - ], - [ - "/:foo+baz", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "+", - pattern: "[^\\/#\\?]+?", - }, - "baz", - ], - [ - ["/foobaz", ["/foobaz", "foo"]], - ["/foo/barbaz", ["/foo/barbaz", "foo/bar"]], - ["/baz", null], - ], - [ - [{ foo: "foo" }, "/foobaz"], - [{ foo: "foo/bar" }, "/foo%2Fbarbaz", { encode: encodeURIComponent }], - [{ foo: ["foo", "bar"] }, "/foo/barbaz"], - ], - ], - [ - "\\/:pre?baz", - undefined, - [ - "/", - { - name: "pre", - prefix: "", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "baz", - ], - [ - ["/foobaz", ["/foobaz", "foo"]], - ["/baz", ["/baz", undefined]], - ], - [ - [{}, "/baz"], - [{ pre: "foo" }, "/foobaz"], - ], - ], - [ - "/:foo\\(:bar?\\)", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "(", - { - name: "bar", - prefix: "", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - ")", - ], - [ - ["/hello(world)", ["/hello(world)", "hello", "world"]], - ["/hello()", ["/hello()", "hello", undefined]], - ], - [ - [{ foo: "hello", bar: "world" }, "/hello(world)"], - [{ foo: "hello" }, "/hello()"], - ], - ], - [ - "/:postType(video|audio|text)(\\+.+)?", - undefined, - [ - { - name: "postType", - prefix: "/", - suffix: "", - modifier: "", - pattern: "video|audio|text", - }, - { - name: 0, - prefix: "", - suffix: "", - modifier: "?", - pattern: "\\+.+", - }, - ], - [ - ["/video", ["/video", "video", undefined]], - ["/video+test", ["/video+test", "video", "+test"]], - ["/video+", null], - ], - [ - [{ postType: "video" }, "/video"], - [{ postType: "random" }, null], - ], - ], - [ - "/:foo?/:bar?-ext", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - { - name: "bar", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "-ext", - ], - [ - ["/-ext", null], - ["-ext", ["-ext", undefined, undefined]], - ["/foo-ext", ["/foo-ext", "foo", undefined]], - ["/foo/bar-ext", ["/foo/bar-ext", "foo", "bar"]], - ["/foo/-ext", null], - ], - [ - [{}, "-ext"], - [{ foo: "foo" }, "/foo-ext"], - [{ bar: "bar" }, "/bar-ext"], - [{ foo: "foo", bar: "bar" }, "/foo/bar-ext"], - ], - ], - [ - "/:required/:optional?-ext", - undefined, - [ - { - name: "required", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - { - name: "optional", - prefix: "/", - suffix: "", - modifier: "?", - pattern: "[^\\/#\\?]+?", - }, - "-ext", - ], - [ - ["/foo-ext", ["/foo-ext", "foo", undefined]], - ["/foo/bar-ext", ["/foo/bar-ext", "foo", "bar"]], - ["/foo/-ext", null], - ], - [[{ required: "foo" }, "/foo-ext"]], - ], - - /** - * Unicode characters. - */ - [ - "/:foo", - undefined, - [ - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [["/café", ["/café", "café"]]], - [ - [{ foo: "café" }, "/café"], - [{ foo: "café" }, "/caf%C3%A9", { encode: encodeURIComponent }], - ], - ], - ["/café", undefined, ["/café"], [["/café", ["/café"]]], [[null, "/café"]]], - [ - "/café", - { encode: encodeURI }, - ["/café"], - [["/caf%C3%A9", ["/caf%C3%A9"]]], - [[null, "/café"]], - ], - [ - "packages/", - undefined, - ["packages/"], - [ - ["packages", null], - ["packages/", ["packages/"]], - ], - [[null, "packages/"]], - ], - - /** - * Hostnames. - */ - [ - ":domain.com", - { - delimiter: ".", - }, - [ - { - name: "domain", - prefix: "", - suffix: "", - modifier: "", - pattern: "[^\\.]+?", - }, - ".com", - ], - [ - ["example.com", ["example.com", "example"]], - ["github.com", ["github.com", "github"]], - ], - [ - [{ domain: "example" }, "example.com"], - [{ domain: "github" }, "github.com"], - ], - ], - [ - "mail.:domain.com", - { - delimiter: ".", - }, - [ - "mail", - { - name: "domain", - prefix: ".", - suffix: "", - modifier: "", - pattern: "[^\\.]+?", - }, - ".com", - ], - [ - ["mail.example.com", ["mail.example.com", "example"]], - ["mail.github.com", ["mail.github.com", "github"]], - ], - [ - [{ domain: "example" }, "mail.example.com"], - [{ domain: "github" }, "mail.github.com"], - ], - ], - [ - "example.:ext", - {}, - [ - "example", - { - name: "ext", - prefix: ".", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["example.com", ["example.com", "com"]], - ["example.org", ["example.org", "org"]], - ], - [ - [{ ext: "com" }, "example.com"], - [{ ext: "org" }, "example.org"], - ], - ], - [ - "this is", - { - delimiter: " ", - end: false, - }, - ["this is"], - [ - ["this is a test", ["this is"]], - ["this isn't", null], - ], - [[null, "this is"]], - ], - - /** - * Ends with. - */ - [ - "/test", - { - endsWith: "?", - }, - ["/test"], - [ - ["/test", ["/test"]], - ["/test?query=string", ["/test"]], - ["/test/?query=string", ["/test/"]], - ["/testx", null], - ], - [[null, "/test"]], - ], - [ - "/test", - { - endsWith: "?", - strict: true, - }, - ["/test"], - [ - ["/test?query=string", ["/test"]], - ["/test/?query=string", null], - ], - [[null, "/test"]], - ], - - /** - * Custom prefixes. - */ - [ - "{$:foo}{$:bar}?", - {}, - [ - { - name: "foo", - pattern: "[^\\/#\\?]+?", - prefix: "$", - suffix: "", - modifier: "", - }, - { - name: "bar", - pattern: "[^\\/#\\?]+?", - prefix: "$", - suffix: "", - modifier: "?", - }, - ], - [ - ["$x", ["$x", "x", undefined]], - ["$x$y", ["$x$y", "x", "y"]], - ], - [ - [{ foo: "foo" }, "$foo"], - [{ foo: "foo", bar: "bar" }, "$foo$bar"], - ], - ], - [ - "name/:attr1?{-:attr2}?{-:attr3}?", - {}, - [ - "name", - { - name: "attr1", - pattern: "[^\\/#\\?]+?", - prefix: "/", - suffix: "", - modifier: "?", - }, - { - name: "attr2", - pattern: "[^\\/#\\?]+?", - prefix: "-", - suffix: "", - modifier: "?", - }, - { - name: "attr3", - pattern: "[^\\/#\\?]+?", - prefix: "-", - suffix: "", - modifier: "?", - }, - ], - [ - ["name/test", ["name/test", "test", undefined, undefined]], - ["name/1", ["name/1", "1", undefined, undefined]], - ["name/1-2", ["name/1-2", "1", "2", undefined]], - ["name/1-2-3", ["name/1-2-3", "1", "2", "3"]], - ["name/foo-bar/route", null], - ["name/test/route", null], - ], - [ - [{}, "name"], - [{ attr1: "test" }, "name/test"], - [{ attr2: "attr" }, "name-attr"], - ], - ], - - /** - * Case-sensitive compile tokensToFunction params. - */ - [ - "/:test(abc)", - { - sensitive: true, - }, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "abc", - }, - ], - [ - ["/abc", ["/abc", "abc"]], - ["/ABC", null], - ], - [ - [{ test: "abc" }, "/abc"], - [{ test: "ABC" }, null], - ], - ], - [ - "/:test(abc)", - {}, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "abc", - }, - ], - [ - ["/abc", ["/abc", "abc"]], - ["/ABC", ["/ABC", "ABC"]], - ], - [ - [{ test: "abc" }, "/abc"], - [{ test: "ABC" }, "/ABC"], - ], - ], - - /** - * Nested parentheses. - */ - [ - "/:test(\\d+(?:\\.\\d+)?)", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "\\d+(?:\\.\\d+)?", - }, - ], - [ - ["/123", ["/123", "123"]], - ["/abc", null], - ["/123/abc", null], - ["/123.123", ["/123.123", "123.123"]], - ["/123.abc", null], - ], - [ - [{ test: 123 }, "/123"], - [{ test: 123.123 }, "/123.123"], - [{ test: "abc" }, null], - [{ test: "123" }, "/123"], - [{ test: "123.123" }, "/123.123"], - [{ test: "123.abc" }, null], - ], - ], - [ - "/:test((?!login)[^/]+)", - undefined, - [ - { - name: "test", - prefix: "/", - suffix: "", - modifier: "", - pattern: "(?!login)[^/]+", - }, - ], - [ - ["/route", ["/route", "route"]], - ["/login", null], - ], - [ - [{ test: "route" }, "/route"], - [{ test: "login" }, null], - ], - ], - - /** - * https://github.com/pillarjs/path-to-regexp/issues/206 - */ - [ - "/user(s)?/:user", - undefined, - [ - "/user", - { - name: 0, - prefix: "", - suffix: "", - modifier: "?", - pattern: "s", - }, - { - name: "user", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/user/123", ["/user/123", undefined, "123"]], - ["/users/123", ["/users/123", "s", "123"]], - ], - [[{ user: "123" }, "/user/123"]], - ], - - /** - * https://github.com/pillarjs/path-to-regexp/issues/209 - */ - [ - "/whatever/:foo\\?query=str", - undefined, - [ - "/whatever", - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - "?query=str", - ], - [["/whatever/123?query=str", ["/whatever/123?query=str", "123"]]], - [[{ foo: "123" }, "/whatever/123?query=str"]], - ], - [ - "/whatever/:foo", - { - end: false, - }, - [ - "/whatever", - { - name: "foo", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }, - ], - [ - ["/whatever/123", ["/whatever/123", "123"]], - ["/whatever/123/path", ["/whatever/123", "123"]], - ["/whatever/123#fragment", ["/whatever/123", "123"]], - ["/whatever/123?query=str", ["/whatever/123", "123"]], - ], - [ - [{ foo: "123" }, "/whatever/123"], - [{ foo: "#" }, null], - ], - ], - /** - * https://github.com/pillarjs/path-to-regexp/issues/260 - */ - [ - ":name*", - undefined, - [ - { - name: "name", - prefix: "", - suffix: "", - modifier: "*", - pattern: "[^\\/#\\?]+?", - }, - ], - [["foobar", ["foobar", "foobar"]]], - [[{ name: "foobar" }, "foobar"]], - ], - [ - ":name+", - undefined, - [ - { - name: "name", - prefix: "", - suffix: "", - modifier: "+", - pattern: "[^\\/#\\?]+?", - }, - ], - [["foobar", ["foobar", "foobar"]]], - [[{ name: "foobar" }, "foobar"]], - ], -]; - -/** - * Named capturing groups (available from 1812 version 10) - */ -if (semver.gte(process.version, "10.0.0")) { - TESTS.push( - [ - /\/(?.+)/, - undefined, - [ - { - name: "groupname", - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/", null], - ["/foo", ["/foo", "foo"]], - ], - [], - ], - [ - /\/(?.*).(?html|json)/, - undefined, - [ - { - name: "test", - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - { - name: "format", - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/route", null], - ["/route.txt", null], - ["/route.html", ["/route.html", "route", "html"]], - ["/route.json", ["/route.json", "route", "json"]], - ], - [], - ], - [ - /\/(.+)\/(?.+)\/(.+)/, - undefined, - [ - { - name: 0, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - { - name: "groupname", - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - { - name: 1, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }, - ], - [ - ["/test", null], - ["/test/testData", null], - [ - "/test/testData/extraStuff", - ["/test/testData/extraStuff", "test", "testData", "extraStuff"], - ], - ], - [], - ], - ); -} +import { parse, compile, match, stringify } from "./index.js"; +import { + PARSER_TESTS, + COMPILE_TESTS, + MATCH_TESTS, + STRINGIFY_TESTS, +} from "./cases.spec.js"; /** * Dynamically generate the entire test suite. */ describe("path-to-regexp", () => { - const TEST_PATH = "/user/:id"; - - const TEST_PARAM = { - name: "id", - prefix: "/", - suffix: "", - modifier: "", - pattern: "[^\\/#\\?]+?", - }; - - describe("arguments", () => { - it("should work without different call combinations", () => { - pathToRegexp.pathToRegexp("/test"); - pathToRegexp.pathToRegexp("/test", []); - pathToRegexp.pathToRegexp("/test", undefined, {}); - - pathToRegexp.pathToRegexp(/^\/test/); - pathToRegexp.pathToRegexp(/^\/test/, []); - pathToRegexp.pathToRegexp(/^\/test/, undefined, {}); - - pathToRegexp.pathToRegexp(["/a", "/b"]); - pathToRegexp.pathToRegexp(["/a", "/b"], []); - pathToRegexp.pathToRegexp(["/a", "/b"], undefined, {}); - }); - - it("should accept an array of keys as the second argument", () => { - const keys: pathToRegexp.Key[] = []; - const re = pathToRegexp.pathToRegexp(TEST_PATH, keys, { end: false }); - - expect(keys).toEqual([TEST_PARAM]); - expect(exec(re, "/user/123/show")).toEqual(["/user/123", "123"]); - }); - - it("should throw on non-capturing pattern", () => { - expect(() => { - pathToRegexp.pathToRegexp("/:foo(?:\\d+(\\.\\d+)?)"); - }).toThrow(new TypeError('Pattern cannot start with "?" at 6')); - }); - - it("should throw on nested capturing group", () => { - expect(() => { - pathToRegexp.pathToRegexp("/:foo(\\d+(\\.\\d+)?)"); - }).toThrow(new TypeError("Capturing groups are not allowed at 9")); - }); - - it("should throw on unbalanced pattern", () => { - expect(() => { - pathToRegexp.pathToRegexp("/:foo(abc"); - }).toThrow(new TypeError("Unbalanced pattern at 5")); - }); - - it("should throw on missing pattern", () => { - expect(() => { - pathToRegexp.pathToRegexp("/:foo()"); - }).toThrow(new TypeError("Missing pattern at 5")); - }); - - it("should throw on missing name", () => { - expect(() => { - pathToRegexp.pathToRegexp("/:(test)"); - }).toThrow(new TypeError("Missing parameter name at 1")); - }); - - it("should throw on nested groups", () => { - expect(() => { - pathToRegexp.pathToRegexp("/{a{b:foo}}"); - }).toThrow(new TypeError("Unexpected OPEN at 3, expected CLOSE")); + describe("parse errors", () => { + it("should throw on unbalanced group", () => { + expect(() => parse("/{:foo,")).toThrow( + new TypeError( + "Unexpected END at 7, expected }: https://git.new/pathToRegexpError", + ), + ); }); - - it("should throw on misplaced modifier", () => { - expect(() => { - pathToRegexp.pathToRegexp("/foo?"); - }).toThrow(new TypeError("Unexpected MODIFIER at 4, expected END")); + it("should throw on nested unbalanced group", () => { + expect(() => parse("/{:foo/{x,y}")).toThrow( + new TypeError( + "Unexpected END at 12, expected }: https://git.new/pathToRegexpError", + ), + ); }); - }); - - describe("tokens", () => { - const tokens = pathToRegexp.parse(TEST_PATH); - it("should expose method to compile tokens to regexp", () => { - const re = pathToRegexp.tokensToRegexp(tokens); - - expect(exec(re, "/user/123")).toEqual(["/user/123", "123"]); + it("should throw on missing param name", () => { + expect(() => parse("/:/")).toThrow( + new TypeError( + "Missing parameter name at 2: https://git.new/pathToRegexpError", + ), + ); }); - it("should expose method to compile tokens to a path function", () => { - const fn = pathToRegexp.tokensToFunction(tokens); - - expect(fn({ id: 123 })).toEqual("/user/123"); + it("should throw on missing wildcard name", () => { + expect(() => parse("/*/")).toThrow( + new TypeError( + "Missing parameter name at 2: https://git.new/pathToRegexpError", + ), + ); }); - }); - describe("rules", () => { - TESTS.forEach(([path, opts, tokens, matchCases, compileCases]) => { - describe(util.inspect(path), () => { - const keys: pathToRegexp.Key[] = []; - const re = pathToRegexp.pathToRegexp(path, keys, opts); - - // Parsing and compiling is only supported with string input. - if (typeof path === "string") { - it("should parse", () => { - expect(pathToRegexp.parse(path, opts)).toEqual(tokens); - }); - - describe("compile", () => { - compileCases.forEach(([params, result, options]) => { - const toPath = pathToRegexp.compile(path, { - ...opts, - ...options, - }); - - if (result !== null) { - it("should compile using " + util.inspect(params), () => { - expect(toPath(params)).toEqual(result); - }); - } else { - it("should not compile using " + util.inspect(params), () => { - expect(() => { - toPath(params); - }).toThrow(TypeError); - }); - } - }); - }); - } else { - it("should parse keys", () => { - expect(keys).toEqual( - tokens.filter((token) => typeof token !== "string"), - ); - }); - } - - describe("match" + (opts ? " using " + util.inspect(opts) : ""), () => { - matchCases.forEach(([pathname, matches, params, options]) => { - const message = `should ${ - matches ? "" : "not " - }match ${util.inspect(pathname)}`; - - it(message, () => { - expect(exec(re, pathname)).toEqual(matches); - }); - - if (typeof path === "string" && params !== undefined) { - const match = pathToRegexp.match(path, options); - - it(message + " params", () => { - expect(match(pathname)).toEqual(params); - }); - } - }); - }); - }); + it("should throw on unterminated quote", () => { + expect(() => parse('/:"foo')).toThrow( + new TypeError( + "Unterminated quote at 2: https://git.new/pathToRegexpError", + ), + ); }); }); describe("compile errors", () => { - it("should throw when a required param is undefined", () => { - const toPath = pathToRegexp.compile("/a/:b/c"); + it("should throw when a param is missing", () => { + const toPath = compile("/a/:b/c"); expect(() => { toPath(); - }).toThrow(new TypeError('Expected "b" to be a string')); + }).toThrow(new TypeError("Missing parameters: b")); }); - it("should throw when it does not match the pattern", () => { - const toPath = pathToRegexp.compile("/:foo(\\d+)"); + it("should throw when expecting a repeated value", () => { + const toPath = compile("/*foo"); expect(() => { - toPath({ foo: "abc" }); - }).toThrow( - new TypeError('Expected "foo" to match "\\d+", but got "abc"'), - ); + toPath({ foo: [] }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); }); - it("should throw when expecting a repeated value", () => { - const toPath = pathToRegexp.compile("/:foo+"); + it("should throw when param gets an array", () => { + const toPath = compile("/:foo"); expect(() => { toPath({ foo: [] }); - }).toThrow(new TypeError('Expected "foo" to not be empty')); + }).toThrow(new TypeError('Expected "foo" to be a string')); }); - it("should throw when not expecting a repeated value", () => { - const toPath = pathToRegexp.compile("/:foo"); + it("should throw when a wildcard is not an array", () => { + const toPath = compile("/*foo"); expect(() => { - toPath({ foo: [] }); - }).toThrow( - new TypeError('Expected "foo" to not repeat, but got an array'), - ); + toPath({ foo: "a" }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); }); - it("should throw when repeated value does not match", () => { - const toPath = pathToRegexp.compile("/:foo(\\d+)+"); + it("should throw when a wildcard array value is not a string", () => { + const toPath = compile("/*foo"); expect(() => { - toPath({ foo: [1, 2, 3, "a"] }); - }).toThrow( - new TypeError('Expected all "foo" to match "\\d+", but got "a"'), - ); + toPath({ foo: [1, "a"] as any }); + }).toThrow(new TypeError('Expected "foo/0" to be a string')); }); }); -}); -/** - * Execute a regular expression and return a flat array for comparison. - * - * @param {RegExp} re - * @param {String} str - * @return {Array} - */ -function exec(re: RegExp, str: string) { - const match = re.exec(str); + describe.each(PARSER_TESTS)( + "parse $path with $options", + ({ path, options, expected }) => { + it("should parse the path", () => { + const data = parse(path, options); + expect(data).toEqual(expected); + }); + }, + ); + + describe.each(STRINGIFY_TESTS)( + "stringify $tokens with $options", + ({ data, expected }) => { + it("should stringify the path", () => { + const path = stringify(data); + expect(path).toEqual(expected); + }); + }, + ); - return match && Array.prototype.slice.call(match); -} + describe.each(COMPILE_TESTS)( + "compile $path with $options", + ({ path, options, tests }) => { + it.each(tests)("should compile $input", ({ input, expected }) => { + const toPath = compile(path, options); + + if (expected === null) { + expect(() => toPath(input)).toThrow(); + } else { + expect(toPath(input)).toEqual(expected); + } + }); + }, + ); + + describe.each(MATCH_TESTS)( + "match $path with $options", + ({ path, options, tests }) => { + it.each(tests)("should match $input", ({ input, expected }) => { + const fn = match(path, options); + expect(fn(input)).toEqual(expected); + }); + }, + ); +}); diff --git a/src/index.ts b/src/index.ts index 4454098..5a0d326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,621 +1,629 @@ +const DEFAULT_DELIMITER = "/"; +const NOOP_VALUE = (value: string) => value; +const ID_START = /^[$_\p{ID_Start}]$/u; +const ID_CONTINUE = /^[$\u200c\u200d\p{ID_Continue}]$/u; +const DEBUG_URL = "https://git.new/pathToRegexpError"; + +/** + * Encode a string into another string. + */ +export type Encode = (value: string) => string; + +/** + * Decode a string into another string. + */ +export type Decode = (value: string) => string; + +export interface ParseOptions { + /** + * A function for encoding input strings. + */ + encodePath?: Encode; +} + +export interface PathToRegexpOptions { + /** + * Matches the path completely without trailing characters. (default: `true`) + */ + end?: boolean; + /** + * Allows optional trailing delimiter to match. (default: `true`) + */ + trailing?: boolean; + /** + * Match will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * The default delimiter for segments. (default: `'/'`) + */ + delimiter?: string; +} + +export interface MatchOptions extends PathToRegexpOptions { + /** + * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) + */ + decode?: Decode | false; +} + +export interface CompileOptions { + /** + * Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) + */ + encode?: Encode | false; + /** + * The default delimiter for segments. (default: `'/'`) + */ + delimiter?: string; +} + +type TokenType = + | "{" + | "}" + | "WILDCARD" + | "PARAM" + | "CHAR" + | "ESCAPED" + | "END" + // Reserved for use or ambiguous due to past use. + | "(" + | ")" + | "[" + | "]" + | "+" + | "?" + | "!"; + /** * Tokenizer results. */ interface LexToken { - type: - | "OPEN" - | "CLOSE" - | "PATTERN" - | "NAME" - | "CHAR" - | "ESCAPED_CHAR" - | "MODIFIER" - | "END"; + type: TokenType; index: number; value: string; } +const SIMPLE_TOKENS: Record = { + // Groups. + "{": "{", + "}": "}", + // Reserved. + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "+": "+", + "?": "?", + "!": "!", +}; + +/** + * Escape text for stringify to path. + */ +function escapeText(str: string) { + return str.replace(/[{}()\[\]+?!:*]/g, "\\$&"); +} + +/** + * Escape a regular expression string. + */ +function escape(str: string) { + return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); +} + /** * Tokenize input string. */ -function lexer(str: string): LexToken[] { - const tokens: LexToken[] = []; +function* lexer(str: string): Generator { + const chars = [...str]; let i = 0; - while (i < str.length) { - const char = str[i]; + function name() { + let value = ""; - if (char === "*" || char === "+" || char === "?") { - tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); - continue; - } + if (ID_START.test(chars[++i])) { + value += chars[i]; + while (ID_CONTINUE.test(chars[++i])) { + value += chars[i]; + } + } else if (chars[i] === '"') { + let pos = i; + + while (i < chars.length) { + if (chars[++i] === '"') { + i++; + pos = 0; + break; + } - if (char === "\\") { - tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); - continue; - } + if (chars[i] === "\\") { + value += chars[++i]; + } else { + value += chars[i]; + } + } - if (char === "{") { - tokens.push({ type: "OPEN", index: i, value: str[i++] }); - continue; + if (pos) { + throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`); + } } - if (char === "}") { - tokens.push({ type: "CLOSE", index: i, value: str[i++] }); - continue; + if (!value) { + throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`); } - if (char === ":") { - let name = ""; - let j = i + 1; - - while (j < str.length) { - const code = str.charCodeAt(j); - - if ( - // `0-9` - (code >= 48 && code <= 57) || - // `A-Z` - (code >= 65 && code <= 90) || - // `a-z` - (code >= 97 && code <= 122) || - // `_` - code === 95 - ) { - name += str[j++]; - continue; - } - - break; - } - - if (!name) throw new TypeError(`Missing parameter name at ${i}`); + return value; + } - tokens.push({ type: "NAME", index: i, value: name }); - i = j; - continue; + while (i < chars.length) { + const value = chars[i]; + const type = SIMPLE_TOKENS[value]; + + if (type) { + yield { type, index: i++, value }; + } else if (value === "\\") { + yield { type: "ESCAPED", index: i++, value: chars[i++] }; + } else if (value === ":") { + const value = name(); + yield { type: "PARAM", index: i, value }; + } else if (value === "*") { + const value = name(); + yield { type: "WILDCARD", index: i, value }; + } else { + yield { type: "CHAR", index: i, value: chars[i++] }; } + } - if (char === "(") { - let count = 1; - let pattern = ""; - let j = i + 1; + return { type: "END", index: i, value: "" }; +} - if (str[j] === "?") { - throw new TypeError(`Pattern cannot start with "?" at ${j}`); - } +class Iter { + #peek?: LexToken; - while (j < str.length) { - if (str[j] === "\\") { - pattern += str[j++] + str[j++]; - continue; - } + constructor(private tokens: Generator) {} - if (str[j] === ")") { - count--; - if (count === 0) { - j++; - break; - } - } else if (str[j] === "(") { - count++; - if (str[j + 1] !== "?") { - throw new TypeError(`Capturing groups are not allowed at ${j}`); - } - } + peek(): LexToken { + if (!this.#peek) { + const next = this.tokens.next(); + this.#peek = next.value; + } + return this.#peek; + } - pattern += str[j++]; - } + tryConsume(type: TokenType): string | undefined { + const token = this.peek(); + if (token.type !== type) return; + this.#peek = undefined; // Reset after consumed. + return token.value; + } - if (count) throw new TypeError(`Unbalanced pattern at ${i}`); - if (!pattern) throw new TypeError(`Missing pattern at ${i}`); + consume(type: TokenType): string { + 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}: ${DEBUG_URL}`, + ); + } - tokens.push({ type: "PATTERN", index: i, value: pattern }); - i = j; - continue; + text(): string { + let result = ""; + let value: string | undefined; + while ((value = this.tryConsume("CHAR") || this.tryConsume("ESCAPED"))) { + result += value; } - - tokens.push({ type: "CHAR", index: i, value: str[i++] }); + return result; } +} - tokens.push({ type: "END", index: i, value: "" }); - - return tokens; +/** + * Plain text. + */ +export interface Text { + type: "text"; + value: string; } -export interface ParseOptions { - /** - * Set the default delimiter for repeat parameters. (default: `'/'`) - */ - delimiter?: string; - /** - * List of characters to automatically consider prefixes when parsing. - */ - prefixes?: string; +/** + * A parameter designed to match arbitrary text within a segment. + */ +export interface Parameter { + type: "param"; + name: string; } /** - * Parse a string for the raw tokens. + * A wildcard parameter designed to match multiple segments. */ -export function parse(str: string, options: ParseOptions = {}): Token[] { - const tokens = lexer(str); - const { prefixes = "./" } = options; - const defaultPattern = `[^${escapeString(options.delimiter || "/#?")}]+?`; - const result: Token[] = []; - let key = 0; - let i = 0; - let path = ""; +export interface Wildcard { + type: "wildcard"; + name: string; +} - const tryConsume = (type: LexToken["type"]): string | undefined => { - if (i < tokens.length && tokens[i].type === type) return tokens[i++].value; - }; +/** + * A set of possible tokens to expand when matching. + */ +export interface Group { + type: "group"; + tokens: Token[]; +} - const mustConsume = (type: LexToken["type"]): string => { - const value = tryConsume(type); - if (value !== undefined) return value; - const { type: nextType, index } = tokens[i]; - throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); - }; +/** + * A token that corresponds with a regexp capture. + */ +export type Key = Parameter | Wildcard; - const consumeText = (): string => { - let result = ""; - let value: string | undefined; - while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { - result += value; - } - return result; - }; +/** + * A sequence of `path-to-regexp` keys that match capturing groups. + */ +export type Keys = Array; - while (i < tokens.length) { - const char = tryConsume("CHAR"); - const name = tryConsume("NAME"); - const pattern = tryConsume("PATTERN"); +/** + * A sequence of path match characters. + */ +export type Token = Text | Parameter | Wildcard | Group; - if (name || pattern) { - let prefix = char || ""; +/** + * Tokenized path instance. + */ +export class TokenData { + constructor(public readonly tokens: Token[]) {} +} - if (prefixes.indexOf(prefix) === -1) { - path += prefix; - prefix = ""; +/** + * Parse a string for the raw tokens. + */ +export function parse(str: string, options: ParseOptions = {}): TokenData { + const { encodePath = NOOP_VALUE } = options; + const it = new Iter(lexer(str)); + + function consume(endType: TokenType): Token[] { + const tokens: Token[] = []; + + while (true) { + const path = it.text(); + if (path) tokens.push({ type: "text", value: encodePath(path) }); + + const param = it.tryConsume("PARAM"); + if (param) { + tokens.push({ + type: "param", + name: param, + }); + continue; } - if (path) { - result.push(path); - path = ""; + const wildcard = it.tryConsume("WILDCARD"); + if (wildcard) { + tokens.push({ + type: "wildcard", + name: wildcard, + }); + continue; } - result.push({ - name: name || key++, - prefix, - suffix: "", - pattern: pattern || defaultPattern, - modifier: tryConsume("MODIFIER") || "", - }); - continue; - } - - const value = char || tryConsume("ESCAPED_CHAR"); - if (value) { - path += value; - continue; - } - - if (path) { - result.push(path); - path = ""; - } + const open = it.tryConsume("{"); + if (open) { + tokens.push({ + type: "group", + tokens: consume("}"), + }); + continue; + } - const open = tryConsume("OPEN"); - if (open) { - const prefix = consumeText(); - const name = tryConsume("NAME") || ""; - const pattern = tryConsume("PATTERN") || ""; - const suffix = consumeText(); - - mustConsume("CLOSE"); - - result.push({ - name: name || (pattern ? key++ : ""), - pattern: name && !pattern ? defaultPattern : pattern, - prefix, - suffix, - modifier: tryConsume("MODIFIER") || "", - }); - continue; + it.consume(endType); + return tokens; } - - mustConsume("END"); } - return result; -} - -export interface TokensToFunctionOptions { - /** - * When `true` the regexp will be case sensitive. (default: `false`) - */ - sensitive?: boolean; - /** - * Function for encoding input strings for output. - */ - encode?: (value: string, token: Key) => string; - /** - * When `false` the function can produce an invalid (unmatched) path. (default: `true`) - */ - validate?: boolean; + const tokens = consume("END"); + return new TokenData(tokens); } /** * Compile a string to a template function for the path. */ -export function compile

( - str: string, - options?: ParseOptions & TokensToFunctionOptions, +export function compile

( + path: Path, + options: CompileOptions & ParseOptions = {}, ) { - return tokensToFunction

(parse(str, options), options); + const { encode = encodeURIComponent, delimiter = DEFAULT_DELIMITER } = + options; + const data = path instanceof TokenData ? path : parse(path, options); + const fn = tokensToFunction(data.tokens, delimiter, encode); + + return function path(data: P = {} as P) { + const [path, ...missing] = fn(data); + if (missing.length) { + throw new TypeError(`Missing parameters: ${missing.join(", ")}`); + } + return path; + }; } -export type PathFunction

= (data?: P) => string; +export type ParamData = Partial>; +export type PathFunction

= (data?: P) => string; -/** - * Expose a method for transforming tokens into the path function. - */ -export function tokensToFunction

( +function tokensToFunction( tokens: Token[], - options: TokensToFunctionOptions = {}, -): PathFunction

{ - const reFlags = flags(options); - const { encode = (x: string) => x, validate = true } = options; - - // Compile all the tokens into regexps. - const matches = tokens.map((token) => { - if (typeof token === "object") { - return new RegExp(`^(?:${token.pattern})$`, reFlags); - } - }); - - return (data: Record | null | undefined) => { - let path = ""; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - - if (typeof token === "string") { - path += token; - continue; - } - - const value = data ? data[token.name] : undefined; - const optional = token.modifier === "?" || token.modifier === "*"; - const repeat = token.modifier === "*" || token.modifier === "+"; + delimiter: string, + encode: Encode | false, +) { + const encoders = tokens.map((token) => + tokenToFunction(token, delimiter, encode), + ); - if (Array.isArray(value)) { - if (!repeat) { - throw new TypeError( - `Expected "${token.name}" to not repeat, but got an array`, - ); - } + return (data: ParamData) => { + const result: string[] = [""]; - if (value.length === 0) { - if (optional) continue; + for (const encoder of encoders) { + const [value, ...extras] = encoder(data); + result[0] += value; + result.push(...extras); + } - throw new TypeError(`Expected "${token.name}" to not be empty`); - } + return result; + }; +} - for (let j = 0; j < value.length; j++) { - const segment = encode(value[j], token); +/** + * Convert a single token into a path building function. + */ +function tokenToFunction( + token: Token, + delimiter: string, + encode: Encode | false, +): (data: ParamData) => string[] { + if (token.type === "text") return () => [token.value]; + + if (token.type === "group") { + const fn = tokensToFunction(token.tokens, delimiter, encode); + + return (data) => { + const [value, ...missing] = fn(data); + if (!missing.length) return [value]; + return [""]; + }; + } - if (validate && !(matches[i] as RegExp).test(segment)) { - throw new TypeError( - `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`, - ); - } + const encodeValue = encode || NOOP_VALUE; - path += token.prefix + segment + token.suffix; - } + if (token.type === "wildcard" && encode !== false) { + return (data) => { + const value = data[token.name]; + if (value == null) return ["", token.name]; - continue; + if (!Array.isArray(value) || value.length === 0) { + throw new TypeError(`Expected "${token.name}" to be a non-empty array`); } - if (typeof value === "string" || typeof value === "number") { - const segment = encode(String(value), token); - - if (validate && !(matches[i] as RegExp).test(segment)) { - throw new TypeError( - `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`, - ); - } - - path += token.prefix + segment + token.suffix; - continue; - } + return [ + value + .map((value, index) => { + if (typeof value !== "string") { + throw new TypeError( + `Expected "${token.name}/${index}" to be a string`, + ); + } + + return encodeValue(value); + }) + .join(delimiter), + ]; + }; + } - if (optional) continue; + return (data) => { + const value = data[token.name]; + if (value == null) return ["", token.name]; - const typeOfMessage = repeat ? "an array" : "a string"; - throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`); + if (typeof value !== "string") { + throw new TypeError(`Expected "${token.name}" to be a string`); } - return path; + return [encodeValue(value)]; }; } -export interface RegexpToFunctionOptions { - /** - * Function for decoding strings for params. - */ - decode?: (value: string, token: Key) => string; -} - /** * A match result contains data about the path match. */ -export interface MatchResult

{ +export interface MatchResult

{ path: string; - index: number; params: P; } /** * A match is either `false` (no match) or a match result. */ -export type Match

= false | MatchResult

; +export type Match

= false | MatchResult

; /** * The match function takes a string and returns whether it matched the path. */ -export type MatchFunction

= ( - path: string, -) => Match

; +export type MatchFunction

= (path: string) => Match

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

( - str: Path, - options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions, -) { - const keys: Key[] = []; - const re = pathToRegexp(str, keys, options); - return regexpToFunction

(re, keys, options); -} +export type Path = string | TokenData; /** - * Create a path match function from `path-to-regexp` output. + * Transform a path into a match function. */ -export function regexpToFunction

( - re: RegExp, - keys: Key[], - options: RegexpToFunctionOptions = {}, +export function match

( + path: Path | Path[], + options: MatchOptions & ParseOptions = {}, ): MatchFunction

{ - const { decode = (x: string) => x } = options; + const { decode = decodeURIComponent, delimiter = DEFAULT_DELIMITER } = + options; + const { regexp, keys } = pathToRegexp(path, options); + + const decoders = keys.map((key) => { + if (decode === false) return NOOP_VALUE; + if (key.type === "param") return decode; + return (value: string) => value.split(delimiter).map(decode); + }); - return function (pathname: string) { - const m = re.exec(pathname); + return function match(input: string) { + const m = regexp.exec(input); if (!m) return false; - const { 0: path, index } = m; + const path = m[0]; const params = Object.create(null); for (let i = 1; i < m.length; i++) { if (m[i] === undefined) continue; const key = keys[i - 1]; - - if (key.modifier === "*" || key.modifier === "+") { - params[key.name] = m[i].split(key.prefix + key.suffix).map((value) => { - return decode(value, key); - }); - } else { - params[key.name] = decode(m[i], key); - } + const decoder = decoders[i - 1]; + params[key.name] = decoder(m[i]); } - return { path, index, params }; + return { path, params }; }; } -/** - * Escape a regular expression string. - */ -function escapeString(str: string) { - return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); -} +export function pathToRegexp( + path: Path | Path[], + options: PathToRegexpOptions & ParseOptions = {}, +) { + const { + delimiter = DEFAULT_DELIMITER, + end = true, + sensitive = false, + trailing = true, + } = options; + const keys: Keys = []; + const sources: string[] = []; + const flags = sensitive ? "s" : "is"; + const paths = Array.isArray(path) ? path : [path]; + const items = paths.map((path) => + path instanceof TokenData ? path : parse(path, options), + ); + + for (const { tokens } of items) { + for (const seq of flatten(tokens, 0, [])) { + const regexp = sequenceToRegExp(seq, delimiter, keys); + sources.push(regexp); + } + } -/** - * Get the flags for a regexp from the options. - */ -function flags(options?: { sensitive?: boolean }) { - return options && options.sensitive ? "" : "i"; -} + let pattern = `^(?:${sources.join("|")})`; + if (trailing) pattern += `(?:${escape(delimiter)}$)?`; + pattern += end ? "$" : `(?=${escape(delimiter)}|$)`; -/** - * Metadata about a key. - */ -export interface Key { - name: string | number; - prefix: string; - suffix: string; - pattern: string; - modifier: string; + const regexp = new RegExp(pattern, flags); + return { regexp, keys }; } /** - * A token is a string (nothing special) or key metadata (capture group). + * Flattened token set. */ -export type Token = string | Key; +type Flattened = Text | Parameter | Wildcard; /** - * Pull out keys from a regexp. + * Generate a flat list of sequence tokens from the given tokens. */ -function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp { - if (!keys) return path; - - const groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; - - let index = 0; - let execResult = groupsRegex.exec(path.source); - while (execResult) { - keys.push({ - // Use parenthesized substring match if available, index otherwise - name: execResult[1] || index++, - prefix: "", - suffix: "", - modifier: "", - pattern: "", - }); - execResult = groupsRegex.exec(path.source); +function* flatten( + tokens: Token[], + index: number, + init: Flattened[], +): Generator { + if (index === tokens.length) { + return yield init; } - return path; -} + const token = tokens[index]; -/** - * Transform an array into a regexp. - */ -function arrayToRegexp( - paths: Array, - keys?: Key[], - options?: TokensToRegexpOptions & ParseOptions, -): RegExp { - const parts = paths.map((path) => pathToRegexp(path, keys, options).source); - return new RegExp(`(?:${parts.join("|")})`, flags(options)); -} - -/** - * Create a path regexp from string input. - */ -function stringToRegexp( - path: string, - keys?: Key[], - options?: TokensToRegexpOptions & ParseOptions, -) { - return tokensToRegexp(parse(path, options), keys, options); -} + if (token.type === "group") { + const fork = init.slice(); + for (const seq of flatten(token.tokens, 0, fork)) { + yield* flatten(tokens, index + 1, seq); + } + } else { + init.push(token); + } -export interface TokensToRegexpOptions { - /** - * When `true` the regexp will be case sensitive. (default: `false`) - */ - sensitive?: boolean; - /** - * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) - */ - strict?: boolean; - /** - * When `true` the regexp will match to the end of the string. (default: `true`) - */ - end?: boolean; - /** - * When `true` the regexp will match from the beginning of the string. (default: `true`) - */ - start?: boolean; - /** - * Sets the final character for non-ending optimistic matches. (default: `/`) - */ - delimiter?: string; - /** - * List of characters that can also be "end" characters. - */ - endsWith?: string; - /** - * Encode path tokens for use in the `RegExp`. - */ - encode?: (value: string) => string; + yield* flatten(tokens, index + 1, init); } /** - * Expose a function for taking tokens and returning a RegExp. + * Transform a flat sequence of tokens into a regular expression. */ -export function tokensToRegexp( - tokens: Token[], - keys?: Key[], - options: TokensToRegexpOptions = {}, -) { - const { - strict = false, - start = true, - end = true, - encode = (x: string) => x, - delimiter = "/#?", - endsWith = "", - } = options; - const endsWithRe = `[${escapeString(endsWith)}]|$`; - const delimiterRe = `[${escapeString(delimiter)}]`; - let route = start ? "^" : ""; - - // Iterate over the tokens and create our regexp string. - for (const token of tokens) { - if (typeof token === "string") { - route += escapeString(encode(token)); - } else { - const prefix = escapeString(encode(token.prefix)); - const suffix = escapeString(encode(token.suffix)); - - if (token.pattern) { - if (keys) keys.push(token); - - if (prefix || suffix) { - if (token.modifier === "+" || token.modifier === "*") { - const mod = token.modifier === "*" ? "?" : ""; - route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; - } else { - route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; - } - } else { - if (token.modifier === "+" || token.modifier === "*") { - route += `((?:${token.pattern})${token.modifier})`; - } else { - route += `(${token.pattern})${token.modifier}`; - } - } - } else { - route += `(?:${prefix}${suffix})${token.modifier}`; - } +function sequenceToRegExp(tokens: Flattened[], delimiter: string, keys: Keys) { + let result = ""; + let backtrack = ""; + let isSafeSegmentParam = true; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.type === "text") { + result += escape(token.value); + backtrack = token.value; + isSafeSegmentParam ||= token.value.includes(delimiter); + continue; } - } - if (end) { - if (!strict) route += `${delimiterRe}?`; + if (token.type === "param" || token.type === "wildcard") { + if (!isSafeSegmentParam && !backtrack) { + throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`); + } - route += !options.endsWith ? "$" : `(?=${endsWithRe})`; - } else { - const endToken = tokens[tokens.length - 1]; - const isEndDelimited = - typeof endToken === "string" - ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 - : endToken === undefined; - - if (!strict) { - route += `(?:${delimiterRe}(?=${endsWithRe}))?`; - } + if (token.type === "param") { + result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; + } else { + result += `(.+)`; + } - if (!isEndDelimited) { - route += `(?=${delimiterRe}|${endsWithRe})`; + keys.push(token); + backtrack = ""; + isSafeSegmentParam = false; + continue; } } - return new RegExp(route, flags(options)); + return result; } -/** - * Supported `path-to-regexp` input types. - */ -export type Path = string | RegExp | Array; +function negate(delimiter: string, backtrack: string) { + const values = [delimiter, backtrack].filter(Boolean); + const isSimple = values.every((value) => value.length === 1); + if (isSimple) return `[^${escape(values.join(""))}]`; + return `(?:(?!${values.map(escape).join("|")}).)`; +} /** - * Normalize the given path string, returning a regular expression. - * - * An empty array can be passed in for the keys, which will hold the - * placeholder key descriptions. For example, using `/user/:id`, `keys` will - * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + * Stringify token data into a path string. */ -export function pathToRegexp( - path: Path, - keys?: Key[], - options?: TokensToRegexpOptions & ParseOptions, -) { - if (path instanceof RegExp) return regexpToRegexp(path, keys); - if (Array.isArray(path)) return arrayToRegexp(path, keys, options); - return stringToRegexp(path, keys, options); +export function stringify(data: TokenData) { + return data.tokens + .map(function stringifyToken(token, index, tokens): string { + if (token.type === "text") return escapeText(token.value); + if (token.type === "group") { + return `{${token.tokens.map(stringifyToken).join("")}}`; + } + + const isSafe = + isNameSafe(token.name) && isNextNameSafe(tokens[index + 1]); + const key = isSafe ? token.name : JSON.stringify(token.name); + + if (token.type === "param") return `:${key}`; + if (token.type === "wildcard") return `*${key}`; + throw new TypeError(`Unexpected token: ${token}`); + }) + .join(""); +} + +function isNameSafe(name: string) { + const [first, ...rest] = name; + if (!ID_START.test(first)) return false; + return rest.every((char) => ID_CONTINUE.test(char)); +} + +function isNextNameSafe(token: Token | undefined) { + if (token?.type !== "text") return true; + return !ID_CONTINUE.test(token.value[0]); } diff --git a/tsconfig.build.json b/tsconfig.build.json index d783ab3..3db8e88 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": [] }, - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"] } diff --git a/tsconfig.es2015.json b/tsconfig.es2015.json deleted file mode 100644 index fa546d4..0000000 --- a/tsconfig.es2015.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.build.json", - "compilerOptions": { - "outDir": "dist.es2015", - "module": "es2015", - "declaration": false - } -} diff --git a/tsconfig.json b/tsconfig.json index a81e6f2..83a86d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "@borderless/ts-scripts/configs/tsconfig.json", "compilerOptions": { - "target": "es5", - "lib": ["es5"], + "target": "ES2020", + "lib": ["ES2020"], "rootDir": "src", "outDir": "dist", - "module": "commonjs", - "moduleResolution": "node", + "module": "NodeNext", + "moduleResolution": "NodeNext", "types": ["node"] }, "include": ["src/**/*"] diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..f98f607 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,10 @@ +/// +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + coverage: { + exclude: ["scripts/**"], + }, + }, +});