From e537daadebe0dfd4b8e869ee7bb0347f444b8a46 Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Sun, 1 Sep 2024 15:34:01 -0700 Subject: [PATCH] Add a stringify API --- src/cases.spec.ts | 113 ++++++++++++++++++++++++++++++++++++---------- src/index.spec.ts | 21 +++++++-- src/index.ts | 34 ++++++++++++++ 3 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/cases.spec.ts b/src/cases.spec.ts index ef06e1f..30bea83 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,116 @@ 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" }, - ], + ]), + }, +]; + +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: "/\\:\\+\\?\\*", }, ]; 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..8daab82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,13 @@ const SIMPLE_TOKENS: Record = { "!": "!", }; +/** + * Escape text for stringify to path. + */ +function escapeText(str: string) { + return str.replace(/[{}()\[\]+?!:*]/g, "\\$&"); +} + /** * Escape a regular expression string. */ @@ -595,3 +602,30 @@ 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(stringifyToken).join(""); +} + +function stringifyToken(token: Token): string { + if (token.type === "text") return escapeText(token.value); + if (token.type === "group") { + return `{${token.tokens.map(stringifyToken).join("")}}`; + } + + const isSafe = isNameSafe(token.name); + 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}`); +} + +function isNameSafe(name: string) { + const [first, ...rest] = name; + if (!ID_START.test(first)) return false; + return rest.every((char) => ID_CONTINUE.test(char)); +}