import { TranslatedString } from "@gnu-taler/taler-util"; import { createHashHistory } from "history"; import { ComponentChildren, h as create, createContext, VNode } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; type ContextType = { onChange: (listener: () => void) => VoidFunction } const nullChangeListener = { onChange: () => () => { } } const Context = createContext(nullChangeListener); export const usePathChangeContext = (): ContextType => useContext(Context); export function HashPathProvider({ children }: { children: ComponentChildren }): VNode { const history = createHashHistory(); return create(Context.Provider, { value: { onChange: history.listen }, children }, children) } 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; name: TranslatedString, Icon?: () => VNode, } : T extends unknown ? { url: string; view: (props: {}) => VNode; name: TranslatedString, Icon?: () => 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>): Location | undefined { const [currentLocation, setCurrentLocation] = useState(null); const path = usePathChangeContext(); useEffect(() => { return path.onChange(() => { const result = doSync(window.location.hash, new URLSearchParams(window.location.search), pageList); setCurrentLocation(result); }); }, []); if (currentLocation === null) { return doSync(window.location.hash, new URLSearchParams(window.location.search), pageList); } return currentLocation; } export function useChangeLocation() { const [location, setLocation] = useState(window.location.hash) const path = usePathChangeContext() useEffect(() => { return path.onChange(() => { setLocation(window.location.hash) }); }, []); return location; } /** * Search path in the pageList * get the values from the path found * add params from searchParams * * @param path * @param params */ export function doSync(path: string, params: URLSearchParams, pageList: Array>): Location | undefined { 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; }); return { page, values, path }; } } else { const values = doestUrlMatchToRoute(path, page.url.pattern); if (values !== undefined) { params.forEach((v, k) => { values[k] = v; }); return { page, values, path }; } } } return undefined; } 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 = {};