Skip to main content
Module

x/path_to_regexp/index.ts

Turn a path string such as `/user/:name` into a regular expression
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
/** * Tokenizer results. */interface LexToken { type: | "OPEN" | "CLOSE" | "PATTERN" | "NAME" | "CHAR" | "ESCAPED_CHAR" | "MODIFIER" | "END"; index: number; value: string;}
/** * Tokenize input string. */function lexer(str: string): LexToken[] { const tokens: LexToken[] = []; let i = 0;
while (i < str.length) { const char = str[i];
if (char === "*" || char === "+" || char === "?") { tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); continue; }
if (char === "\\") { tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); continue; }
if (char === "{") { tokens.push({ type: "OPEN", index: i, value: str[i++] }); continue; }
if (char === "}") { tokens.push({ type: "CLOSE", index: i, value: str[i++] }); continue; }
if (char === ":") { let name = ""; let j = i + 1;
while (j < str.length) { const code = str.charCodeAt(j);
if ( // `0-9` (code >= 48 && code <= 57) || // `A-Z` (code >= 65 && code <= 90) || // `a-z` (code >= 97 && code <= 122) || // `_` code === 95 ) { name += str[j++]; continue; }
break; }
if (!name) throw new TypeError(`Missing parameter name at ${i}`);
tokens.push({ type: "NAME", index: i, value: name }); i = j; continue; }
if (char === "(") { let count = 1; let pattern = ""; let j = i + 1;
if (str[j] === "?") { throw new TypeError(`Pattern cannot start with "?" at ${j}`); }
while (j < str.length) { if (str[j] === "\\") { pattern += str[j++] + str[j++]; continue; }
if (str[j] === ")") { count--; if (count === 0) { j++; break; } } else if (str[j] === "(") { count++; if (str[j + 1] !== "?") { throw new TypeError(`Capturing groups are not allowed at ${j}`); } }
pattern += str[j++]; }
if (count) throw new TypeError(`Unbalanced pattern at ${i}`); if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
tokens.push({ type: "PATTERN", index: i, value: pattern }); i = j; continue; }
tokens.push({ type: "CHAR", index: i, value: str[i++] }); }
tokens.push({ type: "END", index: i, value: "" });
return tokens;}
export interface ParseOptions { /** * Set the default delimiter for repeat parameters. (default: `'/'`) */ delimiter?: string; /** * List of characters to automatically consider prefixes when parsing. */ prefixes?: string;}
/** * Parse a string for the raw tokens. */export function parse(str: string, options: ParseOptions = {}): Token[] { const tokens = lexer(str); const { prefixes = "./" } = options; const defaultPattern = `[^${escapeString(options.delimiter || "/#?")}]+?`; const result: Token[] = []; let key = 0; let i = 0; let path = "";
const tryConsume = (type: LexToken["type"]): string | undefined => { if (i < tokens.length && tokens[i].type === type) return tokens[i++].value; };
const mustConsume = (type: LexToken["type"]): string => { const value = tryConsume(type); if (value !== undefined) return value; const { type: nextType, index } = tokens[i]; throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); };
const consumeText = (): string => { let result = ""; let value: string | undefined; while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { result += value; } return result; };
while (i < tokens.length) { const char = tryConsume("CHAR"); const name = tryConsume("NAME"); const pattern = tryConsume("PATTERN");
if (name || pattern) { let prefix = char || "";
if (prefixes.indexOf(prefix) === -1) { path += prefix; prefix = ""; }
if (path) { result.push(path); path = ""; }
result.push({ name: name || key++, prefix, suffix: "", pattern: pattern || defaultPattern, modifier: tryConsume("MODIFIER") || "", }); continue; }
const value = char || tryConsume("ESCAPED_CHAR"); if (value) { path += value; continue; }
if (path) { result.push(path); path = ""; }
const open = tryConsume("OPEN"); if (open) { const prefix = consumeText(); const name = tryConsume("NAME") || ""; const pattern = tryConsume("PATTERN") || ""; const suffix = consumeText();
mustConsume("CLOSE");
result.push({ name: name || (pattern ? key++ : ""), pattern: name && !pattern ? defaultPattern : pattern, prefix, suffix, modifier: tryConsume("MODIFIER") || "", }); continue; }
mustConsume("END"); }
return result;}
export interface TokensToFunctionOptions { /** * When `true` the regexp will be case sensitive. (default: `false`) */ sensitive?: boolean; /** * Function for encoding input strings for output. */ encode?: (value: string, token: Key) => string; /** * When `false` the function can produce an invalid (unmatched) path. (default: `true`) */ validate?: boolean;}
/** * Compile a string to a template function for the path. */export function compile<P extends object = object>( str: string, options?: ParseOptions & TokensToFunctionOptions) { return tokensToFunction<P>(parse(str, options), options);}
export type PathFunction<P extends object = object> = (data?: P) => string;
/** * Expose a method for transforming tokens into the path function. */export function tokensToFunction<P extends object = object>( tokens: Token[], options: TokensToFunctionOptions = {}): PathFunction<P> { const reFlags = flags(options); const { encode = (x: string) => x, validate = true } = options;
// Compile all the tokens into regexps. const matches = tokens.map((token) => { if (typeof token === "object") { return new RegExp(`^(?:${token.pattern})$`, reFlags); } });
return (data: Record<string, any> | null | undefined) => { let path = "";
for (let i = 0; i < tokens.length; i++) { const token = tokens[i];
if (typeof token === "string") { path += token; continue; }
const value = data ? data[token.name] : undefined; const optional = token.modifier === "?" || token.modifier === "*"; const repeat = token.modifier === "*" || token.modifier === "+";
if (Array.isArray(value)) { if (!repeat) { throw new TypeError( `Expected "${token.name}" to not repeat, but got an array` ); }
if (value.length === 0) { if (optional) continue;
throw new TypeError(`Expected "${token.name}" to not be empty`); }
for (let j = 0; j < value.length; j++) { const segment = encode(value[j], token);
if (validate && !(matches[i] as RegExp).test(segment)) { throw new TypeError( `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"` ); }
path += token.prefix + segment + token.suffix; }
continue; }
if (typeof value === "string" || typeof value === "number") { const segment = encode(String(value), token);
if (validate && !(matches[i] as RegExp).test(segment)) { throw new TypeError( `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"` ); }
path += token.prefix + segment + token.suffix; continue; }
if (optional) continue;
const typeOfMessage = repeat ? "an array" : "a string"; throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`); }
return path; };}
export interface RegexpToFunctionOptions { /** * Function for decoding strings for params. */ decode?: (value: string, token: Key) => string;}
/** * A match result contains data about the path match. */export interface MatchResult<P extends object = object> { path: string; index: number; params: P;}
/** * A match is either `false` (no match) or a match result. */export type Match<P extends object = object> = false | MatchResult<P>;
/** * The match function takes a string and returns whether it matched the path. */export type MatchFunction<P extends object = object> = ( path: string) => Match<P>;
/** * Create path match function from `path-to-regexp` spec. */export function match<P extends object = object>( str: Path, options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions) { const keys: Key[] = []; const re = pathToRegexp(str, keys, options); return regexpToFunction<P>(re, keys, options);}
/** * Create a path match function from `path-to-regexp` output. */export function regexpToFunction<P extends object = object>( re: RegExp, keys: Key[], options: RegexpToFunctionOptions = {}): MatchFunction<P> { const { decode = (x: string) => x } = options;
return function (pathname: string) { const m = re.exec(pathname); if (!m) return false;
const { 0: path, index } = m; const params = Object.create(null);
for (let i = 1; i < m.length; i++) { if (m[i] === undefined) continue;
const key = keys[i - 1];
if (key.modifier === "*" || key.modifier === "+") { params[key.name] = m[i].split(key.prefix + key.suffix).map((value) => { return decode(value, key); }); } else { params[key.name] = decode(m[i], key); } }
return { path, index, params }; };}
/** * Escape a regular expression string. */function escapeString(str: string) { return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");}
/** * Get the flags for a regexp from the options. */function flags(options?: { sensitive?: boolean }) { return options && options.sensitive ? "" : "i";}
/** * Metadata about a key. */export interface Key { name: string | number; prefix: string; suffix: string; pattern: string; modifier: string;}
/** * A token is a string (nothing special) or key metadata (capture group). */export type Token = string | Key;
/** * Pull out keys from a regexp. */function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp { if (!keys) return path;
const groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g;
let index = 0; let execResult = groupsRegex.exec(path.source); while (execResult) { keys.push({ // Use parenthesized substring match if available, index otherwise name: execResult[1] || index++, prefix: "", suffix: "", modifier: "", pattern: "", }); execResult = groupsRegex.exec(path.source); }
return path;}
/** * Transform an array into a regexp. */function arrayToRegexp( paths: Array<string | RegExp>, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions): RegExp { const parts = paths.map((path) => pathToRegexp(path, keys, options).source); return new RegExp(`(?:${parts.join("|")})`, flags(options));}
/** * Create a path regexp from string input. */function stringToRegexp( path: string, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions) { return tokensToRegexp(parse(path, options), keys, options);}
export interface TokensToRegexpOptions { /** * When `true` the regexp will be case sensitive. (default: `false`) */ sensitive?: boolean; /** * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) */ strict?: boolean; /** * When `true` the regexp will match to the end of the string. (default: `true`) */ end?: boolean; /** * When `true` the regexp will match from the beginning of the string. (default: `true`) */ start?: boolean; /** * Sets the final character for non-ending optimistic matches. (default: `/`) */ delimiter?: string; /** * List of characters that can also be "end" characters. */ endsWith?: string; /** * Encode path tokens for use in the `RegExp`. */ encode?: (value: string) => string;}
/** * Expose a function for taking tokens and returning a RegExp. */export function tokensToRegexp( tokens: Token[], keys?: Key[], options: TokensToRegexpOptions = {}) { const { strict = false, start = true, end = true, encode = (x: string) => x, delimiter = "/#?", endsWith = "", } = options; const endsWithRe = `[${escapeString(endsWith)}]|$`; const delimiterRe = `[${escapeString(delimiter)}]`; let route = start ? "^" : "";
// Iterate over the tokens and create our regexp string. for (const token of tokens) { if (typeof token === "string") { route += escapeString(encode(token)); } else { const prefix = escapeString(encode(token.prefix)); const suffix = escapeString(encode(token.suffix));
if (token.pattern) { if (keys) keys.push(token);
if (prefix || suffix) { if (token.modifier === "+" || token.modifier === "*") { const mod = token.modifier === "*" ? "?" : ""; route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; } else { route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; } } else { if (token.modifier === "+" || token.modifier === "*") { route += `((?:${token.pattern})${token.modifier})`; } else { route += `(${token.pattern})${token.modifier}`; } } } else { route += `(?:${prefix}${suffix})${token.modifier}`; } } }
if (end) { if (!strict) route += `${delimiterRe}?`;
route += !options.endsWith ? "$" : `(?=${endsWithRe})`; } else { const endToken = tokens[tokens.length - 1]; const isEndDelimited = typeof endToken === "string" ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 : endToken === undefined;
if (!strict) { route += `(?:${delimiterRe}(?=${endsWithRe}))?`; }
if (!isEndDelimited) { route += `(?=${delimiterRe}|${endsWithRe})`; } }
return new RegExp(route, flags(options));}
/** * Supported `path-to-regexp` input types. */export type Path = string | RegExp | Array<string | RegExp>;
/** * Normalize the given path string, returning a regular expression. * * An empty array can be passed in for the keys, which will hold the * placeholder key descriptions. For example, using `/user/:id`, `keys` will * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. */export function pathToRegexp( path: Path, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions) { if (path instanceof RegExp) return regexpToRegexp(path, keys); if (Array.isArray(path)) return arrayToRegexp(path, keys, options); return stringToRegexp(path, keys, options);}