UNPKG

router-dom

Version:

A lightweight router for everyone

316 lines (315 loc) 12 kB
import { pathToRegexp, match } from "path-to-regexp"; import { render, html, hydro, $, $$, setReuseElements } from "hydro-js"; let router; const storageKey = "router-scroll"; const outletSelector = "[data-outlet]"; const reactivityRegex = /\{\{([^]*?)\}\}/; const fetchCache = new WeakMap(); let base = $("base")?.getAttribute("href") || ""; if (base.endsWith("/")) { base = [...base].slice(0, -1).join(""); } 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; routes; oldRoute; constructor(routes, 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, }); }); Reflect.deleteProperty(route, "children"); } } const newRoutes = routes.map((route) => { return { restoreScroll: true, ...route, path: pathToRegexp(base + route.path), originalPath: base + route.path, }; }); 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 }; fetchCache.set(route, cache); setTimeout(() => { requestIdleCallback(() => { cache.promise = fetch(route.templateUrl, { signal: controller.signal, }); cache.promise .then((res) => res.text()) .then((_html) => { cache.html = _html; }) .catch(async (err) => { await this.options.errorHandler?.(err); }); }); }); } }); this.doRouting(); } getMatchingRoute(path) { if (path.startsWith(".")) { path = path.replace(".", ""); } return this.routes.find((route) => route.path.exec(path)); } async doRouting(to = location.pathname + location.search, e) { dispatchEvent(new Event("beforeRouting")); const from = this.oldRoute ?? to; const route = this.getMatchingRoute(to); if (route) { // Store position let currStorageKey; if (this.oldRoute) { currStorageKey = `${storageKey}-${from}`; sessionStorage.setItem(currStorageKey, `${scrollX} ${scrollY}`); } try { const { params } = match(route.originalPath, { decode: decodeURIComponent, })(to); const allParams = { ...Router.getParams(), ...Object.fromEntries(Object.entries(params) .map((pair) => Number.isNaN(Number(pair[0])) && pair) .filter(Boolean)), }; 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["leave" /* cycles.leave */]?.(props); this.oldRoute = route.originalPath; } } // Trigger beforeEnter await route["beforeEnter" /* 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.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.append(html `${route.element}`); render(copy, where, false); } else { // Clear outlet $(outletSelector).textContent = null; } currStorageKey = `${storageKey}-${to}`; // Trigger afterEnter await route["afterEnter" /* 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, state, 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) { const idx = this.routes.findIndex((route) => String(route.path) === String(pathToRegexp(path))); if (idx > -1) { this.routes.splice(idx, 1); } } addRoute(route) { this.routes.push({ ...route, path: pathToRegexp(base + route.path), originalPath: base + route.path, }); } modifyRoute(path, newRoute) { 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) { this.options = options; } static getParams(search = location.search) { return Object.fromEntries(new URLSearchParams(search)); } } function registerAnchorEvent(anchor) { if (anchor.getAttribute("href")?.startsWith("http")) return; anchor.addEventListener("click", (e) => { 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) { 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) { 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) { return ["form", "a"].includes(elem.localName) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); let formOrA; while ((formOrA = nodes.nextNode())) { if (formOrA.localName === "a") { registerAnchorEvent(formOrA); } else { registerFormEvent(formOrA); } } } } }).observe(document.body, { childList: true, subtree: true }); async function handleTemplate(route, where) { 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.append(window.isHMR || !cacheObj?.hasOwnProperty("html") ? html `${await (await fetch(route.templateUrl)).text()}` : html `${(await cacheObj.html) || ""}`); render(copy, where, false); }