diff --git a/Readme.md b/Readme.md index 17bf028..4af74bc 100644 --- a/Readme.md +++ b/Readme.md @@ -192,6 +192,14 @@ Parameter names must be provided after `:` or `*`, and they must be a valid Java Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character. +### Unbalanced pattern + +Parameter patterns must be wrapped in parentheses, and this error means you forgot to close the parentheses. + +### Missing pattern + +When defining a custom pattern for a parameter (e.g., `:id()`), you must provide a pattern. + ### Express <= 4.x Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways: diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 6a7aeec..6c3b081 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -100,6 +100,20 @@ export const PARSER_TESTS: ParserTestSet[] = [ { type: "text", value: "stuff" }, ]), }, + { + path: "/:locale(de|en)", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "locale", pattern: "de|en" }, + ]), + }, + { + path: "/:foo(a|b|c)", + expected: new TokenData([ + { type: "text", value: "/" }, + { type: "param", name: "foo", pattern: "a|b|c" }, + ]), + }, ]; export const STRINGIFY_TESTS: StringifyTestSet[] = [ @@ -270,6 +284,16 @@ export const COMPILE_TESTS: CompileTestSet[] = [ { input: { test: "123/xyz" }, expected: "/123/xyz" }, ], }, + { + path: "/:locale(de|en)", + tests: [ + { input: undefined, expected: null }, + { input: {}, expected: null }, + { input: { locale: "de" }, expected: "/de" }, + { input: { locale: "en" }, expected: "/en" }, + { input: { locale: "fr" }, expected: "/fr" }, + ], + }, ]; /** @@ -376,6 +400,45 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, + /** + * Patterns + */ + { + path: "/:locale(de|en)", + tests: [ + { input: "/de", expected: { path: "/de", params: { locale: "de" } } }, + { input: "/en", expected: { path: "/en", params: { locale: "en" } } }, + { input: "/fr", expected: false }, + { input: "/", expected: false }, + ], + }, + { + path: "/:foo(\\\\d)", + tests: [ + { + input: "/\\d", + expected: { path: "/\\d", params: { foo: "\\d" } }, + }, + ], + }, + { + path: "/file.*ext(png|jpg)", + tests: [ + { + input: "/file.png", + expected: { path: "/file.png", params: { ext: ["png"] } }, + }, + { + input: "/file.webp", + expected: false, + }, + { + input: "/file.jpg", + expected: { path: "/file.jpg", params: { ext: ["jpg"] } }, + }, + ], + }, + /** * Case-sensitive paths. */ diff --git a/src/index.spec.ts b/src/index.spec.ts index cef557f..d5e8fc5 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parse, compile, match, stringify } from "./index.js"; +import { parse, compile, match, stringify, pathToRegexp } from "./index.js"; import { PARSER_TESTS, COMPILE_TESTS, @@ -50,6 +50,22 @@ describe("path-to-regexp", () => { ), ); }); + + it("should throw on unbalanced pattern", () => { + expect(() => parse("/:foo((bar|sdfsdf)/")).toThrow( + new TypeError( + "Unbalanced pattern at 5: https://git.new/pathToRegexpError", + ), + ); + }); + + it("should throw on missing pattern", () => { + expect(() => parse("//:foo()")).toThrow( + new TypeError( + "Missing pattern at 6: https://git.new/pathToRegexpError", + ), + ); + }); }); describe("compile errors", () => { @@ -94,6 +110,14 @@ describe("path-to-regexp", () => { }); }); + describe("pathToRegexp errors", () => { + it("should throw on not allowed characters in pattern", () => { + expect(() => pathToRegexp("/:foo(\\d)")).toThrow( + new TypeError(`Only "|" meta character is allowed in pattern: \\d`), + ); + }); + }); + describe.each(PARSER_TESTS)( "parse $path with $options", ({ path, options, expected }) => { diff --git a/src/index.ts b/src/index.ts index c178797..cbcfda0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,23 @@ 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"; - +const PATTERN_META_CHARS = new Set([ + "*", + "+", + "?", + ".", + "^", + "$", + "|", + "\\", + "(", + ")", + "[", + "]", + "{", + "}", +]); +const ALLOWED_PATTERN_META_CHARS = new Set(["|"]); /** * Encode a string into another string. */ @@ -63,6 +79,7 @@ type TokenType = | "}" | "WILDCARD" | "PARAM" + | "PATTERN" | "CHAR" | "ESCAPED" | "END" @@ -89,7 +106,7 @@ const SIMPLE_TOKENS: Record = { "{": "{", "}": "}", // Reserved. - "(": "(", + // "(": "(", ")": ")", "[": "[", "]": "]", @@ -156,6 +173,39 @@ function* lexer(str: string): Generator { return value; } + function pattern() { + const pos = i++; + let depth = 1; + let pattern = ""; + + while (i < chars.length && depth > 0) { + const char = chars[i]; + + if (char === ")") { + depth--; + if (depth === 0) { + i++; + break; + } + } else if (char === "(") { + depth++; + } + + pattern += char; + i++; + } + + if (depth) { + throw new TypeError(`Unbalanced pattern at ${pos}: ${DEBUG_URL}`); + } + + if (!pattern) { + throw new TypeError(`Missing pattern at ${pos}: ${DEBUG_URL}`); + } + + return pattern; + } + while (i < chars.length) { const value = chars[i]; const type = SIMPLE_TOKENS[value]; @@ -167,6 +217,9 @@ function* lexer(str: string): Generator { } else if (value === ":") { const value = name(); yield { type: "PARAM", index: i, value }; + } else if (value === "(") { + const value = pattern(); + yield { type: "PATTERN", index: i, value }; } else if (value === "*") { const value = name(); yield { type: "WILDCARD", index: i, value }; @@ -231,6 +284,7 @@ export interface Text { export interface Parameter { type: "param"; name: string; + pattern?: string; } /** @@ -239,6 +293,7 @@ export interface Parameter { export interface Wildcard { type: "wildcard"; name: string; + pattern?: string; } /** @@ -287,18 +342,22 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { const param = it.tryConsume("PARAM"); if (param) { + const pattern = it.tryConsume("PATTERN"); tokens.push({ type: "param", name: param, + pattern, }); continue; } const wildcard = it.tryConsume("WILDCARD"); if (wildcard) { + const pattern = it.tryConsume("PATTERN"); tokens.push({ type: "wildcard", name: wildcard, + pattern, }); continue; } @@ -578,10 +637,14 @@ function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) { throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`); } - if (token.type === "param") { - result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; + if (token.pattern && isPatternSafe(token.pattern)) { + result += `(${token.pattern})`; } else { - result += `([\\s\\S]+)`; + if (token.type === "param") { + result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; + } else { + result += `([\\s\\S]+)`; + } } keys.push(token); @@ -646,3 +709,27 @@ function isNextNameSafe(token: Token | undefined) { if (!token || token.type !== "text") return true; return !ID_CONTINUE.test(token.value[0]); } + +/** + * Validate the pattern contains only allowed meta characters. + */ +function isPatternSafe(pattern: string) { + let i = 0; + while (i < pattern.length) { + const char = pattern[i]; + + if (char === "\\" && PATTERN_META_CHARS.has(pattern[i + 1])) { + i += 2; + } else if (PATTERN_META_CHARS.has(char)) { + if (!ALLOWED_PATTERN_META_CHARS.has(char)) { + throw new TypeError( + `Only "${[...ALLOWED_PATTERN_META_CHARS].join(", ")}" meta character is allowed in pattern: ${pattern}`, + ); + } + i++; + } else { + i++; + } + } + return true; +}