Files
plspace/src/lib/router.svelte.ts
T

131 lines
4.2 KiB
TypeScript

/**
* 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<string, string>
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<string, string> | null {
const patternParts = pattern.split('/').filter(Boolean)
const pathParts = path.split('/').filter(Boolean)
if (patternParts.length !== pathParts.length) return null
const params: Record<string, string> = {}
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<RouteMatch>(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, string | undefined>): 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}` : ''}`
}