/* 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 = // 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; export type Route = { [T in K]: { params: ExtractParams; path: T; order: number; url: string; }; }[K]; type RouteNode = { children: Map; paramName?: string; paramChild?: RouteNode; config?: RouteConfig; }; const fallbackRoute: Route<'/'> = { params: {}, path: '/', order: 0, url: '/' }; export class Router { current = $state(fallbackRoute); direction = $state<'left' | 'right' | 'none'>('none'); scrollPositions = new SvelteMap(); // 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 = {}; 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; 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(); } }