1/**
2 * A ~60-line history-API router — deliberately dependency-free so the
3 * example stays a *plain* React SPA. Deep links work because the site is
4 * deployed with `assets: { notFoundHandling: "single-page-application" }`.
5 */
6import {
7 createContext,
8 useContext,
9 useEffect,
10 useState,
11 type MouseEvent,
12 type ReactNode,
13} from "react";
14
15interface RouterState {
16 readonly path: string;
17 readonly navigate: (to: string) => void;
18}
19
20const RouterContext = createContext<RouterState>({
21 path: "/",
22 navigate: () => {},
23});
24
25export const Router = ({ children }: { children: ReactNode }) => {
26 const [path, setPath] = useState(location.pathname);
27 useEffect(() => {
28 const onPop = () => setPath(location.pathname);
29 window.addEventListener("popstate", onPop);
30 return () => window.removeEventListener("popstate", onPop);
31 }, []);
32 const navigate = (to: string) => {
33 history.pushState(null, "", to);
34 setPath(new URL(to, location.origin).pathname);
35 window.scrollTo(0, 0);
36 };
37 return (
38 <RouterContext.Provider value={{ path, navigate }}>
39 {children}
40 </RouterContext.Provider>
41 );
42};
43
44export const useRouter = () => useContext(RouterContext);
45
46export const Link = ({
47 to,
48 className,
49 children,
50 title,
51}: {
52 to: string;
53 className?: string;
54 children: ReactNode;
55 title?: string;
56}) => {
57 const { navigate } = useRouter();
58 const onClick = (event: MouseEvent) => {
59 if (event.metaKey || event.ctrlKey || event.shiftKey) return;
60 event.preventDefault();
61 navigate(to);
62 };
63 return (
64 <a href={to} onClick={onClick} className={className} title={title}>
65 {children}
66 </a>
67 );
68};
69
70/** Splits a pathname into decoded segments. */
71export const segments = (path: string): string[] =>
72 path.split("/").filter(Boolean).map(decodeURIComponent);
73
74/** Builds a pathname from raw segments, encoding each. */
75export const href = (...parts: string[]): string =>
76 "/" + parts.map(encodeURIComponent).join("/");