1const navEl = document.getElementById("nav");
2const contentEl = document.getElementById("content");
3const menuToggle = document.querySelector(".menu-toggle");
4const sidebar = document.querySelector(".sidebar");
5const overlay = document.querySelector(".overlay");
6
7function toggleMenu(open) {
8 const isOpen = open ?? !sidebar.classList.contains("open");
9 sidebar.classList.toggle("open", isOpen);
10 overlay?.classList.toggle("open", isOpen);
11 menuToggle?.setAttribute("aria-expanded", isOpen);
12 document.body.style.overflow = isOpen ? "hidden" : "";
13}
14
15menuToggle?.addEventListener("click", () => toggleMenu());
16overlay?.addEventListener("click", () => toggleMenu(false));
17
18// Close menu when nav link clicked (mobile)
19navEl?.addEventListener("click", (e) => {
20 if (e.target.closest("a")) toggleMenu(false);
21});
22
23const buildId = new URL(import.meta.url).searchParams.get("v") || "";
24
25function withBuild(url) {
26 if (!buildId) return url;
27 const sep = url.includes("?") ? "&" : "?";
28 return `${url}${sep}v=${encodeURIComponent(buildId)}`;
29}
30
31function escapeHtml(text) {
32 return text
33 .replaceAll("&", "&")
34 .replaceAll("<", "<")
35 .replaceAll(">", ">")
36 .replaceAll('"', """)
37 .replaceAll("'", "'");
38}
39
40function normalizeDocPath(docPath) {
41 let p = String(docPath || "").trim();
42 p = p.replaceAll("\\", "/");
43 p = p.replace(/^\/+/, "");
44 p = p.replace(/\.\.\//g, "");
45 if (!p.endsWith(".md")) p += ".md";
46 return p;
47}
48
49function getSelectedPath() {
50 const hash = (location.hash || "").replace(/^#/, "");
51 if (!hash) return null;
52 return normalizeDocPath(hash);
53}
54
55function setSelectedPath(docPath) {
56 location.hash = normalizeDocPath(docPath);
57}
58
59async function fetchJson(path) {
60 const res = await fetch(withBuild(path), { cache: "no-store" });
61 if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`);
62 return res.json();
63}
64
65async function fetchText(path) {
66 const res = await fetch(withBuild(path), { cache: "no-store" });
67 if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`);
68 return res.text();
69}
70
71function renderNav(pages, activePath) {
72 if (!pages.length) {
73 navEl.innerHTML = "";
74 return;
75 }
76
77 navEl.innerHTML = pages
78 .map((p) => {
79 const path = normalizeDocPath(p.path);
80 const title = escapeHtml(p.title || path);
81 const current = activePath === path ? ` aria-current="page"` : "";
82 return `<a href="#${encodeURIComponent(path)}"${current}>${title}</a>`;
83 })
84 .join("");
85}
86
87function installContentLinkHandler() {
88 contentEl.addEventListener("click", (e) => {
89 const a = e.target?.closest?.("a");
90 if (!a) return;
91
92 const href = a.getAttribute("href") || "";
93 if (
94 href.startsWith("http://") ||
95 href.startsWith("https://") ||
96 href.startsWith("mailto:") ||
97 href.startsWith("#")
98 ) {
99 return;
100 }
101
102 // Route relative markdown links through the SPA.
103 if (href.endsWith(".md")) {
104 e.preventDefault();
105 setSelectedPath(href);
106 return;
107 }
108 });
109}
110
111async function main() {
112 if (!globalThis.marked) {
113 contentEl.innerHTML = `<p class="empty">Markdown renderer failed to load.</p>`;
114 return;
115 }
116
117 installContentLinkHandler();
118
119 let manifest;
120 try {
121 manifest = await fetchJson("./manifest.json");
122 } catch (e) {
123 contentEl.innerHTML = `<p class="empty">Missing <code>manifest.json</code>. Deploy the site via CI.</p>`;
124 navEl.innerHTML = "";
125 console.error(e);
126 return;
127 }
128
129 const pages = Array.isArray(manifest.pages) ? manifest.pages : [];
130 const defaultPath = pages[0]?.path ? normalizeDocPath(pages[0].path) : null;
131
132 async function render() {
133 const activePath = getSelectedPath() || defaultPath;
134 renderNav(pages, activePath);
135
136 if (!activePath) {
137 contentEl.innerHTML = `<p class="empty">No docs yet. Add markdown files under <code>zat/docs/</code> and push to <code>main</code>.</p>`;
138 return;
139 }
140
141 try {
142 const md = await fetchText(`./docs/${activePath}`);
143 const html = globalThis.marked.parse(md);
144 contentEl.innerHTML = html;
145
146 // Update current marker after navigation re-render.
147 for (const a of navEl.querySelectorAll("a")) {
148 const href = decodeURIComponent((a.getAttribute("href") || "").slice(1));
149 a.toggleAttribute("aria-current", normalizeDocPath(href) === activePath);
150 }
151 } catch (e) {
152 contentEl.innerHTML = `<p class="empty">Failed to load <code>${escapeHtml(
153 activePath,
154 )}</code>.</p>`;
155 console.error(e);
156 }
157 }
158
159 window.addEventListener("hashchange", () => render());
160
161 if (!getSelectedPath() && defaultPath) setSelectedPath(defaultPath);
162 await render();
163}
164
165main();