diff --git a/Readme.md b/Readme.md index 263c0f7..455c935 100644 --- a/Readme.md +++ b/Readme.md @@ -17,11 +17,13 @@ npm install path-to-regexp --save ## Usage ```js -const { match, compile, parse } = require("path-to-regexp"); - -// match(path, options?) -// compile(path, options?) -// parse(path, options?) +const { + match, + pathToRegexp, + compile, + parse, + stringify, +} = require("path-to-regexp"); ``` ### Parameters @@ -64,25 +66,38 @@ fn("/users/123/delete"); The `match` function returns a function for matching strings against a path: +- **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 fn = match("/foo/:bar"); +``` + +**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). + +## PathToRegexp + +The `pathToRegexp` function returns a regular expression for matching strings against paths. It + - **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`) - - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`) ```js -const fn = match("/foo/:bar"); +const { regexp, keys } = pathToRegexp("/foo/:bar"); ``` -**Please note:** `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). - ## Compile ("Reverse" Path-To-RegExp) The `compile` function will return a function for transforming parameters into a valid path: - **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 @@ -102,6 +117,21 @@ const toPathRaw = compile("/user/:id", { encode: false }); toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" ``` +## Stringify + +Transform `TokenData` (a sequence of tokens) back into a Path-to-RegExp string. + +- **data** A `TokenData` instance + +```js +const data = new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "foo" }, +]); + +const path = stringify(data); //=> "/:foo" +``` + ## Developers - If you are rewriting paths with match and compile, consider using `encode: false` and `decode: false` to keep raw paths passed around. @@ -113,7 +143,6 @@ The `parse` function accepts a string and returns `TokenData`, the set of tokens - **path** A string. - **options** _(optional)_ - - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`) - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl)) ### Tokens diff --git a/package-lock.json b/package-lock.json index a582d4b..ab36824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "path-to-regexp", - "version": "8.0.0", + "version": "8.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "path-to-regexp", - "version": "8.0.0", + "version": "8.1.0", "license": "MIT", "devDependencies": { "@borderless/ts-scripts": "^0.15.0", diff --git a/package.json b/package.json index f6806a5..b9a6d43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "path-to-regexp", - "version": "8.0.0", + "version": "8.1.0", "description": "Express style path to RegExp utility", "keywords": [ "express", diff --git a/scripts/redos.ts b/scripts/redos.ts index 841cd07..9f0b4bc 100644 --- a/scripts/redos.ts +++ b/scripts/redos.ts @@ -1,5 +1,5 @@ import { checkSync } from "recheck"; -import { match } from "../src/index.js"; +import { pathToRegexp } from "../src/index.js"; import { MATCH_TESTS } from "../src/cases.spec.js"; let safe = 0; @@ -8,14 +8,14 @@ let fail = 0; const TESTS = MATCH_TESTS.map((x) => x.path); for (const path of TESTS) { - const { re } = match(path) as any; - const result = checkSync(re.source, re.flags); + const { regexp } = pathToRegexp(path); + const result = checkSync(regexp.source, regexp.flags); if (result.status === "safe") { safe++; - console.log("Safe:", path, String(re)); + console.log("Safe:", path, String(regexp)); } else { fail++; - console.log("Fail:", path, String(re)); + console.log("Fail:", path, String(regexp)); } } diff --git a/src/cases.spec.ts b/src/cases.spec.ts index ef06e1f..6a7aeec 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -1,16 +1,23 @@ -import type { - MatchOptions, - Match, - ParseOptions, - Token, - CompileOptions, - ParamData, +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: Token[]; + expected: TokenData; +} + +export interface StringifyTestSet { + data: TokenData; + options?: ParseOptions; + expected: string; } export interface CompileTestSet { @@ -34,56 +41,132 @@ export interface MatchTestSet { export const PARSER_TESTS: ParserTestSet[] = [ { path: "/", - expected: [{ type: "text", value: "/" }], + expected: new TokenData([{ type: "text", value: "/" }]), }, { path: "/:test", - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: "test" }, - ], + ]), }, { path: '/:"0"', - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: "0" }, - ], + ]), }, { path: "/:_", - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: "_" }, - ], + ]), }, { path: "/:café", - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: "café" }, - ], + ]), }, { path: '/:"123"', - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: "123" }, - ], + ]), }, { path: '/:"1\\"\\2\\"3"', - expected: [ + expected: new TokenData([ { type: "text", value: "/" }, { type: "param", name: '1"2"3' }, - ], + ]), }, { path: "/*path", - expected: [ + 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', }, ]; @@ -219,6 +302,10 @@ export const MATCH_TESTS: MatchTestSet[] = [ input: "/test/", expected: { path: "/test/", params: {} }, }, + { + input: "/TEST/", + expected: { path: "/TEST/", params: {} }, + }, ], }, { @@ -311,11 +398,11 @@ export const MATCH_TESTS: MatchTestSet[] = [ sensitive: true, }, tests: [ + { input: "/test", expected: false }, { input: "/TEST", expected: { path: "/TEST", params: {} }, }, - { input: "/test", expected: false }, ], }, diff --git a/src/index.spec.ts b/src/index.spec.ts index c6da631..cef557f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; -import { parse, compile, match } from "./index.js"; -import { PARSER_TESTS, COMPILE_TESTS, MATCH_TESTS } from "./cases.spec.js"; +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. @@ -94,7 +99,17 @@ describe("path-to-regexp", () => { ({ path, options, expected }) => { it("should parse the path", () => { const data = parse(path, options); - expect(data.tokens).toEqual(expected); + 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); }); }, ); diff --git a/src/index.ts b/src/index.ts index a63e365..5a0d326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,11 +21,7 @@ export interface ParseOptions { encodePath?: Encode; } -export interface MatchOptions { - /** - * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) - */ - decode?: Decode | false; +export interface PathToRegexpOptions { /** * Matches the path completely without trailing characters. (default: `true`) */ @@ -44,6 +40,13 @@ export interface MatchOptions { 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`) @@ -96,17 +99,17 @@ const SIMPLE_TOKENS: Record = { }; /** - * Escape a regular expression string. + * Escape text for stringify to path. */ -function escape(str: string) { - return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); +function escapeText(str: string) { + return str.replace(/[{}()\[\]+?!:*]/g, "\\$&"); } /** - * Get the flags for a regexp from the options. + * Escape a regular expression string. */ -function toFlags(options: { sensitive?: boolean }) { - return options.sensitive ? "s" : "is"; +function escape(str: string) { + return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); } /** @@ -246,6 +249,16 @@ export interface Group { tokens: Token[]; } +/** + * A token that corresponds with a regexp capture. + */ +export type Key = Parameter | Wildcard; + +/** + * A sequence of `path-to-regexp` keys that match capturing groups. + */ +export type Keys = Array; + /** * A sequence of path match characters. */ @@ -309,14 +322,15 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { } /** - * Transform tokens into a path building function. + * Compile a string to a template function for the path. */ -function $compile

