168 lines
4.7 KiB
TypeScript
168 lines
4.7 KiB
TypeScript
import { createHashHistory } from "history";
|
|
import { h as create, VNode } from "preact";
|
|
import { useEffect, useState } from "preact/hooks";
|
|
const history = createHashHistory();
|
|
|
|
type PageDefinition<DynamicPart extends Record<string, string>> = {
|
|
pattern: string;
|
|
(params: DynamicPart): string;
|
|
};
|
|
|
|
function replaceAll(
|
|
pattern: string,
|
|
vars: Record<string, string>,
|
|
values: Record<string, string>,
|
|
): string {
|
|
let result = pattern;
|
|
for (const v in vars) {
|
|
result = result.replace(vars[v], !values[v] ? "" : values[v]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function pageDefinition<T extends Record<string, string>>(
|
|
pattern: string,
|
|
): PageDefinition<T> {
|
|
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<string, string>);
|
|
|
|
const f = (values: T): string => replaceAll(pattern, vars, values);
|
|
f.pattern = pattern;
|
|
return f;
|
|
}
|
|
|
|
export type PageEntry<T = unknown> = T extends Record<string, string>
|
|
? {
|
|
url: PageDefinition<T>;
|
|
view: (props: T) => VNode;
|
|
}
|
|
: T extends unknown
|
|
? {
|
|
url: string;
|
|
view: (props: {}) => VNode;
|
|
}
|
|
: never;
|
|
|
|
export function Router({
|
|
pageList,
|
|
onNotFound,
|
|
}: {
|
|
pageList: Array<PageEntry<any>>;
|
|
onNotFound: () => VNode;
|
|
}): VNode {
|
|
const current = useCurrentLocation(pageList);
|
|
if (current !== undefined) {
|
|
return create(current.page.view, current.values);
|
|
}
|
|
return onNotFound();
|
|
}
|
|
|
|
type Location = {
|
|
page: PageEntry<any>;
|
|
path: string;
|
|
values: Record<string, string>;
|
|
};
|
|
export function useCurrentLocation(pageList: Array<PageEntry<any>>) {
|
|
const [currentLocation, setCurrentLocation] = useState<Location>();
|
|
/**
|
|
* 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<string, string> = {};
|
|
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<string, string> {
|
|
const paramsPattern = /(?:\?([^#]*))?$/;
|
|
// const paramsPattern = /(?:\?([^#]*))?(#.*)?$/;
|
|
const params = url.match(paramsPattern);
|
|
const urlWithoutParams = url.replace(paramsPattern, "");
|
|
|
|
const result: Record<string, string> = {};
|
|
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<string, string> = {};
|