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