router.ts
4.0 kB · typescript · 105 lines
1import { createContext, startTransition, useCallback, useEffect, useRef, useState } from "react";2import { flushSync } from "react-dom";3import { bump } from "../lib/hooks.ts";4import { need } from "../kinds.js";5import { read, type View } from "./read.ts";6import site from "../../site.json";78type Go = (href: string, options?: { keep?: boolean }) => void;910export const Nav = createContext<Go>(() => {});1112const heading = (title: string) => (title === site.name ? site.name : `${title} · ${site.name}`);1314function target(event: MouseEvent | FocusEvent | PointerEvent): { link: HTMLAnchorElement; url: URL } | null {15 const node = event.target instanceof Element ? event.target.closest("a[href]") : null;16 if (!(node instanceof HTMLAnchorElement)) return null;17 if (node.target || node.hasAttribute("download") || node.hasAttribute("data-buy")) return null;18 const url = new URL(node.href, location.href);19 if (url.origin !== location.origin || !url.pathname.endsWith("/")) return null;20 return { link: node, url };21}2223const plain = (event: MouseEvent) => !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;2425export function useRouter(first: View): [View, Go] {26 const [view, setView] = useState(first);27 const store = useRef<Map<string, View> | null>(null);28 if (!store.current) store.current = new Map([[first.chrome.route, first]]);29 const seen = store.current;30 const here = useRef(first.chrome.route);31 here.current = view.chrome.route;3233 const grab = useCallback(34 async (path: string) => {35 const hit = seen.get(path);36 if (hit) return hit;37 const reply = await fetch(path, { headers: { accept: "text/html" } });38 if (!reply.ok) throw new Error(`page ${reply.status}`);39 const next = read(await reply.text());40 if (!next) throw new Error(`page ${path} carries no props`);41 await need(next.page.kind);42 seen.set(path, next);43 return next;44 },45 [seen],46 );4748 const paint = useCallback((next: View, keep: boolean) => {49 const swap = () => {50 setView(next);51 document.title = heading(next.page.title);52 if (!keep) scrollTo({ top: 0, behavior: "instant" });53 };54 if (typeof document.startViewTransition === "function") document.startViewTransition(() => flushSync(swap));55 else startTransition(swap);56 }, []);5758 const go = useCallback<Go>(59 (href, options = {}) => {60 const url = new URL(href, location.href);61 void grab(url.pathname)62 .then((next) => {63 history.pushState(null, "", url.pathname + url.search + url.hash);64 paint(next, Boolean(options.keep));65 bump();66 })67 .catch(() => location.assign(href));68 },69 [grab, paint],70 );7172 useEffect(() => {73 const click = (event: MouseEvent) => {74 if (!plain(event)) return;75 const hit = target(event);76 if (!hit) return;77 if (hit.url.hash && hit.url.pathname === location.pathname && hit.url.search === location.search) return;78 event.preventDefault();79 go(hit.url.pathname + hit.url.search + hit.url.hash, { keep: hit.link.hasAttribute("data-stay") });80 };81 const hint = (event: PointerEvent | FocusEvent) => {82 const hit = target(event);83 if (hit) void grab(hit.url.pathname).catch(() => {});84 };85 const pop = () => {86 const path = location.pathname;87 if (path === here.current) return;88 void grab(path)89 .then((next) => paint(next, true))90 .catch(() => location.reload());91 };92 document.addEventListener("click", click);93 document.addEventListener("pointerenter", hint as EventListener, true);94 document.addEventListener("focus", hint as EventListener, true);95 addEventListener("popstate", pop);96 return () => {97 document.removeEventListener("click", click);98 document.removeEventListener("pointerenter", hint as EventListener, true);99 document.removeEventListener("focus", hint as EventListener, true);100 removeEventListener("popstate", pop);101 };102 }, [go, grab, paint]);103104 return [view, go];105}