288 lines
6.3 KiB
TypeScript
288 lines
6.3 KiB
TypeScript
|
// NIRAX --- A lightweight router
|
||
|
|
||
|
import { EventEmitter } from "eventemitter3";
|
||
|
import { Ref, Component, ref, shallowRef, ShallowRef } from "vue";
|
||
|
import { pleaseLogin } from "@/scripts/please-login";
|
||
|
import { safeURIDecode } from "@/scripts/safe-uri-decode";
|
||
|
|
||
|
type RouteDef = {
|
||
|
path: string;
|
||
|
component: Component;
|
||
|
query?: Record<string, string>;
|
||
|
loginRequired?: boolean;
|
||
|
name?: string;
|
||
|
hash?: string;
|
||
|
globalCacheKey?: string;
|
||
|
children?: RouteDef[];
|
||
|
};
|
||
|
|
||
|
type ParsedPath = (
|
||
|
| string
|
||
|
| {
|
||
|
name: string;
|
||
|
startsWith?: string;
|
||
|
wildcard?: boolean;
|
||
|
optional?: boolean;
|
||
|
}
|
||
|
)[];
|
||
|
|
||
|
export type Resolved = {
|
||
|
route: RouteDef;
|
||
|
props: Map<string, string>;
|
||
|
child?: Resolved;
|
||
|
};
|
||
|
|
||
|
function parsePath(path: string): ParsedPath {
|
||
|
const res = [] as ParsedPath;
|
||
|
|
||
|
path = path.substring(1);
|
||
|
|
||
|
for (const part of path.split("/")) {
|
||
|
if (part.includes(":")) {
|
||
|
const prefix = part.substring(0, part.indexOf(":"));
|
||
|
const placeholder = part.substring(part.indexOf(":") + 1);
|
||
|
const wildcard = placeholder.includes("(*)");
|
||
|
const optional = placeholder.endsWith("?");
|
||
|
res.push({
|
||
|
name: placeholder.replace("(*)", "").replace("?", ""),
|
||
|
startsWith: prefix !== "" ? prefix : undefined,
|
||
|
wildcard,
|
||
|
optional,
|
||
|
});
|
||
|
} else if (part.length !== 0) {
|
||
|
res.push(part);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return res;
|
||
|
}
|
||
|
|
||
|
export class Router extends EventEmitter<{
|
||
|
change: (ctx: {
|
||
|
beforePath: string;
|
||
|
path: string;
|
||
|
resolved: Resolved;
|
||
|
key: string;
|
||
|
}) => void;
|
||
|
replace: (ctx: {
|
||
|
path: string;
|
||
|
key: string;
|
||
|
}) => void;
|
||
|
push: (ctx: {
|
||
|
beforePath: string;
|
||
|
path: string;
|
||
|
route: RouteDef | null;
|
||
|
props: Map<string, string> | null;
|
||
|
key: string;
|
||
|
}) => void;
|
||
|
same: () => void;
|
||
|
}> {
|
||
|
private routes: RouteDef[];
|
||
|
public current: Resolved;
|
||
|
public currentRef: ShallowRef<Resolved> = shallowRef();
|
||
|
public currentRoute: ShallowRef<RouteDef> = shallowRef();
|
||
|
private currentPath: string;
|
||
|
private currentKey = Date.now().toString();
|
||
|
|
||
|
public navHook: ((path: string, flag?: any) => boolean) | null = null;
|
||
|
|
||
|
constructor(routes: Router["routes"], currentPath: Router["currentPath"]) {
|
||
|
super();
|
||
|
|
||
|
this.routes = routes;
|
||
|
this.currentPath = currentPath;
|
||
|
this.navigate(currentPath, null, false);
|
||
|
}
|
||
|
|
||
|
public resolve(path: string): Resolved | null {
|
||
|
let queryString: string | null = null;
|
||
|
let hash: string | null = null;
|
||
|
if (path[0] === "/") path = path.substring(1);
|
||
|
if (path.includes("#")) {
|
||
|
hash = path.substring(path.indexOf("#") + 1);
|
||
|
path = path.substring(0, path.indexOf("#"));
|
||
|
}
|
||
|
if (path.includes("?")) {
|
||
|
queryString = path.substring(path.indexOf("?") + 1);
|
||
|
path = path.substring(0, path.indexOf("?"));
|
||
|
}
|
||
|
|
||
|
if (_DEV_) console.log("Routing: ", path, queryString);
|
||
|
|
||
|
function check(routes: RouteDef[], _parts: string[]): Resolved | null {
|
||
|
forEachRouteLoop: for (const route of routes) {
|
||
|
let parts = [..._parts];
|
||
|
const props = new Map<string, string>();
|
||
|
|
||
|
pathMatchLoop: for (const p of parsePath(route.path)) {
|
||
|
if (typeof p === "string") {
|
||
|
if (p === parts[0]) {
|
||
|
parts.shift();
|
||
|
} else {
|
||
|
continue forEachRouteLoop;
|
||
|
}
|
||
|
} else {
|
||
|
if (parts[0] == null && !p.optional) {
|
||
|
continue forEachRouteLoop;
|
||
|
}
|
||
|
if (p.wildcard) {
|
||
|
if (parts.length !== 0) {
|
||
|
props.set(p.name, safeURIDecode(parts.join("/")));
|
||
|
parts = [];
|
||
|
}
|
||
|
break pathMatchLoop;
|
||
|
} else {
|
||
|
if (p.startsWith) {
|
||
|
if (parts[0] == null || !parts[0].startsWith(p.startsWith))
|
||
|
continue forEachRouteLoop;
|
||
|
|
||
|
props.set(
|
||
|
p.name,
|
||
|
safeURIDecode(parts[0].substring(p.startsWith.length)),
|
||
|
);
|
||
|
parts.shift();
|
||
|
} else {
|
||
|
if (parts[0]) {
|
||
|
props.set(p.name, safeURIDecode(parts[0]));
|
||
|
}
|
||
|
parts.shift();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (parts.length === 0) {
|
||
|
if (route.children) {
|
||
|
const child = check(route.children, []);
|
||
|
if (child) {
|
||
|
return {
|
||
|
route,
|
||
|
props,
|
||
|
child,
|
||
|
};
|
||
|
} else {
|
||
|
continue forEachRouteLoop;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (route.hash != null && hash != null) {
|
||
|
props.set(route.hash, safeURIDecode(hash));
|
||
|
}
|
||
|
|
||
|
if (route.query != null && queryString != null) {
|
||
|
const queryObject = [
|
||
|
...new URLSearchParams(queryString).entries(),
|
||
|
].reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
|
||
|
|
||
|
for (const q in route.query) {
|
||
|
const as = route.query[q];
|
||
|
if (queryObject[q]) {
|
||
|
props.set(as, safeURIDecode(queryObject[q]));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
route,
|
||
|
props,
|
||
|
};
|
||
|
} else {
|
||
|
if (route.children) {
|
||
|
const child = check(route.children, parts);
|
||
|
if (child) {
|
||
|
return {
|
||
|
route,
|
||
|
props,
|
||
|
child,
|
||
|
};
|
||
|
} else {
|
||
|
}
|
||
|
} else {
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
const _parts = path.split("/").filter((part) => part.length !== 0);
|
||
|
|
||
|
return check(this.routes, _parts);
|
||
|
}
|
||
|
|
||
|
private navigate(
|
||
|
path: string,
|
||
|
key: string | null | undefined,
|
||
|
emitChange = true,
|
||
|
) {
|
||
|
const beforePath = this.currentPath;
|
||
|
this.currentPath = path;
|
||
|
|
||
|
const res = this.resolve(this.currentPath);
|
||
|
|
||
|
if (res == null) {
|
||
|
throw new Error(`no route found for: ${path}`);
|
||
|
}
|
||
|
|
||
|
if (res.route.loginRequired) {
|
||
|
pleaseLogin("/");
|
||
|
}
|
||
|
|
||
|
const isSamePath = beforePath === path;
|
||
|
if (isSamePath && key == null) key = this.currentKey;
|
||
|
this.current = res;
|
||
|
this.currentRef.value = res;
|
||
|
this.currentRoute.value = res.route;
|
||
|
this.currentKey = res.route.globalCacheKey ?? key ?? path;
|
||
|
|
||
|
if (emitChange) {
|
||
|
this.emit("change", {
|
||
|
beforePath,
|
||
|
path,
|
||
|
resolved: res,
|
||
|
key: this.currentKey,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return res;
|
||
|
}
|
||
|
|
||
|
public getCurrentPath() {
|
||
|
return this.currentPath;
|
||
|
}
|
||
|
|
||
|
public getCurrentKey() {
|
||
|
return this.currentKey;
|
||
|
}
|
||
|
|
||
|
public push(path: string, flag?: any) {
|
||
|
const beforePath = this.currentPath;
|
||
|
if (path === beforePath) {
|
||
|
this.emit("same");
|
||
|
return;
|
||
|
}
|
||
|
if (this.navHook) {
|
||
|
const cancel = this.navHook(path, flag);
|
||
|
if (cancel) return;
|
||
|
}
|
||
|
const res = this.navigate(path, null);
|
||
|
this.emit("push", {
|
||
|
beforePath,
|
||
|
path,
|
||
|
route: res.route,
|
||
|
props: res.props,
|
||
|
key: this.currentKey,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public replace(path: string, key?: string | null, emitEvent = true) {
|
||
|
this.navigate(path, key);
|
||
|
if (emitEvent) {
|
||
|
this.emit("replace", {
|
||
|
path,
|
||
|
key: this.currentKey,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|