diff --git a/packages/vue-router/__tests__/viewStacks.spec.ts b/packages/vue-router/__tests__/viewStacks.spec.ts index 0de9191daa..f2268466cb 100644 --- a/packages/vue-router/__tests__/viewStacks.spec.ts +++ b/packages/vue-router/__tests__/viewStacks.spec.ts @@ -13,12 +13,12 @@ describe('View Stacks', () => { const item = viewStacks.createViewItem( '1', () => {}, - 'mockMatchedRoute', + { path: '/mockMatchedRoute' }, { pathname: '/home' } ); expect(item.outletId).toEqual('1'); - expect(item.matchedRoute).toEqual('mockMatchedRoute'); + expect(item.matchedRoute).toEqual({ path: '/mockMatchedRoute' }); expect(item.pathname).toEqual('/home'); }); @@ -26,7 +26,7 @@ describe('View Stacks', () => { const item = viewStacks.createViewItem( '1', () => {}, - 'mockMatchedRoute', + { path: '/mockMatchedRoute' }, { pathname: '/home' } ); @@ -39,7 +39,7 @@ describe('View Stacks', () => { const item = viewStacks.createViewItem( '1', () => {}, - 'mockMatchedRoute', + { path: '/mockMatchedRoute' }, { pathname: '/home' } ); @@ -69,31 +69,44 @@ describe('View Stacks', () => { const itemA = createRegisteredViewItem(viewStacks, '1', '/home'); const itemB = createRegisteredViewItem(viewStacks, '2', '/dashboard'); - const getLeavingView = viewStacks.findLeavingViewItemByRouteInfo({ pathname: '/home', lastPathname: '/dashboard' }); + const getLeavingView = viewStacks.findLeavingViewItemByRouteInfo({ pathname: '/home', lastPathname: '/dashboard', matchedRoute: { path: '/home' } }); expect(getLeavingView).toEqual(itemB); }); it('should get children to render', () => { - const itemA = createRegisteredViewItem(viewStacks); - const itemB = createRegisteredViewItem(viewStacks); - const itemC = createRegisteredViewItem(viewStacks); + const itemA = createRegisteredViewItem(viewStacks); + const itemB = createRegisteredViewItem(viewStacks); + const itemC = createRegisteredViewItem(viewStacks); - itemA.mount = itemC.mount = true; + itemA.mount = itemC.mount = true; - const routes = viewStacks.getChildrenToRender('1'); - expect(routes).toEqual([ - itemA, - itemC - ]); - }); + const routes = viewStacks.getChildrenToRender(1); + expect(routes).toEqual([ + itemA, + itemC + ]); + }); + + it('should clear a stack', () => { + const itemA = createRegisteredViewItem(viewStacks, 2); + const itemB = createRegisteredViewItem(viewStacks, 2); + + const viewItems = viewStacks.getViewStack(2); + expect(viewItems.length).toEqual(2); + + viewStacks.clear('2'); + + const viewItemsAgain = viewStacks.getViewStack(2); + expect(viewItemsAgain).toEqual(undefined); + }) }) const createRegisteredViewItem = (viewStacks, outletId = '1', route = `/home/${counter++}`) => { const item = viewStacks.createViewItem( outletId, () => {}, - 'mockMatchedRoute', + { path: '/mockMatchedRoute' }, { pathname: route } ); diff --git a/packages/vue-router/package-lock.json b/packages/vue-router/package-lock.json index 9c4478f74b..67a59951e6 100644 --- a/packages/vue-router/package-lock.json +++ b/packages/vue-router/package-lock.json @@ -4670,6 +4670,11 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-to-regexp": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", + "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index a1f329755b..000ae62de8 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -66,5 +66,8 @@ "json", "jsx" ] + }, + "dependencies": { + "path-to-regexp": "^6.2.0" } } diff --git a/packages/vue-router/src/regexp.ts b/packages/vue-router/src/regexp.ts new file mode 100644 index 0000000000..f828d4cf90 --- /dev/null +++ b/packages/vue-router/src/regexp.ts @@ -0,0 +1,622 @@ +// @ts-nocheck +// https://github.com/pillarjs/path-to-regexp +// Included here so we do not need to add additional package.json dependency + +/** + * 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; + // tslint:disable-next-line + 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
( + str: string, + options?: ParseOptions & TokensToFunctionOptions +) { + return tokensToFunction
(parse(str, options), options); +} + +export type PathFunction
= (data?: P) => string; + +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction
( + tokens: Token[], + options: TokensToFunctionOptions = {} +): PathFunction
{
+ 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 {
+ path: string;
+ index: number;
+ params: P;
+}
+
+/**
+ * A match is either `false` (no match) or a match result.
+ */
+export type Match = false | MatchResult ;
+
+/**
+ * The match function takes a string and returns whether it matched the path.
+ */
+export type MatchFunction = (
+ path: string
+) => Match ;
+
+/**
+ * Create path match function from `path-to-regexp` spec.
+ */
+export function match (
+ str: Path,
+ options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions
+) {
+ const keys: Key[] = [];
+ const re = pathToRegexp(str, keys, options);
+ return regexpToFunction (re, keys, options);
+}
+
+/**
+ * Create a path match function from `path-to-regexp` output.
+ */
+export function regexpToFunction (
+ re: RegExp,
+ keys: Key[],
+ options: RegexpToFunctionOptions = {}
+): MatchFunction {
+ 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++) {
+ // tslint:disable-next-line
+ 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