( - data: TokenData, - options: CompileOptions, -): PathFunction

{ +export function compile

( + path: Path, + options: CompileOptions & ParseOptions = {}, +) { 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) { @@ -328,19 +342,6 @@ function $compile

( }; } -/** - * Compile a string to a template function for the path. - */ -export function compile

( - path: Path, - options: CompileOptions & ParseOptions = {}, -) { - return $compile

( - path instanceof TokenData ? path : parse(path, options), - options, - ); -} - export type ParamData = Partial>; export type PathFunction

= (data?: P) => string; @@ -444,34 +445,20 @@ export type Match

= false | MatchResult

; export type MatchFunction

= (path: string) => Match

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

( - data: TokenData[], - options: MatchOptions = {}, -): MatchFunction

{ - const { - decode = decodeURIComponent, - delimiter = DEFAULT_DELIMITER, - end = true, - trailing = true, - } = options; - const flags = toFlags(options); - const sources: string[] = []; - const keys: Array = []; - - for (const { tokens } of data) { - for (const seq of flatten(tokens, 0, [])) { - const regexp = sequenceToRegExp(seq, delimiter, keys); - sources.push(regexp); - } - } - - let pattern = `^(?:${sources.join("|")})`; - if (trailing) pattern += `(?:${escape(delimiter)}$)?`; - pattern += end ? "$" : `(?=${escape(delimiter)}|$)`; +export type Path = string | TokenData; - const re = new RegExp(pattern, flags); +/** + * Transform a path into a match function. + */ +export function match

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

{ + const { decode = decodeURIComponent, delimiter = DEFAULT_DELIMITER } = + options; + const { regexp, keys } = pathToRegexp(path, options); const decoders = keys.map((key) => { if (decode === false) return NOOP_VALUE; @@ -479,40 +466,56 @@ function $match

( return (value: string) => value.split(delimiter).map(decode); }); - return Object.assign( - function match(input: string) { - const m = re.exec(input); - if (!m) return false; + return function match(input: string) { + const m = regexp.exec(input); + if (!m) return false; - const { 0: path } = m; - const params = Object.create(null); + const path = m[0]; + const params = Object.create(null); - for (let i = 1; i < m.length; i++) { - if (m[i] === undefined) continue; + for (let i = 1; i < m.length; i++) { + if (m[i] === undefined) continue; - const key = keys[i - 1]; - const decoder = decoders[i - 1]; - params[key.name] = decoder(m[i]); - } + const key = keys[i - 1]; + const decoder = decoders[i - 1]; + params[key.name] = decoder(m[i]); + } - return { path, params }; - }, - { re }, - ); + return { path, params }; + }; } -export type Path = string | TokenData; - -export function match

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

{ + 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), ); - return $match(items, options); + for (const { tokens } of items) { + for (const seq of flatten(tokens, 0, [])) { + const regexp = sequenceToRegExp(seq, delimiter, keys); + sources.push(regexp); + } + } + + let pattern = `^(?:${sources.join("|")})`; + if (trailing) pattern += `(?:${escape(delimiter)}$)?`; + pattern += end ? "$" : `(?=${escape(delimiter)}|$)`; + + const regexp = new RegExp(pattern, flags); + return { regexp, keys }; } /** @@ -549,11 +552,7 @@ function* flatten( /** * Transform a flat sequence of tokens into a regular expression. */ -function sequenceToRegExp( - tokens: Flattened[], - delimiter: string, - keys: Array, -): string { +function sequenceToRegExp(tokens: Flattened[], delimiter: string, keys: Keys) { let result = ""; let backtrack = ""; let isSafeSegmentParam = true; @@ -595,3 +594,36 @@ function negate(delimiter: string, backtrack: string) { if (isSimple) return `[^${escape(values.join(""))}]`; return `(?:(?!${values.map(escape).join("|")}).)`; } + +/** + * Stringify token data into a path string. + */ +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]); +}