type RouteParams = Record; type QueryParams = Record; export interface RouteContext { params: RouteParams; query: QueryParams } export type NextFunction = () => void; export type RouteHandler = (ctx: RouteContext) => HTMLElement; export type RouteGuard = (ctx: RouteContext, next: NextFunction) => void; export type LayoutHandler = () => { view: HTMLElement, slot: HTMLElement } interface Route { path: string; regex: RegExp, keys: string[], handler: RouteHandler; guards: RouteGuard[]; layout?: LayoutHandler } export class Router { private routes: Route[] = []; private rootElement: HTMLElement | null = null; private isTransitioning = false; private currentLayout: LayoutHandler | null = null; private currentSlot: HTMLElement | null = null; private static instance: Router | null = null; constructor(rootId: string) { this.rootElement = document.getElementById(rootId); Router.instance = this; window.addEventListener('hashchange', () => this.handleRoute()); window.addEventListener('load', () => this.handleRoute()) } public static reload() { if (Router.instance) { Router.instance.handleRoute(); } } public addRoute(path: string, handler: RouteHandler, guards: RouteGuard[] = [], layout?: LayoutHandler) { const keys: string[] = []; const regexpPath = path.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)' }) this.routes.push({ path, regex: new RegExp(`^${regexpPath}$`), keys, handler, guards, layout }) } private handleRoute() { if (this.isTransitioning) return; const hash = window.location.hash.slice(1) || '/'; const [path, queryString] = hash.split("?"); const query: QueryParams = {}; new URLSearchParams(queryString).forEach((val, key) => { query[key] = val }); for (const route of this.routes) { const match = path.match(route.regex); if (match) { const params: RouteParams = {}; route.keys.forEach((key, index) => params[key] = match[index + 1]); const ctx: RouteContext = { params, query }; this.runGuards(route.guards, ctx, () => { const pageElement = route.handler(ctx); const layoutChanged = route.layout !== this.currentLayout; const transitionTarget = layoutChanged ? this.rootElement : this.currentSlot this.performTransition(() => { if (layoutChanged) { if (route.layout) { const { view, slot } = route.layout(); this.rootElement!.innerHTML = ''; this.rootElement!.appendChild(view); this.currentSlot = slot; this.currentLayout = route.layout; } else { this.rootElement!.innerHTML = ''; this.currentSlot = this.rootElement; this.currentLayout = null; } } if (this.currentSlot) { this.currentSlot.innerHTML = '' this.currentSlot.appendChild(pageElement); } }, transitionTarget) }); return; } } } private runGuards(guards: RouteGuard[], ctx: RouteContext, callback: () => void) { let index = 0; const next = () => { if (index < guards.length) { guards[index++](ctx, next) } else { callback(); } } next(); } private performTransition(updateDomFn: () => void, targetElement: HTMLElement | null) { const el = targetElement || this.rootElement; if (!el) { updateDomFn(); return; } this.isTransitioning = true; this.rootElement.classList.add('fading'); setTimeout(() => { updateDomFn(); this.rootElement?.classList.remove('fading'); this.isTransitioning = false; }, 100) } static navigate(path: string) { window.location.hash = path; } }