diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 18d9159..482d363 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -1645,6 +1645,35 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, ], }, + { + path: "%25:foo..:bar", + options: { + delimiter: "%25", + }, + tests: [ + { + input: "%25hello..world", + expected: { + path: "%25hello..world", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "%25555..222", + expected: { + path: "%25555..222", + params: { foo: "555", bar: "222" }, + }, + }, + { + input: "%25555....222%25", + expected: { + path: "%25555....222%25", + params: { foo: "555..", bar: "222" }, + }, + }, + ], + }, /** * Array input is normalized. diff --git a/src/index.spec.ts b/src/index.spec.ts index 9186cc7..9e6cd05 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -159,6 +159,14 @@ describe("path-to-regexp", () => { }); }); + describe("stringify errors", () => { + it("should error on unknown token", () => { + expect(() => + stringify({ tokens: [{ type: "unknown", value: "test" } as any] }), + ).toThrow(new TypeError("Unknown token type: unknown")); + }); + }); + describe.each(PARSER_TESTS)( "parse $path with $options", ({ path, options, expected }) => { diff --git a/src/index.ts b/src/index.ts index 98c7c3c..92550ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -619,25 +619,51 @@ function negate(delimiter: string, backtrack: string) { } /** - * Stringify token data into a path string. + * Stringify an array of tokens 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("")}}`; - } +function stringifyTokens(tokens: Token[]): string { + let value = ""; + let i = 0; + + function name(value: string) { + const isSafe = isNameSafe(value) && isNextNameSafe(tokens[i]); + return isSafe ? value : JSON.stringify(value); + } + + while (i < tokens.length) { + const token = tokens[i++]; - const isSafe = - isNameSafe(token.name) && isNextNameSafe(tokens[index + 1]); - const key = isSafe ? token.name : JSON.stringify(token.name); + if (token.type === "text") { + value += escapeText(token.value); + continue; + } - if (token.type === "param") return `:${key}`; - if (token.type === "wildcard") return `*${key}`; - throw new TypeError(`Unexpected token: ${token}`); - }) - .join(""); + if (token.type === "group") { + value += `{${stringifyTokens(token.tokens)}}`; + continue; + } + + if (token.type === "param") { + value += `:${name(token.name)}`; + continue; + } + + if (token.type === "wildcard") { + value += `*${name(token.name)}`; + continue; + } + + throw new TypeError(`Unknown token type: ${(token as any).type}`); + } + + return value; +} + +/** + * Stringify token data into a path string. + */ +export function stringify(data: TokenData) { + return stringifyTokens(data.tokens); } /** @@ -645,14 +671,13 @@ export function stringify(data: TokenData) { */ function isNameSafe(name: string) { const [first, ...rest] = name; - if (!ID_START.test(first)) return false; - return rest.every((char) => ID_CONTINUE.test(char)); + return ID_START.test(first) && rest.every((char) => ID_CONTINUE.test(char)); } /** * Validate the next token does not interfere with the current param name. */ function isNextNameSafe(token: Token | undefined) { - if (!token || token.type !== "text") return true; - return !ID_CONTINUE.test(token.value[0]); + if (token && token.type === "text") return !ID_CONTINUE.test(token.value[0]); + return true; }