mirror of
https://github.com/nvms/soma3.git
synced 2025-12-13 14:40:52 +00:00
261 lines
8.0 KiB
TypeScript
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));
|
|
}
|
|
}
|