summaryrefslogtreecommitdiff
path: root/packages/frontend/src/lib/nirax.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/lib/nirax.ts')
-rw-r--r--packages/frontend/src/lib/nirax.ts348
1 files changed, 348 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts
new file mode 100644
index 0000000000..a97803e879
--- /dev/null
+++ b/packages/frontend/src/lib/nirax.ts
@@ -0,0 +1,348 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// NIRAX --- A lightweight router
+
+import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
+import { EventEmitter } from 'eventemitter3';
+import type { Component, ShallowRef } from 'vue';
+
+function safeURIDecode(str: string): string {
+ try {
+ return decodeURIComponent(str);
+ } catch {
+ return str;
+ }
+}
+
+interface RouteDefBase {
+ path: string;
+ query?: Record<string, string>;
+ loginRequired?: boolean;
+ name?: string;
+ hash?: string;
+ children?: RouteDef[];
+}
+
+interface RouteDefWithComponent extends RouteDefBase {
+ component: Component,
+}
+
+interface RouteDefWithRedirect extends RouteDefBase {
+ redirect: string | ((props: Map<string, string | boolean>) => string);
+}
+
+export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect;
+
+export type RouterFlag = 'forcePage';
+
+type ParsedPath = (string | {
+ name: string;
+ startsWith?: string;
+ wildcard?: boolean;
+ optional?: boolean;
+})[];
+
+export type RouterEvents = {
+ change: (ctx: {
+ beforeFullPath: string;
+ fullPath: string;
+ resolved: PathResolvedResult;
+ }) => void;
+ replace: (ctx: {
+ fullPath: string;
+ }) => void;
+ push: (ctx: {
+ beforeFullPath: string;
+ fullPath: string;
+ route: RouteDef | null;
+ props: Map<string, string> | null;
+ }) => void;
+ same: () => void;
+};
+
+export type PathResolvedResult = {
+ route: RouteDef;
+ props: Map<string, string | boolean>;
+ child?: PathResolvedResult;
+ redirected?: boolean;
+
+ /** @internal */
+ _parsedRoute: {
+ fullPath: string;
+ queryString: string | null;
+ hash: string | null;
+ };
+};
+
+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 Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
+ private routes: DEF;
+ public current: PathResolvedResult;
+ public currentRef: ShallowRef<PathResolvedResult>;
+ public currentRoute: ShallowRef<RouteDef>;
+ private currentFullPath: string; // /foo/bar?baz=qux#hash
+ private isLoggedIn: boolean;
+ private notFoundPageComponent: Component;
+ private redirectCount = 0;
+
+ public navHook: ((fullPath: string, flag?: RouterFlag) => boolean) | null = null;
+
+ constructor(routes: DEF, currentFullPath: Nirax<DEF>['currentFullPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
+ super();
+
+ this.routes = routes;
+ this.current = this.resolve(currentFullPath)!;
+ this.currentRef = shallowRef(this.current);
+ this.currentRoute = shallowRef(this.current.route);
+ this.currentFullPath = currentFullPath;
+ this.isLoggedIn = isLoggedIn;
+ this.notFoundPageComponent = notFoundPageComponent;
+ }
+
+ public init() {
+ const res = this.navigate(this.currentFullPath, false);
+ this.emit('replace', {
+ fullPath: res._parsedRoute.fullPath,
+ });
+ }
+
+ public resolve(fullPath: string): PathResolvedResult | null {
+ let path = fullPath;
+ 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('?'));
+ }
+
+ const _parsedRoute = {
+ fullPath,
+ queryString,
+ hash,
+ };
+
+ function check(routes: RouteDef[], _parts: string[]): PathResolvedResult | 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,
+ _parsedRoute,
+ };
+ } 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,
+ _parsedRoute,
+ };
+ } else {
+ if (route.children) {
+ const child = check(route.children, parts);
+ if (child) {
+ return {
+ route,
+ props,
+ child,
+ _parsedRoute,
+ };
+ } else {
+ continue forEachRouteLoop;
+ }
+ } else {
+ continue forEachRouteLoop;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ const _parts = path.split('/').filter(part => part.length !== 0);
+
+ return check(this.routes, _parts);
+ }
+
+ private navigate(fullPath: string, emitChange = true, _redirected = false): PathResolvedResult {
+ const beforeFullPath = this.currentFullPath;
+ this.currentFullPath = fullPath;
+
+ const res = this.resolve(this.currentFullPath);
+
+ if (res == null) {
+ throw new Error('no route found for: ' + fullPath);
+ }
+
+ if ('redirect' in res.route) {
+ let redirectPath: string;
+ if (typeof res.route.redirect === 'function') {
+ redirectPath = res.route.redirect(res.props);
+ } else {
+ redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : '');
+ }
+ if (_DEV_) console.log('Redirecting to: ', redirectPath);
+ if (_redirected && this.redirectCount++ > 10) {
+ throw new Error('redirect loop detected');
+ }
+ return this.navigate(redirectPath, emitChange, true);
+ }
+
+ if (res.route.loginRequired && !this.isLoggedIn) {
+ res.route.component = this.notFoundPageComponent;
+ res.props.set('showLoginPopup', true);
+ }
+
+ this.current = res;
+ this.currentRef.value = res;
+ this.currentRoute.value = res.route;
+
+ if (emitChange && res.route.path !== '/:(*)') {
+ this.emit('change', {
+ beforeFullPath,
+ fullPath,
+ resolved: res,
+ });
+ }
+
+ this.redirectCount = 0;
+ return {
+ ...res,
+ redirected: _redirected,
+ };
+ }
+
+ public getCurrentFullPath() {
+ return this.currentFullPath;
+ }
+
+ public push(fullPath: string, flag?: RouterFlag) {
+ const beforeFullPath = this.currentFullPath;
+ if (fullPath === beforeFullPath) {
+ this.emit('same');
+ return;
+ }
+ if (this.navHook) {
+ const cancel = this.navHook(fullPath, flag);
+ if (cancel) return;
+ }
+ const res = this.navigate(fullPath);
+ if (res.route.path === '/:(*)') {
+ window.location.href = fullPath;
+ } else {
+ this.emit('push', {
+ beforeFullPath,
+ fullPath: res._parsedRoute.fullPath,
+ route: res.route,
+ props: res.props,
+ });
+ }
+ }
+
+ public replace(fullPath: string) {
+ const res = this.navigate(fullPath);
+ this.emit('replace', {
+ fullPath: res._parsedRoute.fullPath,
+ });
+ }
+
+ public useListener<E extends keyof RouterEvents, L = RouterEvents[E]>(event: E, listener: L) {
+ this.addListener(event, listener);
+
+ onBeforeUnmount(() => {
+ this.removeListener(event, listener);
+ });
+ }
+}