import { createHashHistory } from "history"; import { h as create, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; const history = createHashHistory(); type PageDefinition> = { pattern: string; (params: DynamicPart): string; }; function replaceAll( pattern: string, vars: Record, values: Record, ): string { let result = pattern; for (const v in vars) { result = result.replace(vars[v], !values[v] ? "" : values[v]); } return result; } export function pageDefinition>( pattern: string, ): PageDefinition { const patternParams = pattern.match(/(:[\w?]*)/g); if (!patternParams) throw Error( `page definition pattern ${pattern} doesn't have any parameter`, ); const vars = patternParams.reduce((prev, cur) => { const pName = cur.match(/(\w+)/g); //skip things like :? in the path pattern if (!pName || !pName[0]) return prev; const name = pName[0]; return { ...prev, [name]: cur }; }, {} as Record); const f = (values: T): string => replaceAll(pattern, vars, values); f.pattern = pattern; return f; } export type PageEntry = T extends Record ? { url: PageDefinition; view: (props: T) => VNode; } : T extends unknown ? { url: string; view: (props: {}) => VNode; } : never; export function Router({ pageList, onNotFound, }: { pageList: Array>; onNotFound: () => VNode; }): VNode { const current = useCurrentLocation(pageList); if (current !== undefined) { return create(current.page.view, current.values); } return onNotFound(); } type Location = { page: PageEntry; path: string; values: Record; }; export function useCurrentLocation(pageList: Array>) { const [currentLocation, setCurrentLocation] = useState(); /** * Search path in the pageList * get the values from the path found * add params from searchParams * * @param path * @param params */ function doSync(path: string, params: URLSearchParams) { let result: typeof currentLocation; for (let idx = 0; idx < pageList.length; idx++) { const page = pageList[idx]; if (typeof page.url === "string") { if (page.url === path) { const values: Record = {}; params.forEach((v, k) => { values[k] = v; }); result = { page, values, path }; break; } } else { const values = doestUrlMatchToRoute(path, page.url.pattern); if (values !== undefined) { params.forEach((v, k) => { values[k] = v; }); result = { page, values, path }; break; } } } setCurrentLocation(result); } useEffect(() => { doSync(window.location.hash, new URLSearchParams(window.location.search)); return history.listen(() => { doSync(window.location.hash, new URLSearchParams(window.location.search)); }); }, []); return currentLocation; } function doestUrlMatchToRoute( url: string, route: string, ): undefined | Record { const paramsPattern = /(?:\?([^#]*))?$/; // const paramsPattern = /(?:\?([^#]*))?(#.*)?$/; const params = url.match(paramsPattern); const urlWithoutParams = url.replace(paramsPattern, ""); const result: Record = {}; if (params && params[1]) { const paramList = params[1].split("&"); for (let i = 0; i < paramList.length; i++) { const idx = paramList[i].indexOf("="); const name = paramList[i].substring(0, idx); const value = paramList[i].substring(idx + 1); result[decodeURIComponent(name)] = decodeURIComponent(value); } } const urlSeg = urlWithoutParams.split("/"); const routeSeg = route.split("/"); let max = Math.max(urlSeg.length, routeSeg.length); for (let i = 0; i < max; i++) { if (routeSeg[i] && routeSeg[i].charAt(0) === ":") { const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, ""); const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || ""; const plus = ~flags.indexOf("+"); const star = ~flags.indexOf("*"); const val = urlSeg[i] || ""; if (!val && !star && (flags.indexOf("?") < 0 || plus)) { return undefined; } result[param] = decodeURIComponent(val); if (plus || star) { result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/"); break; } } else if (routeSeg[i] !== urlSeg[i]) { return undefined; } } return result; } const EMPTY: Record = {};