import { updateUser } from "@app/auth"; import { ensureWindowState, escapeHTML } from "@app/utils"; ensureWindowState(); declare module "ft_state" { interface State { routes: Map; title: string; titleElem: HTMLDivElement; _routingHandler: boolean; } } window.__state.routes ??= new Map(); window.__state.title ??= ""; window.__state.titleElem ??= document.querySelector("#header-title")!; window.__state._routingHandler ??= false; // ---- Router logic ---- export function navigateTo(url: string) { if (url.startsWith("/") && !url.startsWith("/app")) url = `/app${url}`; history.pushState(null, "", url); handleRoute(); } type AsyncFunctionMaker any> = ( ...args: Parameters ) => Promise>; export type RouteHandlerParams = { [k: string]: string }; export type SyncRouteHandlerPostInsertFn = (appNode?: HTMLElement) => void; export type AsyncRouteHandlerPostInsertFn = AsyncFunctionMaker; export type RouteHandlerReturn = { html: string; postInsert?: SyncRouteHandlerPostInsertFn | AsyncRouteHandlerPostInsertFn; }; export type SyncRouteHandler = ( url: string, args: RouteHandlerParams, ) => RouteHandlerReturn | string; export type AsyncRouteHandler = AsyncFunctionMaker; export type RouteHandler = string | SyncRouteHandler | AsyncRouteHandler; export type Routes = Map; export type RouteHandlerSpecialArgs = { bypass_auth: boolean; }; export class RouteHandlerData { public readonly handler: RouteHandler; public readonly url: string; public readonly parts: (string | null)[]; public readonly args: (string | null)[]; public readonly orignal_url: string; public readonly special_args: RouteHandlerSpecialArgs; public static SPECIAL_ARGS_DEFAULT: RouteHandlerSpecialArgs = { bypass_auth: false, }; constructor( url: string, handler: RouteHandler, special_args: Partial, ) { this.special_args = Object.assign( {}, RouteHandlerData.SPECIAL_ARGS_DEFAULT, ); Object.assign(this.special_args, special_args); let parsed = RouteHandlerData.parseUrl(url); this.handler = handler; this.parts = parsed.parts.filter((p) => p?.length !== 0); this.url = parsed.parts .filter((p) => p?.length !== 0) .map((v, i) => v ?? `:${i}`) .reduce((p, c) => `${p}/${c}`, ""); this.args = parsed.args; this.orignal_url = parsed.original; } private static parseUrl(url: string): { parts: (string | null)[]; original: string; args: (string | null)[]; } { const deduped = url.replace(RegExp("/+"), "/"); const trimed = deduped.replace(RegExp("(^/)|(/$)"), ""); let parts = trimed.split("/"); let s = parts.map((part, idx) => { // then this is a parameter ! if (part.startsWith(":")) { let param_name = part.substring(1); // remove the : // verifiy that the parameter name only contains character, underscores and numbers (not in fist char tho) if (!param_name.match("^[a-zA-Z_][a-zA-Z_0-9]*$")) throw `route parameter ${idx} for url '${url}' contains illegal character`; return { idx, param_name, part: null }; } else { return { idx, param_name: null, part }; } }); { let dup = new Set(); for (const { param_name } of s) { if (param_name === null) continue; if (dup.has(param_name)) throw `route paramater '${param_name}' is a duplicate in route ${url}`; dup.add(param_name); } } let out_args = s.map((p) => p.param_name); let out_parts = s.map((p) => p.part); return { parts: out_parts, args: out_args, original: url, }; } } function urlToParts(url: string): string[] { const deduped = url.replace(RegExp("/+"), "/"); const trimed = deduped.replace(RegExp("(^/)|(/$)"), ""); let parts = trimed.split("/"); if (parts.at(0) === "app") parts.shift(); return parts.filter((p) => p.length !== 0); } export function addRoute( url: string, handler: RouteHandler | string, args?: Partial, ) { let d = new RouteHandlerData(url, handler, args ?? {}); if (window.__state.routes.has(d.url)) throw `Tried to insert route ${url}, but it already exists`; window.__state.routes.set(d.url, d); } export function setTitle(title: string) { window.__state.title = title; window.__state.titleElem.innerText = window.__state.title; } const executeRouteHandler = async ( handler: RouteHandlerData, ...args: Parameters ): Promise => { // handler may be a raw string literal, if yes => return it directly if (typeof handler.handler === "string") return { html: handler.handler }; // now we know handler is a function. what does it return ? we don't know // the two choices are either a string, or a Promise (needing an await to get the string) const result = handler.handler(...args); // if `result` is a promise, awaits it, otherwise do nothing let ret = result instanceof Promise ? await result : result; // if ret is a string, then no postInsert function exists => return a well formed object if (typeof ret === "string") return { html: ret }; return ret; }; const route_404_handler = new RouteHandlerData("", route_404, { bypass_auth: true, }); function parts_match(route_parts: (string | null)[], parts: string[]): boolean { if (route_parts.length !== parts.length) return false; let zipped = route_parts.map((v, i) => [v ?? parts[i], parts[i]]); return zipped.every(([lhs, rhs]) => lhs == rhs); } export async function handleRoute() { let routes = window.__state.routes; let parts = urlToParts(window.location.pathname); let routes_all = routes.entries(); let route_handler: RouteHandlerData = route_404_handler; let args: RouteHandlerParams = {}; for (const [_, route_data] of routes_all) { if (!parts_match(route_data.parts, parts)) continue; args = {}; route_data.args.forEach((v, i) => { if (v === null) return; args[v] = decodeURIComponent(parts[i]); }); route_handler = route_data; break; } let user = await updateUser(); if (user === null && !route_handler.special_args.bypass_auth) return navigateTo( `/login?returnTo=${encodeURIComponent(window.location.pathname)}`, ); const app = document.getElementById("app")!; const event = new CustomEvent("ft:pageChange", { detail: window.location.pathname }); document.dispatchEvent(event); let ret = await executeRouteHandler( route_handler, window.location.pathname, args, ); app.innerHTML = ret.html; if (ret.postInsert) { let r = ret.postInsert(app); if (r instanceof Promise) await r; } } if (!window.__state._routingHandler) { // ---- Intercept link clicks ---- document.addEventListener("click", (e) => { const target = e?.target; if (!target) return; if (!(target instanceof Element)) return; let link = target.closest("a[href]") as HTMLAnchorElement; if (!link) return; const url = new URL(link.href); const sameOrigin = url.origin === window.location.origin; if (sameOrigin) { e.preventDefault(); navigateTo(url.pathname); } }); // ---- Handle browser navigation (back/forward) ---- window.addEventListener("popstate", handleRoute); window.__state._routingHandler = true; } async function route_404( url: string, _args: RouteHandlerParams, ): Promise { return `
404 - Not Found

${escapeHTML(url)}
`; } Object.assign(window as any, { setTitle, addRoute, navigateTo });