← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
starter-fetch/src/client/router.ts
139 lines · 4.5 KB · TypeScript/**
* Navigation API SPA router with HTML prefetch + [data-app-main] swap.
* Config via data-app-base / data-pak-config.routes. Spinner during fetch is OK.
*/
import { apiUrl, readClientConfig } from './config.ts'
export function bootRouter() {
const config = readClientConfig()
const cache = new Map()
function relativeRoute(appBase, pathname) {
var path = pathname || '/'
if (path.length > 1 && path.charAt(path.length - 1) === '/') path = path.slice(0, -1)
if (path === appBase || path === appBase + '/') return '/'
if (appBase && path.indexOf(appBase + '/') === 0) {
var rel = path.slice(appBase.length)
return rel || '/'
}
if (path.charAt(0) === '/') return path
return null
}
function isAppRoute(pathname) {
var rel = relativeRoute(config.appBase, pathname)
return rel != null && config.routes.indexOf(rel) !== -1
}
function applyDocument(html, route) {
var doc = new DOMParser().parseFromString(html, 'text/html')
var nextMain = doc.querySelector('[data-app-main]')
var curMain = document.querySelector('[data-app-main]')
if (!nextMain || !curMain) return false
curMain.replaceWith(nextMain)
document.title = doc.title
var attrs = ['data-page', 'data-running-sha']
for (var i = 0; i < attrs.length; i++) {
var v = doc.documentElement.getAttribute(attrs[i])
if (v == null) document.documentElement.removeAttribute(attrs[i])
else document.documentElement.setAttribute(attrs[i], v)
}
var curNav = document.querySelectorAll('.pak-nav a')
for (var j = 0; j < curNav.length; j++) {
var href = curNav[j].getAttribute('href') || ''
var destRoute = relativeRoute(config.appBase, new URL(href, location.href).pathname)
if (destRoute === route) curNav[j].setAttribute('aria-current', 'page')
else curNav[j].removeAttribute('aria-current')
}
document.dispatchEvent(new CustomEvent('pak:pagechange', { detail: { route: route } }))
return true
}
async function swapDocument(html, route, fallbackUrl) {
var ok = false
function go() { ok = applyDocument(html, route) }
if (typeof document.startViewTransition === 'function') {
try {
await document.startViewTransition(go).finished.catch(function () {})
} catch (e) {
if (!ok) go()
}
} else {
go()
}
if (!ok) location.assign(fallbackUrl)
}
async function loadHtml(url, useCache) {
if (useCache && cache.has(url)) return cache.get(url)
try {
var res = await fetch(url, {
headers: { Accept: 'text/html' },
credentials: 'same-origin',
cache: 'no-cache',
})
if (!res.ok) return null
var html = await res.text()
cache.set(url, html)
return html
} catch (e) {
return null
}
}
function prefetchRoutes() {
for (var i = 0; i < config.routes.length; i++) {
var route = config.routes[i]
var path = route === '/' ? '/' : route
void loadHtml(apiUrl(config, path), false)
}
}
async function navigateTo(dest) {
var route = relativeRoute(config.appBase, dest.pathname)
if (!route) { location.assign(dest.href); return }
var html = await loadHtml(dest.href, true)
if (!html) { location.assign(dest.href); return }
await swapDocument(html, route, dest.href)
void loadHtml(dest.href, false)
}
const nav = window.navigation
if (nav && typeof nav.addEventListener === 'function') {
nav.addEventListener('navigate', function (event) {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return
if (event.formData) return
var dest
try { dest = new URL(event.destination.url) } catch (e) { return }
if (dest.origin !== location.origin) return
if (!isAppRoute(dest.pathname)) return
event.intercept({
handler: async function () { await navigateTo(dest) },
})
})
} else {
document.addEventListener('click', function (e) {
var t = e.target
var a = t && t.closest ? t.closest('a') : null
if (!a || a.target === '_blank' || a.hasAttribute('download')) return
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
var href = a.getAttribute('href')
if (!href || href.charAt(0) === '#') return
var dest
try { dest = new URL(href, location.href) } catch (err) { return }
if (dest.origin !== location.origin) return
if (!isAppRoute(dest.pathname)) return
e.preventDefault()
navigateTo(dest).then(function () { history.pushState({}, '', dest.href) })
})
window.addEventListener('popstate', function () {
void navigateTo(new URL(location.href))
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', prefetchRoutes, { once: true })
} else {
prefetchRoutes()
}
}