import { Env } from "../component/component"; import { QWeb } from "../qweb/index"; import { shallowEqual } from "../utils"; type NavigationGuard = (info: { env: Env; to: Route | null; from: Route | null; }) => boolean | Destination; export interface Route { name: string; path: string; extractionRegExp: RegExp; component?: any; redirect?: Destination; params: string[]; beforeRouteEnter?: NavigationGuard; } export type RouteParams = { [key: string]: string | number }; export interface RouterEnv extends Env { router: Router; } export interface Destination { path?: string; to?: string; params?: RouteParams; } interface PositiveMatchResult { type: "match"; route: Route; params: RouteParams; } interface NegativeMatchResult { type: "nomatch"; } interface CancelledMatch { type: "cancelled"; } type MatchResult = PositiveMatchResult | NegativeMatchResult | CancelledMatch; interface Options { mode: Router["mode"]; } export interface EnvWithRouter extends Env { router: Router; } const paramRegexp = /\{\{(.*?)\}\}/; const globalParamRegexp = new RegExp(paramRegexp.source, "g"); export class Router { currentRoute: Route | null = null; currentParams: RouteParams | null = null; mode: "history" | "hash"; routes: { [id: string]: Route }; routeIds: string[]; env: RouterEnv; constructor( env: Partial, routes: Partial[], options: Options = { mode: "history" } ) { env.router = this; this.mode = options.mode; this.env = env as RouterEnv; this.routes = {}; this.routeIds = []; let nextId = 1; for (let partialRoute of routes) { if (!partialRoute.name) { partialRoute.name = "__route__" + nextId++; } if (partialRoute.component) { QWeb.registerComponent("__component__" + partialRoute.name, partialRoute.component); } if (partialRoute.redirect) { this.validateDestination(partialRoute.redirect); } partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : []; partialRoute.extractionRegExp = makeExtractionRegExp(partialRoute.path); this.routes[partialRoute.name] = partialRoute as Route; this.routeIds.push(partialRoute.name); } } //-------------------------------------------------------------------------- // Public API //-------------------------------------------------------------------------- async start() { (this as any)._listener = (ev) => this._navigate(this.currentPath(), ev); window.addEventListener("popstate", (this as any)._listener); if (this.mode === "hash") { window.addEventListener("hashchange", (this as any)._listener); } const result = await this.matchAndApplyRules(this.currentPath()); if (result.type === "match") { this.currentRoute = result.route; this.currentParams = result.params; const currentPath = this.routeToPath(result.route, result.params); if (currentPath !== this.currentPath()) { this.setUrlFromPath(currentPath); } } } async navigate(to: Destination): Promise { const path = this.destToPath(to); return this._navigate(path); } async _navigate(path: string, ev?: any): Promise { const initialName = this.currentRouteName; const initialParams = this.currentParams; const result = await this.matchAndApplyRules(path); if (result.type === "match") { const finalPath = this.routeToPath(result.route, result.params); const isPopStateEvent = ev && ev instanceof PopStateEvent; if (!isPopStateEvent) { this.setUrlFromPath(finalPath); } this.currentRoute = result.route; this.currentParams = result.params; } else if (result.type === "nomatch") { this.currentRoute = null; this.currentParams = null; } const didChange = this.currentRouteName !== initialName || !shallowEqual(this.currentParams, initialParams); if (didChange) { this.env.qweb.forceUpdate(); return true; } return false; } destToPath(dest: Destination): string { this.validateDestination(dest); return dest.path || this.routeToPath(this.routes[dest.to!], dest.params!); } get currentRouteName(): string | null { return this.currentRoute && this.currentRoute.name; } //-------------------------------------------------------------------------- // Private helpers //-------------------------------------------------------------------------- private setUrlFromPath(path: string) { const separator = this.mode === "hash" ? location.pathname : ""; const url = location.origin + separator + path; if (url !== window.location.href) { window.history.pushState({}, path, url); } } private validateDestination(dest: Destination) { if ((!dest.path && !dest.to) || (dest.path && dest.to)) { throw new Error(`Invalid destination: ${JSON.stringify(dest)}`); } } private routeToPath(route: Route, params: RouteParams): string { const prefix = this.mode === "hash" ? "#" : ""; return ( prefix + route.path.replace(globalParamRegexp, (match, param) => { const [key] = param.split("."); return params[key]; }) ); } private currentPath(): string { let result = this.mode === "history" ? window.location.pathname : window.location.hash.slice(1); return result || "/"; } private match(path: string): MatchResult { for (let routeId of this.routeIds) { let route = this.routes[routeId]; let params = this.getRouteParams(route, path); if (params) { return { type: "match", route: route, params: params, }; } } return { type: "nomatch" }; } private async matchAndApplyRules(path: string): Promise { const result = this.match(path); if (result.type === "match") { return this.applyRules(result); } return result; } private async applyRules(matchResult: PositiveMatchResult): Promise { const route = matchResult.route; if (route.redirect) { const path = this.destToPath(route.redirect); return this.matchAndApplyRules(path); } if (route.beforeRouteEnter) { const result = await route.beforeRouteEnter({ env: this.env, from: this.currentRoute, to: route, }); if (result === false) { return { type: "cancelled" }; } else if (result !== true) { // we want to navigate to another destination const path = this.destToPath(result); return this.matchAndApplyRules(path); } } return matchResult; } private getRouteParams(route: Route, path: string): RouteParams | false { if (route.path === "*") { return {}; } if (path.startsWith("#")) { path = path.slice(1); } const paramsMatch = path.match(route.extractionRegExp); if (!paramsMatch) { return false; } const result = {}; route.params.forEach((param, index) => { const [key, suffix] = param.split("."); const paramValue = paramsMatch[index + 1]; if (suffix === "number") { return (result[key] = parseInt(paramValue, 10)); } return (result[key] = paramValue); }); return result; } } function findParams(str: string): string[] { const result: string[] = []; let m; do { m = globalParamRegexp.exec(str); if (m) { result.push(m[1]); } } while (m); return result; } function escapeRegExp(str: string) { return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); } function makeExtractionRegExp(path: string) { // replace param strings with capture groups so that we can build a regex to match over the path const extractionString = path .split(paramRegexp) .map((part, index) => { return index % 2 ? "(.*)" : escapeRegExp(part); }) .join(""); // Example: /home/{{param1}}/{{param2}} => ^\/home\/(.*)\/(.*)$ return new RegExp(`^${extractionString}$`); }