mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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
|
||||
const value = decodeURIComponent(actual.slice(prefix.length))
|
||||
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}` : ''}`
|
||||
}
|
||||
Reference in New Issue
Block a user