Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

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()
	}
}