TheBird

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}