context-router.ts
ts
import {
type Component,
type Computed,
type Context,
type State,
UNSET,
component,
computed,
dangerouslySetInnerHTML,
on,
provideContexts,
setText,
show,
state,
toggleClass,
} from '../../..'
import { fetchWithCache } from '../../functions/shared/fetch-with-cache'
import { isInternalLink } from '../../functions/shared/is-internal-link'
export type ContextRouterProps = {
'router-pathname': string
'router-query': Record<string, string>
}
/* === Exported Contexts === */
export const ROUTER_PATHNAME = 'router-pathname' as Context<
'router-pathname',
State<string>
>
export const ROUTER_QUERY = 'router-query' as Context<
'router-query',
Computed<Record<string, string>>
>
/* === Component === */
export default component(
'context-router',
{
[ROUTER_PATHNAME]: window.location.pathname,
[ROUTER_QUERY]: () => {
const queryMap = new Map()
for (const [key, value] of new URLSearchParams(
window.location.search,
)) {
queryMap.set(key, state(value))
}
const getSetParam = (key: string, value?: string): string => {
if (!queryMap.has(key)) queryMap.set(key, state(value ?? UNSET))
else if (value != null) queryMap.get(key).set(value)
return queryMap.get(key).get()
}
const syncToURL = () => {
const params = new URLSearchParams()
for (const [key, signal] of queryMap) {
const value = signal.get()
if (value && value !== UNSET) params.set(key, value)
}
window.history.replaceState(
null,
'',
`${window.location.pathname}?${params.toString()}${window.location.hash}`,
)
}
return () =>
new Proxy(
{},
{
has(_, prop: string) {
return queryMap.has(prop)
},
get(_, prop: string) {
return getSetParam(prop)
},
set(_, prop: string, value: string) {
getSetParam(prop, value)
syncToURL()
return true
},
ownKeys() {
return [...queryMap.keys()]
},
},
)
},
},
(el, { all, first }) => {
const outlet = el.getAttribute('outlet') ?? 'main'
const error = state('')
// Convert all relative links to absolute URLs during setup
for (const link of el.querySelectorAll('a[href]')) {
const href = link.getAttribute('href')
if (
href &&
!href.startsWith('#') &&
!href.includes('://') &&
!href.startsWith('/')
) {
try {
const absoluteUrl = new URL(href, window.location.href)
link.setAttribute('href', absoluteUrl.pathname)
} catch {
// Skip invalid URLs
}
}
}
const content = computed(async abort => {
const currentPath = String(el[ROUTER_PATHNAME])
const url = String(new URL(currentPath, window.location.origin))
if (abort?.aborted) return content.get()
try {
error.set('')
const { content: html } = await fetchWithCache(url, abort)
const doc = new DOMParser().parseFromString(html, 'text/html')
// Update title and URL
const newTitle = doc.querySelector('title')?.textContent
if (newTitle) document.title = newTitle
if (currentPath !== window.location.pathname)
window.history.pushState({}, '', url)
return doc.querySelector(outlet)?.innerHTML ?? ''
} catch (err) {
const errorMessage = `Navigation failed: ${err instanceof Error ? err.message : String(err)}`
error.set(errorMessage)
return content.get() // Keep current content on error
}
})
return [
// Provide contexts
provideContexts([ROUTER_PATHNAME, ROUTER_QUERY]),
// Navigate and update 'active' class
all(
'a[href]:not([href^="#"])',
toggleClass(
'active',
target =>
isInternalLink(target) &&
el[ROUTER_PATHNAME] === target.pathname,
),
on('click', e => {
if (!isInternalLink(e.target)) return
const url = new URL(e.target.href)
if (url.origin === window.location.origin) {
e.preventDefault()
el[ROUTER_PATHNAME] = url.pathname
}
}),
),
// Render content
first(
outlet,
dangerouslySetInnerHTML(content, { allowScripts: true }),
),
// Error display with aria-live
first(
'card-callout',
show(() => !!error.get()),
),
first('.error', setText(error)),
// Handle browser history navigation
() => {
const handlePopState = () => {
el[ROUTER_PATHNAME] = window.location.pathname
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
},
]
},
)
declare global {
interface HTMLElementTagNameMap {
'context-router': Component<{}>
}
}