UNPKG

router-dom

Version:

A lightweight router for everyone

406 lines (367 loc) 12.4 kB
import type { MatchResult } from "path-to-regexp"; import { pathToRegexp, match } from "path-to-regexp"; import { render, html, hydro, $, $$, setReuseElements } from "hydro-js"; let router: Router; const storageKey = "router-scroll"; const outletSelector = "[data-outlet]"; const reactivityRegex = /\{\{([^]*?)\}\}/; const fetchCache = new WeakMap<Route, Cache>(); let base = $("base")?.getAttribute("href") || ""; if (base.endsWith("/")) { base = [...base].slice(0, -1).join(""); } interface CustomWindow extends Window { isHMR: boolean; } declare var window: CustomWindow; addEventListener("popstate", async (e) => { router?.doRouting(location.pathname + location.search, e); }); // Reload -> store scrollPosition addEventListener("beforeunload", () => sessionStorage.setItem( `${storageKey}-${location.pathname + location.search}`, `${scrollX} ${scrollY}` ) ); export default class Router { options: Options; routes: [Route, ...Route[]]; oldRoute: undefined | string; constructor(routes: [RouteParam, ...RouteParam[]], options: Options = {}) { // Handle nested routes const length = routes.length - 1; for (let i = length; i >= 0; i--) { const route = routes[i]; if (route.children) { route.children.forEach((child, idx) => { routes.splice(i + idx, 0, { ...child, path: `${route.path}/${child.path}`, isChildOf: route, } as RouteParam); }); Reflect.deleteProperty(route, "children"); } } const newRoutes = routes.map((route) => { return { restoreScroll: true, ...route, path: pathToRegexp(base + route.path), originalPath: base + route.path, }; }) as [Route, ...Route[]]; this.routes = newRoutes; this.options = options; router = this; // Prefetch resources this.routes.forEach((route) => { // @ts-expect-error if (route.templateUrl && !navigator.connection?.saveData) { const controller = new AbortController(); const cache = { promise: null, controller } as Cache; fetchCache.set(route, cache); setTimeout(() => { requestIdleCallback(() => { cache.promise = fetch(route.templateUrl!, { signal: controller.signal, }); (cache.promise as unknown as Promise<Response>) .then((res) => res.text()) .then((_html) => { cache.html = _html; }) .catch(async (err) => { await this.options.errorHandler?.(err); }); }); }); } }); this.doRouting(); } private getMatchingRoute(path: string): Route | undefined { if (path.startsWith(".")) { path = path.replace(".", ""); } return this.routes.find((route) => route.path.exec(path)); } async doRouting( to: string = location.pathname + location.search, e?: PopStateEvent ) { dispatchEvent(new Event("beforeRouting")); const from = this.oldRoute ?? to; const route = this.getMatchingRoute(to); if (route) { // Store position let currStorageKey: string; if (this.oldRoute) { currStorageKey = `${storageKey}-${from}`; sessionStorage.setItem(currStorageKey, `${scrollX} ${scrollY}`); } try { const { params } = match(route.originalPath, { decode: decodeURIComponent, })(to) as MatchResult; const allParams = { ...Router.getParams(), ...Object.fromEntries( Object.entries(params) .map((pair) => Number.isNaN(Number(pair[0])) && pair) .filter(Boolean) as Iterable<[string | symbol, string]> ), }; const props = { from: from.replace(base, ""), to: to.replace(base, ""), ...(Object.keys(allParams).length ? { params: allParams } : {}), ...(history.state && Object.keys(history.state).length ? { state: history.state } : {}), }; // Trigger leave if (this.oldRoute) { const oldRoute = this.routes.find((route) => route.path.exec(this.oldRoute!) ); if (oldRoute) { await oldRoute[cycles.leave]?.(props); this.oldRoute = route.originalPath; } } // Trigger beforeEnter await route[cycles.beforeEnter]?.(props); // Handle template / element if (!!route.isChildOf) { setReuseElements(false); const parent = route.isChildOf!; if (parent.templateUrl) { await handleTemplate(parent, $(outletSelector)!); } else if (parent.element) { const copy = $(outletSelector)!.cloneNode(); (copy as Element).append(html`${parent.element}`); render(copy, outletSelector, false); } setReuseElements(true); } const where = route.isChildOf ? $(outletSelector)!.querySelector(outletSelector)! : $(outletSelector)!; if (route?.templateUrl) { await handleTemplate(route, where); } else if (route?.element) { const copy = where.cloneNode(); (copy as Element).append(html`${route.element}`); render(copy, where, false); } else { // Clear outlet $(outletSelector)!.textContent = null; } currStorageKey = `${storageKey}-${to}`; // Trigger afterEnter await route[cycles.afterEnter]?.(props); } catch (err) { if (this.options.errorHandler) { await this.options.errorHandler(err, e); } else { console.error(err, e); } } finally { // Reload -> restore scroll position if (route.restoreScroll && sessionStorage.getItem(currStorageKey!)) { const [left, top] = sessionStorage .getItem(currStorageKey!)! .split(" ") .map(Number); sessionStorage.removeItem(currStorageKey!); scrollTo({ top, left, behavior: this.options.scrollBehavior || "auto", }); } else { // Reset Scroll, just like Browser if (!window.isHMR) { scrollTo({ top: 0, left: 0, behavior: this.options.scrollBehavior || "auto", }); } } dispatchEvent(new Event("afterRouting")); } } } go(path: string, state: LooseObject, params = "") { this.oldRoute = location.pathname + location.search; const newPath = base + path + params; // Only navigate when the path differs if (newPath !== this.oldRoute) { history.pushState({ ...state }, "", newPath); this.doRouting(newPath); } } removeRoute(path: string) { const idx = this.routes.findIndex( (route) => String(route.path) === String(pathToRegexp(path)) ); if (idx > -1) { this.routes.splice(idx, 1); } } addRoute(route: RouteParam) { this.routes.push({ ...route, path: pathToRegexp(base + route.path), originalPath: base + route.path, }); } modifyRoute(path: string, newRoute: RouteParam) { const idx = this.routes.findIndex( (route) => String(route.path) === String(pathToRegexp(path)) ); if (idx > -1) { this.routes[idx] = { ...newRoute, path: pathToRegexp(base + newRoute.path), originalPath: base + path, }; } } changeOptions(options: Options) { this.options = options; } static getParams(search = location.search) { return Object.fromEntries(new URLSearchParams(search)); } } function registerAnchorEvent(anchor: HTMLAnchorElement) { if (anchor.getAttribute("href")?.startsWith("http")) return; anchor.addEventListener("click", (e: MouseEvent) => { e.preventDefault(); const hasData = anchor.getAttribute("data"); const hydroProp = replaceBars(hasData); const href = anchor.getAttribute("href") || ""; router.go(href, hasData ? hydro[hydroProp!] : void 0); }); } function registerFormEvent(form: HTMLFormElement) { form.addEventListener("submit", (e) => { if (!router.options.formHandler) return; e.preventDefault(); const action = form.getAttribute("action")!; const method = form.getAttribute("method")!; fetch(action, { method, ...(!["HEAD", "GET"].includes(method.toUpperCase()) ? { body: new FormData(form) } : {}), ...router.options.fetchOptions, }) .then((res) => router.options.formHandler!(res, e)) .catch(async (err) => { if (router.options.errorHandler) { await router.options.errorHandler(err, e); } else { console.error(err, e); } }); }); } function replaceBars(hydroTerm: string | null) { if (hydroTerm === null || !hydroTerm.includes("{{")) return hydroTerm; const [_, hydroPath] = hydroTerm.match(reactivityRegex) || []; return hydroPath; } // Add EventListener for every added anchor and form Element $$("a").forEach(registerAnchorEvent); $$("form").forEach(registerFormEvent); new MutationObserver((entries) => { for (const entry of entries) { for (const node of entry.addedNodes) { const nodes = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT, { acceptNode(elem: Element) { return ["form", "a"].includes(elem.localName) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); let formOrA: HTMLAnchorElement | HTMLFormElement; while ( (formOrA = nodes.nextNode() as HTMLAnchorElement | HTMLFormElement) ) { if (formOrA.localName === "a") { registerAnchorEvent(formOrA as HTMLAnchorElement); } else { registerFormEvent(formOrA as HTMLFormElement); } } } } }).observe(document.body, { childList: true, subtree: true }); async function handleTemplate(route: Route, where: Element) { let cacheObj = fetchCache.get(route); if (!fetchCache.has(route) || cacheObj?.promise === null) { cacheObj?.controller?.abort(); const data = await fetch(route.templateUrl!); if (!cacheObj) { cacheObj = { html: await data.text(), }; fetchCache.set(route, cacheObj); } else { cacheObj.html = await data.text(); } } Reflect.deleteProperty(cacheObj!, "controller"); const copy = where.cloneNode(); (copy as Element).append( window.isHMR || !cacheObj?.hasOwnProperty("html") ? html`${await (await fetch(route.templateUrl!)).text()}` : html`${(await cacheObj!.html) || ""}` ); render(copy, where, false); } const enum cycles { leave = "leave", beforeEnter = "beforeEnter", afterEnter = "afterEnter", } interface RouteBasic { templateUrl?: string; element?: Node | string; [cycles.leave]?(routingProps: RoutingProps): Promise<any> | void; [cycles.beforeEnter]?(routingProps: RoutingProps): Promise<any> | void; [cycles.afterEnter]?(routingProps: RoutingProps): Promise<any> | void; restoreScroll?: boolean; } interface RouteParam extends RouteBasic { path: string; children?: Array<RouteParam>; } interface Route extends RouteBasic { isChildOf?: Route; path: RegExp; originalPath: string; } interface Options { errorHandler?(err: unknown, e?: PopStateEvent | Event): Promise<any> | void; formHandler?(res: Response, e: Event): Promise<any> | void; scrollBehavior?: ScrollBehavior; fetchOptions?: RequestInit; } interface RoutingProps { from: string; to: string; state?: LooseObject; params?: LooseObject; } type LooseObject = Record<keyof any, any>; type Cache = { promise?: null | Promise<Response>; controller?: AbortController; html?: string; };