/** * Hash router. * * Hash routing (rather than the History API) is what makes `dist/` a genuinely * static bundle: it can be served from a subdirectory, an S3 bucket or a GitHub * Pages path with no rewrite rules, and the OAuth redirect can land on the * document URL's query string without colliding with our own routes. */ export interface RouteMatch { name: string params: Record query: URLSearchParams /** The raw hash path, e.g. `/@alice@example.social/friends`. */ path: string } interface RoutePattern { name: string /** `/blog/:id` — `:param` captures one segment, `*rest` captures the remainder. */ pattern: string } const ROUTES: RoutePattern[] = [ { name: 'home', pattern: '/' }, { name: 'login', pattern: '/login' }, { name: 'settings', pattern: '/settings' }, { name: 'browse', pattern: '/browse' }, { name: 'search', pattern: '/search' }, { name: 'mail', pattern: '/mail' }, { name: 'mail.folder', pattern: '/mail/:folder' }, { name: 'timeline', pattern: '/timeline/:kind' }, { name: 'tag', pattern: '/tag/:tag' }, { name: 'blog.entry', pattern: '/blog/:id' }, { name: 'compose', pattern: '/compose' }, // Account routes come last: `:acct` is greedy enough to shadow the others. { name: 'profile.friends', pattern: '/@:acct/friends' }, { name: 'profile.blog', pattern: '/@:acct/blog' }, { name: 'profile.pics', pattern: '/@:acct/pics' }, { name: 'profile', pattern: '/@:acct' }, ] function matchPattern(pattern: string, path: string): Record | null { const patternParts = pattern.split('/').filter(Boolean) const pathParts = path.split('/').filter(Boolean) if (patternParts.length !== pathParts.length) return null const params: Record = {} for (let index = 0; index < patternParts.length; index += 1) { const expected = patternParts[index] const actual = pathParts[index] // `@:acct` — a literal prefix followed by a capture, as in `/@alice@host`. const prefixed = /^(@?)(:[a-zA-Z]+)$/.exec(expected) if (prefixed) { const [, prefix, name] = prefixed if (prefix && !actual.startsWith(prefix)) return null let value: string try { value = decodeURIComponent(actual.slice(prefix.length)) } catch { return null } if (!value) return null params[name.slice(1)] = value continue } if (expected !== actual) return null } return params } export function parseHash(hash: string): RouteMatch { const raw = hash.replace(/^#/, '') || '/' const [pathPart, queryPart = ''] = raw.split('?') const path = pathPart || '/' const query = new URLSearchParams(queryPart) for (const route of ROUTES) { const params = matchPattern(route.pattern, path) if (params) return { name: route.name, params, query, path } } return { name: 'notfound', params: {}, query, path } } class Router { current = $state(parseHash(typeof location === 'undefined' ? '#/' : location.hash)) constructor() { if (typeof window === 'undefined') return window.addEventListener('hashchange', () => { this.current = parseHash(location.hash) // Matches the old-web expectation that a new "page" starts at the top. window.scrollTo(0, 0) }) } /** Navigate, adding a history entry. */ go(to: string): void { const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}` if (location.hash === target) { this.current = parseHash(target) return } location.hash = target } /** Navigate without adding a history entry (search-as-you-type, tab switches). */ replace(to: string): void { const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}` history.replaceState({}, '', target) this.current = parseHash(target) } } export const router = new Router() /** Build a route string with an encoded query. */ export function routeTo(path: string, query?: Record): string { const params = new URLSearchParams() for (const [key, value] of Object.entries(query ?? {})) { if (value) params.set(key, value) } const serialized = params.toString() return `#${path}${serialized ? `?${serialized}` : ''}` }