+5
.gitignore
+5
.gitignore
+27
.tangled/workflows/deploy-docs.yml
+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
+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
+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("&", "&")
8
+
.replaceAll("<", "<")
9
+
.replaceAll(">", ">")
10
+
.replaceAll('"', """)
11
+
.replaceAll("'", "'");
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
+4
site/favicon.svg
+42
site/index.html
+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
+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
+
}