import { pathToRegexp } from "path-to-regexp"; import { Route, RouteExpression, RouteMatch } from "."; import { Plugin } from ".."; import { App, Block, Component, current } from "../.."; import { reactive } from "../../reactivity/reactive"; import { unwrap } from "../../reactivity/unwrap"; import { html } from "../../util"; const activeRouters = new Set(); const link = { template: html` LINK `, props: { href: { default: "#" } }, main({ href }: { href: string }) { const go = (e: Event) => { e.preventDefault(); activeRouters.forEach((router) => { router.doRouteChange(unwrap(href as unknown) as string); }); }; const classes = reactive({ "router-link": true }); return { go, classes, href }; }, }; async function runEnterTransition(enter: () => boolean | Promise): Promise { return await enter(); } const canEnterRoute = async (route: Route) => { if (route.beforeEnter) { return await runEnterTransition(route.beforeEnter); } return true; }; const maybeRedirectRoute = (route: Route) => { if (route.redirectTo) { activeRouters.forEach((plugin) => plugin.doRouteChange(route.redirectTo)); } }; export class RouterPlugin implements Plugin { app: App; routes: Route[] = []; pathExpressions = new Map(); lastPath = "/"; knownRouterViews = new Map(); knownRouterViewNames = new Map(); populatedRouterViews = new Map(); constructor(routes: Route[] = []) { this.routes = routes; } use(app: App, ...config: any[]) { this.app = app; this.app.register("router-link", link); window.addEventListener("popstate", this.onHistoryEvent.bind(this)); window.addEventListener("pushstate", this.onHistoryEvent.bind(this)); window.addEventListener("load", this.onHistoryEvent.bind(this)); for (const route of this.routes) { this.cacheRouteExpression(route); } this.lastPath = `${location.pathname}${location.search}`; window.history.replaceState({}, "", this.lastPath); activeRouters.add(this); } compile(element: Element) { if (element.nodeType === Node.ELEMENT_NODE && element.nodeName === "ROUTER-VIEW" && !this.knownRouterViews.has(element) && current.componentBlock) { this.knownRouterViews.set(element, current.componentBlock); this.knownRouterViewNames.set(element.getAttribute("name")?.trim() || "", element); } } onHistoryEvent(e: PopStateEvent | Event) { e.preventDefault(); e.stopImmediatePropagation(); // @ts-ignore const path = new URL(e.currentTarget.location.href).pathname; if (e.type === "load") { window.history.replaceState({}, "", this.lastPath); } else if (e.type === "pushstate") { window.history.replaceState({}, "", path); } else if (e.type === "popstate") { window.history.replaceState({}, "", path); } this.lastPath = path; const matches = this.getMatchesForURL(path); this.applyMatches(matches); } doRouteChange(to: string) { window.history.pushState({}, "", to); const matches = this.getMatchesForURL(`${location.pathname}${location.search}`); this.applyMatches(matches); } getMatchesForURL(url: string): RouteMatch[] { let matches: RouteMatch[] = []; const matchRoutes = (routes: Route[], parentPath: string = "", previousParents = []): RouteMatch[] => { let parents = []; for (const route of routes) { parents.push(route); const path = `${parentPath}${route.path}`.replace(/\/\//g, "/"); const match = this.getPathMatch(path, url); if (match) matches.push({ match, parents: [...previousParents, ...parents] }); if (route.children?.length) { matchRoutes(route.children, path, [...previousParents, ...parents]); parents = []; } } return matches; }; matches = matchRoutes(this.routes); return matches; } /** * getRouteExpression takes a path like "/users/:id" and returns a regex * and an array of params that match the path. * "/users/:id" => { regex: /^\/users\/([^\/]+)\?jwt=(\w)$/, params: ["id"], query: ["jwt"] } */ getRouteExpression(path: string, route: Route): RouteExpression { if (this.pathExpressions.has(path)) return this.pathExpressions.get(path); const params = []; const regex = pathToRegexp(path, params, { strict: false, sensitive: false, end: true }); const expression = { regex, params, path, route }; this.pathExpressions.set(path, expression); return expression; } /** * * @param path A path like /foo/bar/:id * @param url A url like /foo/bar/1234 * @returns A RouteExpression if the URL matches the regex cached for @param path, null otherwise. */ getPathMatch(path: string, url: string): RouteExpression | null { if (this.pathExpressions.get(path)) { const match = this.pathExpressions.get(path).regex.exec(url); if (match) { return this.pathExpressions.get(path); } } return null; } async applyMatches(matches: RouteMatch[] | null) { if (!matches) return; const usedRouterViews = new Set(); const renderRoutes = async (routeChain: Route[], rootNode?: Element) => { for (const route of routeChain) { if (route.view) { const viewNode = this.knownRouterViewNames.get(route.view); if (viewNode && (await canEnterAndRenderRoute(viewNode, route))) { continue; } } else if (rootNode && (await canEnterAndRenderRoute(rootNode, route))) { continue; } } }; const canEnterAndRenderRoute = async (node: Element, route: Route) => { const canEnter = await canEnterRoute(route); if (canEnter) { renderRouteAtNode(node, route); return true; } else { if (route.componentFallback) { renderRouteAtNode(node, route, route.componentFallback); } else { maybeRedirectRoute(route); } return false; } }; const renderRouteAtNode = (node: Element, route: Route, component?: Component) => { if (!usedRouterViews.has(node) || this.populatedRouterViews.get(node)?.route !== route) { const div = document.createElement("div"); node.replaceChildren(div); const target = div.parentElement; const block = new Block({ element: div, component: component ? component : route.component, replacementType: "replaceChildren", parentContext: current.componentBlock.context, }); target.replaceChild(block.element, div); this.populatedRouterViews.set(node, { block, route }); usedRouterViews.add(node); } }; for (const match of matches) { const routeChain = [...match.parents, match.match.route]; const uniqueRouteChain = routeChain.filter((route, index, self) => index === self.findIndex((r) => r.path === route.path)); const rootNode = this.knownRouterViewNames.get("") ?? null; await renderRoutes(uniqueRouteChain, rootNode); } // Clean up stale views for (const node of this.knownRouterViews.keys()) { if (!usedRouterViews.has(node) && this.populatedRouterViews.has(node)) { const entry = this.populatedRouterViews.get(node); if (entry) { entry.block.teardown(); this.populatedRouterViews.delete(node); } } } } cacheRouteExpression(route: Route, parentPath: string = "") { const path = `${parentPath}${route.path}`.replace(/\/\//g, "/"); this.getRouteExpression(path, route); if (route.children?.length) { route.children.forEach((child) => { this.cacheRouteExpression(child, path); }); } } destroy() { window.removeEventListener("popstate", this.onHistoryEvent.bind(this)); window.removeEventListener("pushstate", this.onHistoryEvent.bind(this)); window.removeEventListener("load", this.onHistoryEvent.bind(this)); } }