mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
131 lines
4.2 KiB
TypeScript
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}` : ''}`
|
|
}
|