/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ 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< Location | null | undefined >(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 = {};