type PageHandler = ( outlet: HTMLElement, params: Record, ) => void | Promise; interface Route { pattern: RegExp; keys: string[]; handler: PageHandler; } const routes: Route[] = []; let notFoundHandler: PageHandler | null = null; export function route(path: string, handler: PageHandler) { if (path === "*") { notFoundHandler = handler; return; } const keys: string[] = []; const pattern = path.replace(/:(\w+)/g, (_, key: string) => { keys.push(key); return "([^/]+)"; }); routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler }); } export function navigate(path: string) { history.pushState(null, "", path); void render(); } async function render() { const path = location.pathname; const outlet = document.getElementById("outlet")!; for (const r of routes) { const match = path.match(r.pattern); if (match) { const params: Record = {}; r.keys.forEach((key, i) => { params[key] = match[i + 1]!; }); outlet.innerHTML = ""; await r.handler(outlet, params); return; } } outlet.innerHTML = ""; if (notFoundHandler) { await notFoundHandler(outlet, {}); } } export function initRouter() { window.addEventListener("popstate", () => void render()); document.addEventListener("click", (e) => { const anchor = (e.target as HTMLElement).closest("a"); if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) { e.preventDefault(); navigate(anchor.pathname); } }); void render(); }