import { createNavigationCoordinator, internalDocRoute } from "./navigation.js"; import { renderTreeNodes, subtreeContains } from "./sidebar-tree.js"; import { initSearch } from "./search.js"; const navEl = document.getElementById("nav"); const sidebarEl = document.getElementById("sidebar"); const contentEl = document.getElementById("content"); const themeToggle = document.querySelector(".theme-toggle"); // Theme toggle function getPreferredTheme() { const stored = localStorage.getItem("theme"); if (stored) return stored; return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; } function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); themeToggle?.setAttribute( "aria-label", theme === "dark" ? "Switch to light theme" : "Switch to dark theme", ); } applyTheme(getPreferredTheme()); themeToggle?.addEventListener("click", () => { const current = document.documentElement.getAttribute("data-theme") || getPreferredTheme(); applyTheme(current === "dark" ? "light" : "dark"); }); const buildId = new URL(import.meta.url).searchParams.get("v") || ""; function withBuild(url) { if (!buildId) return url; const sep = url.includes("?") ? "&" : "?"; return `${url}${sep}v=${encodeURIComponent(buildId)}`; } function escapeHtml(text) { return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // Routes are extensionless and file-layout-free: a note's route is its doc // path without ".md", a directory's route is the directory (its README is // the page), and home is "". ".md"-suffixed and trailing-slash inputs are // accepted for backwards compatibility but never emitted. function routeFor(input) { let p = String(input || "").trim(); p = p.replaceAll("\\", "/"); p = p.replace(/^\/+/, "").replace(/\/+$/, ""); p = p.replace(/\.\.\//g, ""); p = p.replace(/\.md$/i, ""); p = p.replace(/(^|\/)README$/i, "$1").replace(/\/+$/, ""); if (p === "index") p = ""; return p; } // candidate files for a route, tried in order: a note, then a directory index function docCandidates(route) { if (!route) return ["index.md"]; return [`${route}.md`, `${route}/README.md`]; } function getSelectedRoute() { const hash = (location.hash || "").replace(/^#/, ""); if (!hash) return null; // Decode defensively so literal and percent-encoded hashes resolve alike. let decoded = hash; try { decoded = decodeURIComponent(hash); } catch { // malformed escape — fall back to the raw hash } return routeFor(decoded); } async function fetchJson(path) { const res = await fetch(withBuild(path), { cache: "no-store" }); if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`); return res.json(); } async function fetchText(path, signal) { const res = await fetch(withBuild(path), { cache: "no-store", signal }); if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`); return res.text(); } function renderNav(pages, activePath) { if (!pages.length) { navEl.innerHTML = ""; return; } navEl.innerHTML = pages .filter((p) => routeFor(p.path) !== "") .map((p) => { const route = routeFor(p.path); const title = escapeHtml(p.title || route); const current = activePath === route ? ` aria-current="page"` : ""; return `${title}`; }) .join(""); } function slugify(text) { return String(text) .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-"); } function pageHref(docPath) { return "#" + routeFor(docPath).split("/").map(encodeURIComponent).join("/"); } function sidebarLabel(page) { return routeFor(page.path) === "" ? "Overview" : page.title || page.path; } // The left tree is progressively disclosed: every parent is a
, and // only the active page's ancestor chain opens automatically. Directories link // to their README when they have one; notes are leaves. Page headings live in // the right-hand #toc column, never in this tree. function renderLeaf(node, activePath, cls) { const p = routeFor(node.path); const active = p === activePath; return `${escapeHtml(node.title || p)}`; } function renderSidebar(pages, activePath) { const items = pages .map((p) => { const route = routeFor(p.path); const active = route === activePath; const kids = Array.isArray(p.children) ? p.children : []; const link = `${escapeHtml(sidebarLabel(p))}`; if (!kids.length) return `
  • ${link}
  • `; const open = active || subtreeContains(p, activePath, routeFor); return ( `
  • ` + `${link}` + `
      ${renderTreeNodes(kids, activePath, { routeFor, renderLeaf, escapeHtml, })}
    ` + `
  • ` ); }) .join(""); sidebarEl.innerHTML = ``; } // Fill the right-hand "on this page" column from the rendered doc's h2/h3. // Hidden when a page has fewer than 2 headings — nothing to orient by. const tocEl = document.getElementById("toc"); function renderHeadingToc() { if (!tocEl) return; const heads = [...contentEl.querySelectorAll("h2, h3")]; if (heads.length < 2) { tocEl.innerHTML = ""; return; } tocEl.innerHTML = `
    on this page
    `; } // a link inside a should navigate, not toggle the section — // route it by hand and suppress the summary's default toggle. sidebarEl.addEventListener("click", (e) => { const summaryLink = e.target?.closest?.("summary a[href]"); if (!summaryLink) return; e.preventDefault(); location.hash = summaryLink.getAttribute("href").replace(/^#/, ""); }); // Heading anchors scroll in-page without touching location.hash (the hash is // the SPA's doc route, so a real #id anchor would break navigation). tocEl?.addEventListener("click", (e) => { const a = e.target?.closest?.("a[data-target]"); if (!a) return; e.preventDefault(); const target = document.getElementById(a.getAttribute("data-target")); if (!target) return; // A heading can sit inside a closed
    ; scrollIntoView on a hidden // element is a no-op, so open the ancestors first. for (let d = target.closest("details"); d; d = d.parentElement?.closest("details")) { d.open = true; } const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; target.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start", }); }); function titleForPath(nodes, path) { for (const n of nodes) { if (n.path && routeFor(n.path) === path) return n.title || path; const found = titleForPath(n.children || [], path); if (found !== path) return found; } return path; } // Skip link: move focus to the content region without polluting the route hash. document.querySelector(".skip-link")?.addEventListener("click", (e) => { e.preventDefault(); contentEl.focus(); }); // Rewrite document-shaped links to the SPA route they actually target. This // keeps browser link previews truthful and lets normal anchor behavior handle // navigation instead of repairing relative paths after a click. function routeContentLinks(fromDocPath) { for (const anchor of contentEl.querySelectorAll("a[href]")) { const route = internalDocRoute(anchor.getAttribute("href") || "", fromDocPath, routeFor); if (route !== null) anchor.setAttribute("href", pageHref(route)); } } async function main() { // SPA fallback: convert pathname to hash route so deep links work if (location.pathname !== "/" && !location.hash) { const path = location.pathname.replace(/^\/+/, ""); if (path) { location.replace("#" + routeFor(path).split("/").map(encodeURIComponent).join("/")); return; } } if (!globalThis.marked) { contentEl.innerHTML = `

    Markdown renderer failed to load.

    `; return; } let manifest; try { manifest = await fetchJson("./manifest.json"); } catch (e) { contentEl.innerHTML = `

    Missing manifest.json. Deploy the site via CI.

    `; navEl.innerHTML = ""; console.error(e); return; } const pages = Array.isArray(manifest.pages) ? manifest.pages : []; const navigations = createNavigationCoordinator(); initSearch({ pages, routeFor, pageHref, escapeHtml }); async function render(isNav) { const navigation = navigations.begin(); const activeRoute = getSelectedRoute() ?? ""; renderNav(pages, activeRoute); renderSidebar(pages, activeRoute); // a route can be a note (route.md) or a directory (route/README.md) — // try in order; the legacy "#foo.md" form was normalized by routeFor. let md = null; let docPath = null; for (const candidate of docCandidates(activeRoute)) { try { md = await fetchText(`./docs/${candidate}`, navigation.signal); if (!navigation.isCurrent()) return; docPath = candidate; break; } catch { if (!navigation.isCurrent()) return; // try the next candidate } } if (!navigation.isCurrent()) return; if (md === null) { contentEl.innerHTML = `

    Nothing lives at ${escapeHtml( activeRoute, )}.

    `; return; } contentEl.innerHTML = globalThis.marked.parse(md); routeContentLinks(docPath); globalThis.Prism?.highlightAllUnder(contentEl); renderHeadingToc(); window.scrollTo(0, 0); // On navigation, announce the new page and move focus into it so screen // reader and keyboard users aren't stranded on stale/orphaned focus. const liveEl = document.getElementById("a11y-live"); if (liveEl) liveEl.textContent = titleForPath(pages, activeRoute) || "notes"; if (isNav) contentEl.focus(); } window.addEventListener("hashchange", () => render(true)); await render(false); } main();