soma3/src/plugins/router/plugin.ts
2024-10-19 08:17:26 -04:00

261 lines
8.0 KiB
TypeScript

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<RouterPlugin>();
const link = {
template: html`
<a {href:bind} @click="go" .class:bind="classes">
<slot>LINK</slot>
</a>
`,
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<boolean>): Promise<boolean> {
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<string, RouteExpression>();
lastPath = "/";
knownRouterViews = new Map<Element, Block>();
knownRouterViewNames = new Map<string, Element>();
populatedRouterViews = new Map<Element, { block: Block; route: Route }>();
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<Element>();
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));
}
}