UNPKG

@thepassle/app-tools

Version:

Collection of tools I regularly use to build apps. Maybe they're useful to somebody else. Maybe not. Most of these are thin wrappers around native API's, like the native `<dialog>` element, `fetch` API, and `URLPattern`.

243 lines (211 loc) 6.33 kB
import { createLogger } from '../utils/log.js'; const log = createLogger('router'); class RouteEvent extends Event { /** * @param {Context} context */ constructor(context) { super('route-changed'); this.context = context; } } /** * @typedef {import('./types.js').Plugin} Plugin * @typedef {import('./types.js').Context} Context * @typedef {import('./types.js').RouteDefinition} RouteDefinition * @typedef {import('./types.js').Route} Route * @typedef {import('./types.js').Config} Config */ export class Router extends EventTarget { context = { params: {}, query: {}, title: '', url: new URL(window.location.href), } /** * @param {Config} config */ constructor(config) { super(); this.config = config; /** @type {Route[]} */ this.routes = config.routes.map((route) => { const r = /** @type {unknown} */ ({ ...route, // @ts-ignore urlPattern: new URLPattern({ pathname: route.path, baseURL: window.location.href, search: '*', hash: '*', }), }); return /** @type {Route} */ (r); }); log('Initialized routes', this.routes); queueMicrotask(() => { this.navigate(new URL(window.location.href), { replace: true }); }); window.addEventListener('popstate', this._onPopState); window.addEventListener('click', this._onAnchorClick); } uninstall() { window.removeEventListener('popstate', this._onPopState); window.removeEventListener('click', this._onAnchorClick); } get url() { return new URL(window.location.href); } get fallback() { return new URL( this.config?.fallback || this.baseUrl.href.substring(window.location.origin.length), this.baseUrl ) } get baseUrl() { return new URL('./', document.baseURI); } /** * @template RenderResult */ render() { log(`Rendering route ${this.context.url.pathname}${this.context.url.search}${this.context.url.hash}`, { context: this.context, route: this.route }); return /** @type {RenderResult} */ (this.route?.render?.(this.context)); } /** * @private * @param {URL} url * @returns {Route | null} */ _matchRoute(url) { for (const route of this.routes) { const match = route.urlPattern.exec(url); if (match) { const { title } = route; const query = Object.fromEntries(new URLSearchParams(url.search)); const params = match?.pathname?.groups ?? {}; this.context = { url, title: typeof title === 'function' ? title({params, query, url}) : title, params, query, } return route; } } log(`No route matched for ${url.pathname}${url.search}${url.hash}`, url); return null; } /** * @private */ _notifyUrlChanged() { this.dispatchEvent(new RouteEvent(this.context)); } /** * @private */ _onPopState = () => { this.navigate(new URL(window.location.href), { backNav: true }); } /** * @private */ _onAnchorClick = (e) => { if ( e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey ) { return; } const a = e.composedPath().find((el) => el.tagName === 'A'); if (!a || !a.href) return; const url = new URL(a.href); if (this.url.href === url.href) return; if (url.host !== window.location.host) return; if (a.hasAttribute('download') || a.href.includes('mailto:')) return; const target = a.getAttribute('target'); if (target && target !== '' && target !== '_self') return; e.preventDefault(); this.navigate(url); } /** * @private */ _collectPlugins(route) { return [ ...(this.config?.plugins ?? []), ...(route?.plugins ?? []), ] } /** * @param {string | URL} url The URL to navigate to. * @param {{ * backNav?: boolean, * replace?: boolean, * }} options options An options object to configure the navigation. The backNav property specifies whether the navigation is a backward navigation, which doesn't push the navigation into browser nav history. */ async navigate(url, options = {}) { if (typeof url === 'string') { url = new URL(url, this.baseUrl); } let route = this._matchRoute(url) || this._matchRoute(this.fallback); log(`Navigating to ${url.pathname}${url.search}${url.hash}`, { context: this.context, route: this.route }); /** @type {Plugin[]} */ let plugins = this._collectPlugins(route); let redirecting; do { redirecting = false; for (const plugin of plugins) { try { const result = await plugin?.shouldNavigate?.(this.context); if (result) { const condition = await result.condition(); if (!condition) { url = new URL(result.redirect, this.baseUrl); route = this._matchRoute(url) || this._matchRoute(this.fallback); plugins = this._collectPlugins(route); log("Redirecting", { context: this.context, route: this.route }); redirecting = true; break; } } } catch (e) { log(`Plugin "${plugin.name}" error on shouldNavigate hook`, e); throw e; } } } while (redirecting); this.route = route; if (!this.route) { throw new Error(`[ROUTER] No route or fallback matched for url ${url}`); } for (const plugin of plugins) { try { await plugin?.beforeNavigation?.(this.context); } catch(e) { log(`Plugin "${plugin.name}" error on beforeNavigation hook`, e); throw e; } } if (options?.replace) { window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); } else if (!options.backNav) { window.history.pushState(null, '', `${url.pathname}${url.search}${url.hash}`); } document.title = this.context.title; this._notifyUrlChanged(); for (const plugin of plugins) { try { await plugin?.afterNavigation?.(this.context); } catch(e) { log(`Plugin "${plugin.name}" error on afterNavigation hook`, e); throw e; } } } }