| /** |
| * @license |
| * Copyright 2023 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| |
| /** |
| * This file was originally a copy of https://github.com/visionmedia/page.js. |
| * It was converted to TypeScript and stripped off lots of code that we don't |
| * need in Gerrit. Thus we reproduce the original LICENSE in js_licenses.txt. |
| */ |
| |
| /** |
| * This is what registered routes have to provide, see `registerRoute()` and |
| * `registerExitRoute()`. |
| * `context` provides information about the matched parameters in the URL. |
| * Then you can decide to handle the route exclusively (not calling `next()`), |
| * or to pass it on to other registered routes. Normally you would not call |
| * `next()`, because your regex matching the URL was specific enough. |
| */ |
| export type PageCallback = ( |
| context: PageContext, |
| next: PageNextCallback |
| ) => void; |
| |
| /** See comment on `PageCallback` above. */ |
| export type PageNextCallback = () => void; |
| |
| /** Options for starting the router. */ |
| export interface PageOptions { |
| /** |
| * Should the router inspect the current URL and dispatch it when the router |
| * is started? Default is `true`, but can be turned off for testing. |
| */ |
| dispatch: boolean; |
| |
| /** |
| * The base path of the application. For Gerrit this must be set to |
| * getBaseUrl(). |
| */ |
| base: string; |
| } |
| |
| /** |
| * The browser `History` API allows `pushState()` to contain an arbitrary state |
| * object. Our router only sets `path` on the state and inspects it when |
| * handling `popstate` events. This interface is internal only. |
| */ |
| interface PageState { |
| path?: string; |
| } |
| |
| const clickEvent = document.ontouchstart ? 'touchstart' : 'click'; |
| |
| export class Page { |
| /** |
| * When a new URL is dispatched all these routes are called one after another. |
| * If a route decides that it wants to handle a URL, then it does not call |
| * next(). |
| */ |
| private entryRoutes: PageCallback[] = []; |
| |
| /** |
| * Before a new URL is dispatched exit routes for the previous URL are called. |
| * They can clean up some state for example. But they could also prevent the |
| * user from navigating away (from within the app), if they don't call next(). |
| */ |
| private exitRoutes: PageCallback[] = []; |
| |
| /** |
| * The path that is currently being dispatched. This is used, so that we can |
| * check whether a context is still valid, i.e. ctx.path === currentPath. |
| */ |
| private currentPath = ''; |
| |
| /** |
| * The base path of the application. For Gerrit this must be set to |
| * getBaseUrl(). For example https://gerrit.wikimedia.org/ uses r/ as its |
| * base path. |
| */ |
| private base = ''; |
| |
| /** |
| * Is set at the beginning of start() and stop(), so that you cannot start |
| * the routing twice. |
| */ |
| private running = false; |
| |
| /** |
| * Keeping around the previous context for being able to call exit routes |
| * after creating a new context. |
| */ |
| private prevPageContext?: PageContext; |
| |
| /** |
| * We don't want to handle popstate events before the document is loaded. |
| */ |
| private documentLoaded = false; |
| |
| start(options: PageOptions = {dispatch: true, base: ''}) { |
| if (this.running) return; |
| this.running = true; |
| this.base = options.base; |
| |
| window.document.addEventListener(clickEvent, this.clickHandler); |
| window.addEventListener('load', this.loadHandler); |
| window.addEventListener('popstate', this.popStateHandler); |
| if (document.readyState === 'complete') this.documentLoaded = true; |
| |
| if (options.dispatch) { |
| const loc = window.location; |
| this.replace(loc.pathname + loc.search + loc.hash); |
| } |
| } |
| |
| stop() { |
| if (!this.running) return; |
| this.currentPath = ''; |
| this.running = false; |
| |
| window.document.removeEventListener(clickEvent, this.clickHandler); |
| window.removeEventListener('popstate', this.popStateHandler); |
| window.removeEventListener('load', this.loadHandler); |
| } |
| |
| show(path: string, push = true) { |
| const ctx = new PageContext(path, {}, this.base); |
| const prev = this.prevPageContext; |
| this.prevPageContext = ctx; |
| this.currentPath = ctx.path; |
| this.dispatch(ctx, prev); |
| if (push && !ctx.preventPush) ctx.pushState(); |
| } |
| |
| redirect(to: string) { |
| setTimeout(() => this.replace(to), 0); |
| } |
| |
| replace(path: string, state: PageState = {}, dispatch = true) { |
| const ctx = new PageContext(path, state, this.base); |
| const prev = this.prevPageContext; |
| this.prevPageContext = ctx; |
| this.currentPath = ctx.path; |
| ctx.replaceState(); // replace before dispatching, which may redirect |
| if (dispatch) this.dispatch(ctx, prev); |
| } |
| |
| dispatch(ctx: PageContext, prev?: PageContext) { |
| let j = 0; |
| const nextExit = () => { |
| const fn = this.exitRoutes[j++]; |
| // First call the exit routes of the previous context. Then proceed |
| // to the entry routes for the new context. |
| if (!fn) { |
| nextEnter(); |
| return; |
| } |
| fn(prev!, nextExit); |
| }; |
| |
| let i = 0; |
| const nextEnter = () => { |
| const fn = this.entryRoutes[i++]; |
| |
| // Concurrency protection. The context is not valid anymore. |
| // Stop calling any further route handlers. |
| if (ctx.path !== this.currentPath) { |
| ctx.preventPush = true; |
| return; |
| } |
| |
| // You must register a route that handles everything (.*) and does not |
| // call next(). |
| if (!fn) throw new Error('No route has handled the URL.'); |
| |
| fn(ctx, nextEnter); |
| }; |
| |
| if (prev) { |
| nextExit(); |
| } else { |
| nextEnter(); |
| } |
| } |
| |
| registerRoute(re: RegExp, fn: PageCallback) { |
| this.entryRoutes.push(createRoute(re, fn)); |
| } |
| |
| registerExitRoute(re: RegExp, fn: PageCallback) { |
| this.exitRoutes.push(createRoute(re, fn)); |
| } |
| |
| loadHandler = () => { |
| setTimeout(() => (this.documentLoaded = true), 0); |
| }; |
| |
| clickHandler = (e: MouseEvent | TouchEvent) => { |
| if ((e as MouseEvent).button !== 0) return; |
| if (e.metaKey || e.ctrlKey || e.shiftKey) return; |
| if (e.defaultPrevented) return; |
| |
| let el = e.target as HTMLAnchorElement; |
| const eventPath = e.composedPath(); |
| if (eventPath) { |
| for (let i = 0; i < eventPath.length; i++) { |
| const pathEl = eventPath[i] as HTMLAnchorElement; |
| if (!pathEl.nodeName) continue; |
| if (pathEl.nodeName.toUpperCase() !== 'A') continue; |
| if (!pathEl.href) continue; |
| |
| el = pathEl; |
| break; |
| } |
| } |
| |
| while (el && 'A' !== el.nodeName.toUpperCase()) |
| el = el.parentNode as HTMLAnchorElement; |
| if (!el || 'A' !== el.nodeName.toUpperCase()) return; |
| |
| if (el.hasAttribute('download') || el.getAttribute('rel') === 'external') |
| return; |
| const link = el.getAttribute('href'); |
| if (samePath(el) && (el.hash || '#' === link)) return; |
| if (link && link.indexOf('mailto:') > -1) return; |
| if (el.target) return; |
| if (!sameOrigin(el.href)) return; |
| |
| let path = el.pathname + el.search + (el.hash ?? ''); |
| path = path[0] !== '/' ? '/' + path : path; |
| |
| const orig = path; |
| if (path.indexOf(this.base) === 0) { |
| path = path.substr(this.base.length); |
| } |
| if (this.base && orig === path && window.location.protocol !== 'file:') { |
| return; |
| } |
| e.preventDefault(); |
| this.show(orig); |
| }; |
| |
| popStateHandler = (e: PopStateEvent) => { |
| if (!this.documentLoaded) return; |
| if (e.state) { |
| const path = e.state.path; |
| this.replace(path, e.state); |
| } else { |
| const loc = window.location; |
| this.show(loc.pathname + loc.search + loc.hash, /* push */ false); |
| } |
| }; |
| } |
| |
| function sameOrigin(href: string) { |
| if (!href) return false; |
| const url = new URL(href, window.location.toString()); |
| const loc = window.location; |
| return ( |
| loc.protocol === url.protocol && |
| loc.hostname === url.hostname && |
| loc.port === url.port |
| ); |
| } |
| |
| function samePath(url: HTMLAnchorElement) { |
| const loc = window.location; |
| return url.pathname === loc.pathname && url.search === loc.search; |
| } |
| |
| function escapeRegExp(s: string) { |
| return s.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1'); |
| } |
| |
| function decodeURIComponentString(val: string | undefined | null) { |
| if (!val) return ''; |
| return decodeURIComponent(val.replace(/\+/g, ' ')); |
| } |
| |
| export class PageContext { |
| /** |
| * Includes everything: base, path, query and hash. |
| * NOT decoded. |
| */ |
| canonicalPath = ''; |
| |
| /** |
| * Does not include base path. |
| * Does not include hash. |
| * Includes query string. |
| * NOT decoded. |
| */ |
| path = ''; |
| |
| /** Decoded. Does not include hash. */ |
| querystring = ''; |
| |
| /** Decoded. */ |
| hash = ''; |
| |
| /** |
| * Regular expression matches of capturing groups. The first entry params[0] |
| * corresponds to the first capturing group. The entire matched string is not |
| * returned in this array. |
| * Each param is double decoded. |
| */ |
| params: string[] = []; |
| |
| /** |
| * Prevents `show()` from eventually calling `pushState()`. For example if |
| * the current context is not "valid" anymore, i.e. the URL has changed in the |
| * meantime. |
| * |
| * This is router internal state. Do not use it from routes. |
| */ |
| preventPush = false; |
| |
| private title = ''; |
| |
| constructor( |
| path: string, |
| private readonly state: PageState = {}, |
| pageBase = '' |
| ) { |
| this.title = window.document.title; |
| |
| if ('/' === path[0] && 0 !== path.indexOf(pageBase)) path = pageBase + path; |
| this.canonicalPath = path; |
| const re = new RegExp('^' + escapeRegExp(pageBase)); |
| this.path = path.replace(re, '') || '/'; |
| this.state.path = path; |
| |
| const i = path.indexOf('?'); |
| this.querystring = |
| i !== -1 ? decodeURIComponentString(path.slice(i + 1)) : ''; |
| |
| // Does the path include a hash? If yes, then remove it from path and |
| // querystring. |
| if (this.path.indexOf('#') === -1) return; |
| const parts = this.path.split('#'); |
| this.path = parts[0]; |
| this.hash = decodeURIComponentString(parts[1]) || ''; |
| this.querystring = this.querystring.split('#')[0]; |
| } |
| |
| pushState() { |
| window.history.pushState(this.state, this.title, this.canonicalPath); |
| } |
| |
| replaceState() { |
| window.history.replaceState(this.state, this.title, this.canonicalPath); |
| } |
| |
| match(re: RegExp) { |
| const qsIndex = this.path.indexOf('?'); |
| const pathname = qsIndex !== -1 ? this.path.slice(0, qsIndex) : this.path; |
| const matches = re.exec(decodeURIComponent(pathname)); |
| if (matches) { |
| this.params = matches |
| .slice(1) |
| .map(match => decodeURIComponentString(match)); |
| } |
| return !!matches; |
| } |
| } |
| |
| function createRoute(re: RegExp, fn: Function) { |
| return (ctx: PageContext, next: Function) => { |
| const matches = ctx.match(re); |
| if (matches) { |
| fn(ctx, next); |
| } else { |
| next(); |
| } |
| }; |
| } |