diff --git a/package.json b/package.json index 7ae266e..5a00c10 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "size-limit": [ { "path": "dist/index.js", - "limit": "2 kB" + "limit": "2.1 kB" } ], "ts-scripts": { diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 21e38f0..af8be4e 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -146,6 +146,26 @@ export const PARSER_TESTS: ParserTestSet[] = [ "\\\\:test", ), }, + { + path: "/:username([a-zA-Z]+)", + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "username", pattern: "[a-zA-Z]+" }, + ], + "/:username([a-zA-Z]+)", + ), + }, + { + path: "/:id(\\.(json|xml))", + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "id", pattern: "\\.(json|xml)" }, + ], + "/:id(\\.(json|xml))", + ), + }, ]; export const STRINGIFY_TESTS: StringifyTestSet[] = [ @@ -231,6 +251,15 @@ export const STRINGIFY_TESTS: StringifyTestSet[] = [ }, expected: "/:test", }, + { + data: { + tokens: [ + { type: "text", value: "/" }, + { type: "param", name: "username", pattern: "[a-zA-Z]+" }, + ], + }, + expected: "/:username([a-zA-Z]+)", + }, ]; export const COMPILE_TESTS: CompileTestSet[] = [ @@ -1741,4 +1770,25 @@ export const MATCH_TESTS: MatchTestSet[] = [ { input: "/123", expected: { path: "/123", params: { test: "123" } } }, ], }, + + /** + * Pattern. + */ + { + path: "/:test(abc|123)", + tests: [ + { + input: "/abc", + expected: { path: "/abc", params: { test: "abc" } }, + }, + { + input: "/123", + expected: { path: "/123", params: { test: "123" } }, + }, + { + input: "/xyz", + expected: false, + }, + ], + }, ]; diff --git a/src/index.spec.ts b/src/index.spec.ts index a8e2a0f..7d78ca4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -77,6 +77,18 @@ describe("path-to-regexp", () => { new PathError("Unterminated quote at index 2", '/:"foo'), ); }); + + it("should throw on incomplete pattern", () => { + expect(() => parse("/(")).toThrow( + new PathError("Unbalanced pattern at index 2", "/("), + ); + }); + + it("should throw on missing opening pattern", () => { + expect(() => parse("/)")).toThrow( + new PathError("Unexpected ) at index 1, expected end", "/)"), + ); + }); }); describe("compile errors", () => { @@ -166,6 +178,23 @@ describe("path-to-regexp", () => { expect(stack).toContain("index.spec.ts"); } }); + + describe("patterns", () => { + it("should throw on unsupported pattern", () => { + expect(() => pathToRegexp("/:foo(??)")).toThrow( + new PathError( + 'Unsupported pattern "??" for "foo" param', + "/:foo(??)", + ), + ); + }); + + it("should throw on empty pattern", () => { + expect(() => pathToRegexp("/:foo()")).toThrow( + new PathError('Unsupported pattern "" for "foo" param', "/:foo()"), + ); + }); + }); }); describe("stringify errors", () => { diff --git a/src/index.ts b/src/index.ts index 052cfb5..32ba30e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,7 @@ type TokenType = | "}" | "wildcard" | "param" + | "pattern" | "char" | "escape" | "end" @@ -88,7 +89,6 @@ const SIMPLE_TOKENS: Record = { "{": "{", "}": "}", // Reserved. - "(": "(", ")": ")", "[": "[", "]": "]", @@ -125,6 +125,7 @@ export interface Text { export interface Parameter { type: "param"; name: string; + pattern?: string; } /** @@ -133,6 +134,7 @@ export interface Parameter { export interface Wildcard { type: "wildcard"; name: string; + pattern?: string; } /** @@ -228,6 +230,35 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { return value; } + function pattern() { + let count = 1; + let value = ""; + + while (index < chars.length) { + const char = chars[index++]; + + if (char === "\\") { + value += char + chars[index++]; + continue; + } + + if (char === "(") { + count++; + } else if (char === ")") { + count--; + if (count === 0) break; + } + + value += char; + } + + if (count) { + throw new PathError(`Unbalanced pattern at index ${index}`, str); + } + + return value; + } + while (index < chars.length) { const value = chars[index]; const type = SIMPLE_TOKENS[value]; @@ -240,6 +271,8 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { tokens.push({ type: "param", index: index++, value: name() }); } else if (value === "*") { tokens.push({ type: "wildcard", index: index++, value: name() }); + } else if (value === "(") { + tokens.push({ type: "pattern", index: index++, value: pattern() }); } else { tokens.push({ type: "char", index: index++, value }); } @@ -271,9 +304,13 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { } if (token.type === "param" || token.type === "wildcard") { + const pattern = + tokens[pos].type === "pattern" ? tokens[pos++].value : undefined; + output.push({ type: token.type, name: token.value, + pattern, }); continue; } @@ -562,11 +599,12 @@ function toRegExpSource( ); } - if (token.type === "param") { - result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; - } else { - result += `([\\s\\S]+)`; - } + result += safePattern( + token, + delimiter, + isSafeSegmentParam ? "" : backtrack, + originalPath, + ); keys.push(token); backtrack = ""; @@ -578,6 +616,33 @@ function toRegExpSource( return result; } +/** + * Validate supported pattern characters. + */ +function safePattern( + token: Parameter | Wildcard, + delimiter: string, + backtrack: string, + originalPath: string | undefined, +): string { + if (token.pattern !== undefined) { + if (!/^[a-zA-Z0-9\|]+$/.test(token.pattern)) { + throw new PathError( + `Unsupported pattern "${token.pattern}" for "${token.name}" ${token.type}`, + originalPath, + ); + } + + return `(${token.pattern})`; + } + + if (token.type === "param") { + return `(${negate(delimiter, backtrack)}+)`; + } + + return `([\\s\\S]+)`; // Wildcard (.+) +} + /** * Block backtracking on previous text and ignore delimiter string. */ @@ -618,12 +683,14 @@ function stringifyTokens(tokens: Token[]): string { } if (token.type === "param") { - value += `:${name(token.name)}`; + value += + `:${name(token.name)}` + (token.pattern ? `(${token.pattern})` : ""); continue; } if (token.type === "wildcard") { - value += `*${name(token.name)}`; + value += + `*${name(token.name)}` + (token.pattern ? `(${token.pattern})` : ""); continue; }