atproto utils for zig zat.dev
atproto sdk zig

docs: deploy to wisp

Changed files
+537
.tangled
workflows
scripts
site
+5
.gitignore
··· 1 .zig-cache/ 2 zig-out/
··· 1 .zig-cache/ 2 zig-out/ 3 + .env 4 + .env.* 5 + 6 + # Wisp docs build output (generated) 7 + site-out/
+27
.tangled/workflows/deploy-docs.yml
···
··· 1 + when: 2 + - event: ["push"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - nodejs 10 + - coreutils 11 + - curl 12 + 13 + environment: 14 + WISP_HANDLE: "zat.dev" 15 + WISP_SITE_NAME: "docs" 16 + 17 + steps: 18 + - name: build docs site 19 + command: | 20 + node ./scripts/build-wisp-docs.mjs 21 + 22 + - name: deploy docs to wisp 23 + command: | 24 + test -n "$WISP_APP_PASSWORD" 25 + curl -sSL https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 26 + chmod +x wisp-cli 27 + ./wisp-cli deploy "$WISP_HANDLE" --path ./site-out --site "$WISP_SITE_NAME" --password "$WISP_APP_PASSWORD"
+113
scripts/build-wisp-docs.mjs
···
··· 1 + import { 2 + readdir, 3 + readFile, 4 + mkdir, 5 + rm, 6 + cp, 7 + writeFile, 8 + access, 9 + } from "node:fs/promises"; 10 + import path from "node:path"; 11 + 12 + const repoRoot = path.resolve(new URL("..", import.meta.url).pathname); 13 + const docsDir = path.join(repoRoot, "docs"); 14 + const siteSrcDir = path.join(repoRoot, "site"); 15 + const outDir = path.join(repoRoot, "site-out"); 16 + const outDocsDir = path.join(outDir, "docs"); 17 + 18 + async function exists(filePath) { 19 + try { 20 + await access(filePath); 21 + return true; 22 + } catch { 23 + return false; 24 + } 25 + } 26 + 27 + function isMarkdown(filePath) { 28 + return filePath.toLowerCase().endsWith(".md"); 29 + } 30 + 31 + async function listMarkdownFiles(dir, prefix = "") { 32 + const entries = await readdir(dir, { withFileTypes: true }); 33 + const out = []; 34 + for (const e of entries) { 35 + if (e.name.startsWith(".")) continue; 36 + const rel = path.join(prefix, e.name); 37 + const abs = path.join(dir, e.name); 38 + if (e.isDirectory()) { 39 + out.push(...(await listMarkdownFiles(abs, rel))); 40 + } else if (e.isFile() && isMarkdown(e.name)) { 41 + out.push(rel.replaceAll(path.sep, "/")); 42 + } 43 + } 44 + return out.sort((a, b) => a.localeCompare(b)); 45 + } 46 + 47 + function titleFromMarkdown(md, fallback) { 48 + const lines = md.split(/\r?\n/); 49 + for (const line of lines) { 50 + const m = /^#\s+(.+)\s*$/.exec(line); 51 + if (m) return m[1].trim(); 52 + } 53 + return fallback.replace(/\.md$/i, ""); 54 + } 55 + 56 + async function main() { 57 + await rm(outDir, { recursive: true, force: true }); 58 + await mkdir(outDir, { recursive: true }); 59 + 60 + // Copy static site shell 61 + await cp(siteSrcDir, outDir, { recursive: true }); 62 + 63 + // Copy docs 64 + await mkdir(outDocsDir, { recursive: true }); 65 + 66 + const pages = []; 67 + 68 + // Prefer an explicit docs homepage if present; otherwise use repo README as index. 69 + const docsIndex = path.join(docsDir, "index.md"); 70 + if (!(await exists(docsIndex))) { 71 + const readme = path.join(repoRoot, "README.md"); 72 + if (await exists(readme)) { 73 + const md = await readFile(readme, "utf8"); 74 + await writeFile(path.join(outDocsDir, "index.md"), md, "utf8"); 75 + pages.push({ path: "index.md", title: titleFromMarkdown(md, "index.md") }); 76 + } 77 + } 78 + 79 + const changelog = path.join(repoRoot, "CHANGELOG.md"); 80 + const docsChangelog = path.join(docsDir, "changelog.md"); 81 + if ((await exists(changelog)) && !(await exists(docsChangelog))) { 82 + const md = await readFile(changelog, "utf8"); 83 + await writeFile(path.join(outDocsDir, "changelog.md"), md, "utf8"); 84 + pages.push({ 85 + path: "changelog.md", 86 + title: titleFromMarkdown(md, "changelog.md"), 87 + }); 88 + } 89 + 90 + const mdFiles = (await exists(docsDir)) ? await listMarkdownFiles(docsDir) : []; 91 + 92 + for (const rel of mdFiles) { 93 + const src = path.join(docsDir, rel); 94 + const dst = path.join(outDocsDir, rel); 95 + await mkdir(path.dirname(dst), { recursive: true }); 96 + await cp(src, dst); 97 + 98 + const md = await readFile(src, "utf8"); 99 + pages.push({ path: rel, title: titleFromMarkdown(md, rel) }); 100 + } 101 + 102 + await writeFile( 103 + path.join(outDir, "manifest.json"), 104 + JSON.stringify({ pages }, null, 2) + "\n", 105 + "utf8", 106 + ); 107 + 108 + process.stdout.write( 109 + `Built Wisp docs site: ${pages.length} markdown file(s) -> ${outDir}\n`, 110 + ); 111 + } 112 + 113 + await main();
+145
site/app.js
···
··· 1 + const navEl = document.getElementById("nav"); 2 + const contentEl = document.getElementById("content"); 3 + const searchEl = document.getElementById("search"); 4 + 5 + function escapeHtml(text) { 6 + return text 7 + .replaceAll("&", "&amp;") 8 + .replaceAll("<", "&lt;") 9 + .replaceAll(">", "&gt;") 10 + .replaceAll('"', "&quot;") 11 + .replaceAll("'", "&#039;"); 12 + } 13 + 14 + function normalizeDocPath(docPath) { 15 + let p = String(docPath || "").trim(); 16 + p = p.replaceAll("\\", "/"); 17 + p = p.replace(/^\/+/, ""); 18 + p = p.replace(/\.\.\//g, ""); 19 + if (!p.endsWith(".md")) p += ".md"; 20 + return p; 21 + } 22 + 23 + function getSelectedPath() { 24 + const hash = (location.hash || "").replace(/^#/, ""); 25 + if (!hash) return null; 26 + return normalizeDocPath(hash); 27 + } 28 + 29 + function setSelectedPath(docPath) { 30 + location.hash = normalizeDocPath(docPath); 31 + } 32 + 33 + async function fetchJson(path) { 34 + const res = await fetch(path, { cache: "no-cache" }); 35 + if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`); 36 + return res.json(); 37 + } 38 + 39 + async function fetchText(path) { 40 + const res = await fetch(path, { cache: "no-cache" }); 41 + if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`); 42 + return res.text(); 43 + } 44 + 45 + function renderNav(pages, activePath, filter) { 46 + const q = (filter || "").trim().toLowerCase(); 47 + const filtered = q 48 + ? pages.filter((p) => (p.title || p.path).toLowerCase().includes(q)) 49 + : pages; 50 + 51 + if (!filtered.length) { 52 + navEl.innerHTML = `<div class="empty">No matches.</div>`; 53 + return; 54 + } 55 + 56 + navEl.innerHTML = filtered 57 + .map((p) => { 58 + const path = normalizeDocPath(p.path); 59 + const title = escapeHtml(p.title || path); 60 + const current = activePath === path ? ` aria-current="page"` : ""; 61 + return `<a href="#${encodeURIComponent(path)}"${current}>${title}</a>`; 62 + }) 63 + .join(""); 64 + } 65 + 66 + function installContentLinkHandler() { 67 + contentEl.addEventListener("click", (e) => { 68 + const a = e.target?.closest?.("a"); 69 + if (!a) return; 70 + 71 + const href = a.getAttribute("href") || ""; 72 + if ( 73 + href.startsWith("http://") || 74 + href.startsWith("https://") || 75 + href.startsWith("mailto:") || 76 + href.startsWith("#") 77 + ) { 78 + return; 79 + } 80 + 81 + // Route relative markdown links through the SPA. 82 + if (href.endsWith(".md")) { 83 + e.preventDefault(); 84 + setSelectedPath(href); 85 + return; 86 + } 87 + }); 88 + } 89 + 90 + async function main() { 91 + if (!globalThis.marked) { 92 + contentEl.innerHTML = `<p class="empty">Markdown renderer failed to load.</p>`; 93 + return; 94 + } 95 + 96 + installContentLinkHandler(); 97 + 98 + let manifest; 99 + try { 100 + manifest = await fetchJson("./manifest.json"); 101 + } catch (e) { 102 + contentEl.innerHTML = `<p class="empty">Missing <code>manifest.json</code>. Deploy the site via CI.</p>`; 103 + navEl.innerHTML = ""; 104 + console.error(e); 105 + return; 106 + } 107 + 108 + const pages = Array.isArray(manifest.pages) ? manifest.pages : []; 109 + const defaultPath = pages[0]?.path ? normalizeDocPath(pages[0].path) : null; 110 + 111 + async function render() { 112 + const activePath = getSelectedPath() || defaultPath; 113 + renderNav(pages, activePath, searchEl.value); 114 + 115 + if (!activePath) { 116 + contentEl.innerHTML = `<p class="empty">No docs yet. Add markdown files under <code>zat/docs/</code> and push to <code>main</code>.</p>`; 117 + return; 118 + } 119 + 120 + try { 121 + const md = await fetchText(`./docs/${encodeURIComponent(activePath)}`); 122 + const html = globalThis.marked.parse(md); 123 + contentEl.innerHTML = html; 124 + 125 + // Update current marker after navigation re-render. 126 + for (const a of navEl.querySelectorAll("a")) { 127 + const href = decodeURIComponent((a.getAttribute("href") || "").slice(1)); 128 + a.toggleAttribute("aria-current", normalizeDocPath(href) === activePath); 129 + } 130 + } catch (e) { 131 + contentEl.innerHTML = `<p class="empty">Failed to load <code>${escapeHtml( 132 + activePath, 133 + )}</code>.</p>`; 134 + console.error(e); 135 + } 136 + } 137 + 138 + searchEl.addEventListener("input", () => render()); 139 + window.addEventListener("hashchange", () => render()); 140 + 141 + if (!getSelectedPath() && defaultPath) setSelectedPath(defaultPath); 142 + await render(); 143 + } 144 + 145 + main();
+4
site/favicon.svg
···
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> 2 + <rect width="128" height="128" rx="28" fill="#0b0b0f"/> 3 + <path d="M36 40h56v10H54l36 28v10H36V78h38L36 50z" fill="#93c5fd"/> 4 + </svg>
+42
site/index.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>zat.dev</title> 7 + <meta name="description" content="zat documentation" /> 8 + <link rel="icon" href="./favicon.svg" type="image/svg+xml" /> 9 + <link rel="stylesheet" href="./style.css" /> 10 + </head> 11 + <body> 12 + <div class="app"> 13 + <header class="header"> 14 + <a class="brand" href="./">zat.dev</a> 15 + <input 16 + id="search" 17 + class="search" 18 + type="search" 19 + placeholder="Search…" 20 + autocomplete="off" 21 + spellcheck="false" 22 + /> 23 + </header> 24 + 25 + <div class="layout"> 26 + <nav class="sidebar"> 27 + <div id="nav" class="nav"></div> 28 + </nav> 29 + 30 + <main class="main"> 31 + <article id="content" class="content"> 32 + <noscript>This docs site requires JavaScript.</noscript> 33 + </article> 34 + </main> 35 + </div> 36 + </div> 37 + 38 + <!-- Markdown renderer (no build step). --> 39 + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> 40 + <script type="module" src="./app.js"></script> 41 + </body> 42 + </html>
+201
site/style.css
···
··· 1 + :root { 2 + color-scheme: light dark; 3 + --bg: #0b0b0f; 4 + --panel: #10101a; 5 + --text: #f3f4f6; 6 + --muted: #a1a1aa; 7 + --border: rgba(255, 255, 255, 0.08); 8 + --link: #93c5fd; 9 + --codebg: rgba(255, 255, 255, 0.06); 10 + --shadow: rgba(0, 0, 0, 0.35); 11 + --max: 900px; 12 + --radius: 12px; 13 + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 14 + "Courier New", monospace; 15 + --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, 16 + Arial, "Apple Color Emoji", "Segoe UI Emoji"; 17 + } 18 + 19 + @media (prefers-color-scheme: light) { 20 + :root { 21 + --bg: #fafafa; 22 + --panel: #ffffff; 23 + --text: #0b0b0f; 24 + --muted: #52525b; 25 + --border: rgba(0, 0, 0, 0.08); 26 + --link: #2563eb; 27 + --codebg: rgba(0, 0, 0, 0.04); 28 + --shadow: rgba(0, 0, 0, 0.08); 29 + } 30 + } 31 + 32 + html, 33 + body { 34 + height: 100%; 35 + } 36 + 37 + body { 38 + margin: 0; 39 + font-family: var(--sans); 40 + background: var(--bg); 41 + color: var(--text); 42 + } 43 + 44 + a { 45 + color: var(--link); 46 + text-decoration: none; 47 + } 48 + a:hover { 49 + text-decoration: underline; 50 + } 51 + 52 + .app { 53 + min-height: 100%; 54 + } 55 + 56 + .header { 57 + position: sticky; 58 + top: 0; 59 + z-index: 5; 60 + display: flex; 61 + gap: 12px; 62 + align-items: center; 63 + padding: 12px 16px; 64 + border-bottom: 1px solid var(--border); 65 + background: color-mix(in srgb, var(--panel) 92%, transparent); 66 + backdrop-filter: blur(10px); 67 + } 68 + 69 + .brand { 70 + font-weight: 700; 71 + letter-spacing: 0.2px; 72 + padding: 6px 10px; 73 + border-radius: 10px; 74 + } 75 + .brand:hover { 76 + background: color-mix(in srgb, var(--codebg) 70%, transparent); 77 + text-decoration: none; 78 + } 79 + 80 + .search { 81 + margin-left: auto; 82 + width: min(520px, 60vw); 83 + padding: 10px 12px; 84 + border-radius: 10px; 85 + border: 1px solid var(--border); 86 + background: var(--panel); 87 + color: var(--text); 88 + outline: none; 89 + } 90 + .search:focus { 91 + border-color: color-mix(in srgb, var(--link) 50%, var(--border)); 92 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 22%, transparent); 93 + } 94 + 95 + .layout { 96 + display: grid; 97 + grid-template-columns: 280px 1fr; 98 + gap: 16px; 99 + padding: 16px; 100 + } 101 + 102 + @media (max-width: 980px) { 103 + .layout { 104 + grid-template-columns: 1fr; 105 + } 106 + .sidebar { 107 + position: relative; 108 + top: auto; 109 + max-height: none; 110 + } 111 + } 112 + 113 + .sidebar { 114 + position: sticky; 115 + top: 64px; 116 + align-self: start; 117 + max-height: calc(100vh - 84px); 118 + overflow: auto; 119 + border: 1px solid var(--border); 120 + border-radius: var(--radius); 121 + background: var(--panel); 122 + box-shadow: 0 12px 40px var(--shadow); 123 + } 124 + 125 + .nav { 126 + padding: 8px; 127 + display: flex; 128 + flex-direction: column; 129 + gap: 2px; 130 + } 131 + 132 + .nav a { 133 + display: block; 134 + padding: 8px 10px; 135 + border-radius: 10px; 136 + color: var(--text); 137 + opacity: 0.9; 138 + } 139 + .nav a:hover { 140 + background: color-mix(in srgb, var(--codebg) 70%, transparent); 141 + text-decoration: none; 142 + } 143 + .nav a[aria-current="page"] { 144 + background: color-mix(in srgb, var(--link) 14%, var(--codebg)); 145 + border: 1px solid color-mix(in srgb, var(--link) 20%, var(--border)); 146 + } 147 + 148 + .main { 149 + display: flex; 150 + justify-content: center; 151 + } 152 + 153 + .content { 154 + width: min(var(--max), 100%); 155 + border: 1px solid var(--border); 156 + border-radius: var(--radius); 157 + background: var(--panel); 158 + box-shadow: 0 12px 40px var(--shadow); 159 + padding: 24px; 160 + } 161 + 162 + .content h1, 163 + .content h2, 164 + .content h3 { 165 + scroll-margin-top: 84px; 166 + } 167 + 168 + .content h1 { 169 + margin-top: 0; 170 + font-size: 34px; 171 + } 172 + 173 + .content p, 174 + .content li { 175 + line-height: 1.6; 176 + } 177 + 178 + .content code { 179 + font-family: var(--mono); 180 + font-size: 0.95em; 181 + background: var(--codebg); 182 + padding: 2px 6px; 183 + border-radius: 8px; 184 + } 185 + 186 + .content pre { 187 + overflow: auto; 188 + padding: 14px 16px; 189 + border-radius: 12px; 190 + background: var(--codebg); 191 + border: 1px solid var(--border); 192 + } 193 + 194 + .content pre code { 195 + background: transparent; 196 + padding: 0; 197 + } 198 + 199 + .empty { 200 + color: var(--muted); 201 + }