/*
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 = {};