/* eslint-disable svelte/no-navigation-without-resolve */
import { pushState, replaceState } from '$app/navigation';
import { SvelteMap } from 'svelte/reactivity';
import { tick } from 'svelte';
export const routes = [
{ path: '/', order: 0 },
{ path: '/following', order: 1 },
{ path: '/notifications', order: 2 },
{ path: '/settings/:tab', order: 3 },
{ path: '/profile/:actor', order: 4 }
] as const;
export type RouteConfig = (typeof routes)[number];
export type RoutePath = RouteConfig['path'];
type ExtractParams<Path extends string> =
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Path extends `${infer Start}/:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
Path extends `${infer Start}/:${infer Param}`
? { [K in Param]: string }
: Record<string, never>;
export type Route<K extends RoutePath = RoutePath> = {
[T in K]: {
params: ExtractParams<T>;
path: T;
order: number;
url: string;
};
}[K];
type RouteNode = {
children: Map<string, RouteNode>;
paramName?: string;
paramChild?: RouteNode;
config?: RouteConfig;
};
const fallbackRoute: Route<'/'> = {
params: {},
path: '/',
order: 0,
url: '/'
};
export class Router {
current = $state<Route>(fallbackRoute);
direction = $state<'left' | 'right' | 'none'>('none');
scrollPositions = new SvelteMap<string, number>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
private root: RouteNode = { children: new Map() };
constructor() {
for (const route of routes) this.addRoute(route);
}
private addRoute(config: RouteConfig) {
const segments = config.path.split('/').filter(Boolean);
let node = this.root;
for (const segment of segments) {
if (segment.startsWith(':')) {
const paramName = segment.slice(1);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
if (!node.paramChild) node.paramChild = { children: new Map(), paramName };
node = node.paramChild;
} else {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
if (!node.children.has(segment)) node.children.set(segment, { children: new Map() });
node = node.children.get(segment)!;
}
}
node.config = config;
}
init() {
if (typeof window === 'undefined') return;
// initialize state
this._updateState(window.location.pathname);
// update state on browser navigation
window.addEventListener('popstate', () => this._updateState(window.location.pathname));
// disable browser scroll restoration
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}
match(urlPath: string): Route | undefined {
const segments = urlPath.split('/').filter(Boolean);
const params: Record<string, string> = {};
let node = this.root;
for (const segment of segments) {
if (node.children.has(segment)) {
node = node.children.get(segment)!;
} else if (node.paramChild) {
node = node.paramChild;
if (node.paramName) params[node.paramName] = decodeURIComponent(segment);
} else {
return undefined;
}
}
if (node.config)
return {
params: params as unknown,
path: node.config.path,
order: node.config.order,
url: urlPath
} as Route<typeof node.config.path>;
return undefined;
}
updateDirection(newOrder: number, oldOrder: number) {
if (newOrder === oldOrder) this.direction = 'none';
else if (newOrder > oldOrder) this.direction = 'right';
else this.direction = 'left';
}
private async _updateState(url: string) {
const target = this.match(url);
if (!target) return;
// save scroll position
if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY);
this.updateDirection(target.order, this.current.order);
this.current = target;
if (typeof window !== 'undefined') {
await tick();
const savedScroll = this.scrollPositions.get(target.url) ?? 0;
window.scrollTo({ top: savedScroll, behavior: 'instant' });
}
}
navigate(url: string, { replace = false } = {}) {
if (typeof window === 'undefined') return;
if (this.current.url === url) return;
if (replace) replaceState(url, {});
else pushState(url, {});
this._updateState(url);
}
replace(url: string) {
this.navigate(url, { replace: true });
}
back() {
if (typeof window !== 'undefined') history.back();
}
}