+2
-2
.tangled/workflows/deploy-docs.yml
+2
-2
.tangled/workflows/deploy-docs.yml
+4
CHANGELOG.md
+4
CHANGELOG.md
+215
scripts/build-site.mjs
+215
scripts/build-site.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
+
import { execFile } from "node:child_process";
12
+
import { promisify } from "node:util";
13
+
14
+
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
15
+
const docsDir = path.join(repoRoot, "docs");
16
+
const devlogDir = path.join(repoRoot, "devlog");
17
+
const siteSrcDir = path.join(repoRoot, "site");
18
+
const outDir = path.join(repoRoot, "site-out");
19
+
const outDocsDir = path.join(outDir, "docs");
20
+
21
+
const execFileAsync = promisify(execFile);
22
+
23
+
async function exists(filePath) {
24
+
try {
25
+
await access(filePath);
26
+
return true;
27
+
} catch {
28
+
return false;
29
+
}
30
+
}
31
+
32
+
function isMarkdown(filePath) {
33
+
return filePath.toLowerCase().endsWith(".md");
34
+
}
35
+
36
+
async function listMarkdownFiles(dir, prefix = "") {
37
+
const entries = await readdir(dir, { withFileTypes: true });
38
+
const out = [];
39
+
for (const e of entries) {
40
+
if (e.name.startsWith(".")) continue;
41
+
const rel = path.join(prefix, e.name);
42
+
const abs = path.join(dir, e.name);
43
+
if (e.isDirectory()) {
44
+
out.push(...(await listMarkdownFiles(abs, rel)));
45
+
} else if (e.isFile() && isMarkdown(e.name)) {
46
+
out.push(rel.replaceAll(path.sep, "/"));
47
+
}
48
+
}
49
+
return out.sort((a, b) => a.localeCompare(b));
50
+
}
51
+
52
+
function titleFromMarkdown(md, fallback) {
53
+
const lines = md.split(/\r?\n/);
54
+
for (const line of lines) {
55
+
const m = /^#\s+(.+)\s*$/.exec(line);
56
+
if (m) return m[1].trim();
57
+
}
58
+
return fallback.replace(/\.md$/i, "");
59
+
}
60
+
61
+
function normalizeTitle(title) {
62
+
let t = String(title || "").trim();
63
+
// Strip markdown links: [text](url) -> text
64
+
t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
65
+
// If pages follow a "zat - ..." style, drop the redundant prefix in the nav.
66
+
t = t.replace(/^zat\s*-\s*/i, "");
67
+
// Cheaply capitalize (keeps the rest as-authored).
68
+
if (t.length) t = t[0].toUpperCase() + t.slice(1);
69
+
return t;
70
+
}
71
+
72
+
async function getBuildId() {
73
+
try {
74
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
75
+
cwd: repoRoot,
76
+
});
77
+
const full = String(stdout || "").trim();
78
+
if (full) return full.slice(0, 12);
79
+
} catch {
80
+
// ignore
81
+
}
82
+
return String(Date.now());
83
+
}
84
+
85
+
async function main() {
86
+
await rm(outDir, { recursive: true, force: true });
87
+
await mkdir(outDir, { recursive: true });
88
+
89
+
// Copy static site shell
90
+
await cp(siteSrcDir, outDir, { recursive: true });
91
+
92
+
// Cache-bust immutable assets on Wisp by appending a per-commit query string.
93
+
const buildId = await getBuildId();
94
+
const outIndex = path.join(outDir, "index.html");
95
+
if (await exists(outIndex)) {
96
+
let html = await readFile(outIndex, "utf8");
97
+
html = html.replaceAll('href="./style.css"', `href="./style.css?v=${buildId}"`);
98
+
html = html.replaceAll(
99
+
'src="./vendor/marked.min.js"',
100
+
`src="./vendor/marked.min.js?v=${buildId}"`,
101
+
);
102
+
html = html.replaceAll(
103
+
'src="./app.js"',
104
+
`src="./app.js?v=${buildId}"`,
105
+
);
106
+
html = html.replaceAll(
107
+
'href="./favicon.svg"',
108
+
`href="./favicon.svg?v=${buildId}"`,
109
+
);
110
+
await writeFile(outIndex, html, "utf8");
111
+
}
112
+
113
+
// Copy docs
114
+
await mkdir(outDocsDir, { recursive: true });
115
+
116
+
const pages = [];
117
+
118
+
// Prefer an explicit docs homepage if present; otherwise use repo README as index.
119
+
const docsIndex = path.join(docsDir, "index.md");
120
+
if (!(await exists(docsIndex))) {
121
+
const readme = path.join(repoRoot, "README.md");
122
+
if (await exists(readme)) {
123
+
let md = await readFile(readme, "utf8");
124
+
// Strip docs/ prefix from links since we're now inside the docs context.
125
+
md = md.replace(/\]\(docs\//g, "](");
126
+
await writeFile(path.join(outDocsDir, "index.md"), md, "utf8");
127
+
pages.push({
128
+
path: "index.md",
129
+
title: normalizeTitle(titleFromMarkdown(md, "index.md")),
130
+
});
131
+
}
132
+
}
133
+
134
+
const changelog = path.join(repoRoot, "CHANGELOG.md");
135
+
const docsChangelog = path.join(docsDir, "changelog.md");
136
+
if ((await exists(changelog)) && !(await exists(docsChangelog))) {
137
+
const md = await readFile(changelog, "utf8");
138
+
await writeFile(path.join(outDocsDir, "changelog.md"), md, "utf8");
139
+
pages.push({
140
+
path: "changelog.md",
141
+
title: normalizeTitle(titleFromMarkdown(md, "changelog.md")),
142
+
});
143
+
}
144
+
145
+
const mdFiles = (await exists(docsDir)) ? await listMarkdownFiles(docsDir) : [];
146
+
147
+
// Copy all markdown under docs/ (including archives), but only include non-archive
148
+
// paths in the sidebar manifest.
149
+
for (const rel of mdFiles) {
150
+
const src = path.join(docsDir, rel);
151
+
const dst = path.join(outDocsDir, rel);
152
+
await mkdir(path.dirname(dst), { recursive: true });
153
+
await cp(src, dst);
154
+
155
+
const md = await readFile(src, "utf8");
156
+
if (!rel.startsWith("archive/")) {
157
+
pages.push({ path: rel, title: normalizeTitle(titleFromMarkdown(md, rel)) });
158
+
}
159
+
}
160
+
161
+
// Copy devlog files to docs/devlog/ and generate an index
162
+
const devlogFiles = (await exists(devlogDir)) ? await listMarkdownFiles(devlogDir) : [];
163
+
const devlogEntries = [];
164
+
165
+
for (const rel of devlogFiles) {
166
+
const src = path.join(devlogDir, rel);
167
+
const dst = path.join(outDocsDir, "devlog", rel);
168
+
await mkdir(path.dirname(dst), { recursive: true });
169
+
await cp(src, dst);
170
+
171
+
const md = await readFile(src, "utf8");
172
+
devlogEntries.push({
173
+
path: `devlog/${rel}`,
174
+
title: titleFromMarkdown(md, rel),
175
+
});
176
+
}
177
+
178
+
// Generate devlog index listing all entries (newest first by filename)
179
+
if (devlogEntries.length > 0) {
180
+
devlogEntries.sort((a, b) => b.path.localeCompare(a.path));
181
+
const indexMd = [
182
+
"# devlog",
183
+
"",
184
+
...devlogEntries.map((e) => `- [${e.title}](${e.path})`),
185
+
"",
186
+
].join("\n");
187
+
await writeFile(path.join(outDocsDir, "devlog", "index.md"), indexMd, "utf8");
188
+
}
189
+
190
+
// Stable nav order: README homepage, then roadmap, then changelog, then the rest.
191
+
pages.sort((a, b) => {
192
+
const order = (p) => {
193
+
if (p === "index.md") return 0;
194
+
if (p === "roadmap.md") return 1;
195
+
if (p === "changelog.md") return 2;
196
+
return 3;
197
+
};
198
+
const ao = order(a.path);
199
+
const bo = order(b.path);
200
+
if (ao !== bo) return ao - bo;
201
+
return a.title.localeCompare(b.title);
202
+
});
203
+
204
+
await writeFile(
205
+
path.join(outDir, "manifest.json"),
206
+
JSON.stringify({ pages }, null, 2) + "\n",
207
+
"utf8",
208
+
);
209
+
210
+
process.stdout.write(
211
+
`Built Wisp docs site: ${pages.length} markdown file(s) -> ${outDir}\n`,
212
+
);
213
+
}
214
+
215
+
await main();
-185
scripts/build-wisp-docs.mjs
-185
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
-
import { execFile } from "node:child_process";
12
-
import { promisify } from "node:util";
13
-
14
-
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
15
-
const docsDir = path.join(repoRoot, "docs");
16
-
const siteSrcDir = path.join(repoRoot, "site");
17
-
const outDir = path.join(repoRoot, "site-out");
18
-
const outDocsDir = path.join(outDir, "docs");
19
-
20
-
const execFileAsync = promisify(execFile);
21
-
22
-
async function exists(filePath) {
23
-
try {
24
-
await access(filePath);
25
-
return true;
26
-
} catch {
27
-
return false;
28
-
}
29
-
}
30
-
31
-
function isMarkdown(filePath) {
32
-
return filePath.toLowerCase().endsWith(".md");
33
-
}
34
-
35
-
async function listMarkdownFiles(dir, prefix = "") {
36
-
const entries = await readdir(dir, { withFileTypes: true });
37
-
const out = [];
38
-
for (const e of entries) {
39
-
if (e.name.startsWith(".")) continue;
40
-
const rel = path.join(prefix, e.name);
41
-
const abs = path.join(dir, e.name);
42
-
if (e.isDirectory()) {
43
-
out.push(...(await listMarkdownFiles(abs, rel)));
44
-
} else if (e.isFile() && isMarkdown(e.name)) {
45
-
out.push(rel.replaceAll(path.sep, "/"));
46
-
}
47
-
}
48
-
return out.sort((a, b) => a.localeCompare(b));
49
-
}
50
-
51
-
function titleFromMarkdown(md, fallback) {
52
-
const lines = md.split(/\r?\n/);
53
-
for (const line of lines) {
54
-
const m = /^#\s+(.+)\s*$/.exec(line);
55
-
if (m) return m[1].trim();
56
-
}
57
-
return fallback.replace(/\.md$/i, "");
58
-
}
59
-
60
-
function normalizeTitle(title) {
61
-
let t = String(title || "").trim();
62
-
// Strip markdown links: [text](url) -> text
63
-
t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
64
-
// If pages follow a "zat - ..." style, drop the redundant prefix in the nav.
65
-
t = t.replace(/^zat\s*-\s*/i, "");
66
-
// Cheaply capitalize (keeps the rest as-authored).
67
-
if (t.length) t = t[0].toUpperCase() + t.slice(1);
68
-
return t;
69
-
}
70
-
71
-
async function getBuildId() {
72
-
try {
73
-
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
74
-
cwd: repoRoot,
75
-
});
76
-
const full = String(stdout || "").trim();
77
-
if (full) return full.slice(0, 12);
78
-
} catch {
79
-
// ignore
80
-
}
81
-
return String(Date.now());
82
-
}
83
-
84
-
async function main() {
85
-
await rm(outDir, { recursive: true, force: true });
86
-
await mkdir(outDir, { recursive: true });
87
-
88
-
// Copy static site shell
89
-
await cp(siteSrcDir, outDir, { recursive: true });
90
-
91
-
// Cache-bust immutable assets on Wisp by appending a per-commit query string.
92
-
const buildId = await getBuildId();
93
-
const outIndex = path.join(outDir, "index.html");
94
-
if (await exists(outIndex)) {
95
-
let html = await readFile(outIndex, "utf8");
96
-
html = html.replaceAll('href="./style.css"', `href="./style.css?v=${buildId}"`);
97
-
html = html.replaceAll(
98
-
'src="./vendor/marked.min.js"',
99
-
`src="./vendor/marked.min.js?v=${buildId}"`,
100
-
);
101
-
html = html.replaceAll(
102
-
'src="./app.js"',
103
-
`src="./app.js?v=${buildId}"`,
104
-
);
105
-
html = html.replaceAll(
106
-
'href="./favicon.svg"',
107
-
`href="./favicon.svg?v=${buildId}"`,
108
-
);
109
-
await writeFile(outIndex, html, "utf8");
110
-
}
111
-
112
-
// Copy docs
113
-
await mkdir(outDocsDir, { recursive: true });
114
-
115
-
const pages = [];
116
-
117
-
// Prefer an explicit docs homepage if present; otherwise use repo README as index.
118
-
const docsIndex = path.join(docsDir, "index.md");
119
-
if (!(await exists(docsIndex))) {
120
-
const readme = path.join(repoRoot, "README.md");
121
-
if (await exists(readme)) {
122
-
let md = await readFile(readme, "utf8");
123
-
// Strip docs/ prefix from links since we're now inside the docs context.
124
-
md = md.replace(/\]\(docs\//g, "](");
125
-
await writeFile(path.join(outDocsDir, "index.md"), md, "utf8");
126
-
pages.push({
127
-
path: "index.md",
128
-
title: normalizeTitle(titleFromMarkdown(md, "index.md")),
129
-
});
130
-
}
131
-
}
132
-
133
-
const changelog = path.join(repoRoot, "CHANGELOG.md");
134
-
const docsChangelog = path.join(docsDir, "changelog.md");
135
-
if ((await exists(changelog)) && !(await exists(docsChangelog))) {
136
-
const md = await readFile(changelog, "utf8");
137
-
await writeFile(path.join(outDocsDir, "changelog.md"), md, "utf8");
138
-
pages.push({
139
-
path: "changelog.md",
140
-
title: normalizeTitle(titleFromMarkdown(md, "changelog.md")),
141
-
});
142
-
}
143
-
144
-
const mdFiles = (await exists(docsDir)) ? await listMarkdownFiles(docsDir) : [];
145
-
146
-
// Copy all markdown under docs/ (including archives), but only include non-archive
147
-
// paths in the sidebar manifest.
148
-
for (const rel of mdFiles) {
149
-
const src = path.join(docsDir, rel);
150
-
const dst = path.join(outDocsDir, rel);
151
-
await mkdir(path.dirname(dst), { recursive: true });
152
-
await cp(src, dst);
153
-
154
-
const md = await readFile(src, "utf8");
155
-
if (!rel.startsWith("archive/")) {
156
-
pages.push({ path: rel, title: normalizeTitle(titleFromMarkdown(md, rel)) });
157
-
}
158
-
}
159
-
160
-
// Stable nav order: README homepage, then roadmap, then changelog, then the rest.
161
-
pages.sort((a, b) => {
162
-
const order = (p) => {
163
-
if (p === "index.md") return 0;
164
-
if (p === "roadmap.md") return 1;
165
-
if (p === "changelog.md") return 2;
166
-
return 3;
167
-
};
168
-
const ao = order(a.path);
169
-
const bo = order(b.path);
170
-
if (ao !== bo) return ao - bo;
171
-
return a.title.localeCompare(b.title);
172
-
});
173
-
174
-
await writeFile(
175
-
path.join(outDir, "manifest.json"),
176
-
JSON.stringify({ pages }, null, 2) + "\n",
177
-
"utf8",
178
-
);
179
-
180
-
process.stdout.write(
181
-
`Built Wisp docs site: ${pages.length} markdown file(s) -> ${outDir}\n`,
182
-
);
183
-
}
184
-
185
-
await main();
···
+2
-2
scripts/publish-docs.zig
+2
-2
scripts/publish-docs.zig
···
14
15
/// devlog entries
16
const devlog = [_]DocEntry{
17
-
.{ .path = "/001", .file = "devlog/001-self-publishing-docs.md" },
18
};
19
20
pub fn main() !void {
···
87
// devlog publication (clock_id 100 to separate from docs)
88
const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100);
89
const devlog_pub = Publication{
90
-
.url = "https://zat.dev/devlog",
91
.name = "zat devlog",
92
.description = "building zat in public",
93
};
···
14
15
/// devlog entries
16
const devlog = [_]DocEntry{
17
+
.{ .path = "/devlog/001", .file = "devlog/001-self-publishing-docs.md" },
18
};
19
20
pub fn main() !void {
···
87
// devlog publication (clock_id 100 to separate from docs)
88
const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100);
89
const devlog_pub = Publication{
90
+
.url = "https://zat.dev",
91
.name = "zat devlog",
92
.description = "building zat in public",
93
};
+20
-1
site/app.js
+20
-1
site/app.js
···
1
const navEl = document.getElementById("nav");
2
const contentEl = document.getElementById("content");
3
4
const buildId = new URL(import.meta.url).searchParams.get("v") || "";
5
···
120
}
121
122
try {
123
-
const md = await fetchText(`./docs/${encodeURIComponent(activePath)}`);
124
const html = globalThis.marked.parse(md);
125
contentEl.innerHTML = html;
126
···
1
const navEl = document.getElementById("nav");
2
const contentEl = document.getElementById("content");
3
+
const menuToggle = document.querySelector(".menu-toggle");
4
+
const sidebar = document.querySelector(".sidebar");
5
+
const overlay = document.querySelector(".overlay");
6
+
7
+
function 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
+
15
+
menuToggle?.addEventListener("click", () => toggleMenu());
16
+
overlay?.addEventListener("click", () => toggleMenu(false));
17
+
18
+
// Close menu when nav link clicked (mobile)
19
+
navEl?.addEventListener("click", (e) => {
20
+
if (e.target.closest("a")) toggleMenu(false);
21
+
});
22
23
const buildId = new URL(import.meta.url).searchParams.get("v") || "";
24
···
139
}
140
141
try {
142
+
const md = await fetchText(`./docs/${activePath}`);
143
const html = globalThis.marked.parse(md);
144
contentEl.innerHTML = html;
145
+8
-8
site/index.html
+8
-8
site/index.html
···
11
<body>
12
<div class="app">
13
<header class="header">
14
<a class="brand" href="./">zat.dev</a>
15
-
<a
16
-
class="header-link"
17
-
href="https://tangled.org/zat.dev/zat"
18
-
target="_blank"
19
-
rel="noopener noreferrer"
20
-
>
21
-
repo
22
-
</a>
23
</header>
24
25
<div class="layout">
26
<nav class="sidebar">
27
<div id="nav" class="nav"></div>
···
11
<body>
12
<div class="app">
13
<header class="header">
14
+
<button class="menu-toggle" aria-label="Toggle navigation" aria-expanded="false">
15
+
<span></span>
16
+
</button>
17
<a class="brand" href="./">zat.dev</a>
18
+
<div class="header-links">
19
+
<a class="header-link" href="#devlog/index.md">devlog</a>
20
+
<a class="header-link" href="https://tangled.sh/zat.dev/zat" target="_blank" rel="noopener noreferrer">repo</a>
21
+
</div>
22
</header>
23
24
+
<div class="overlay"></div>
25
<div class="layout">
26
<nav class="sidebar">
27
<div id="nav" class="nav"></div>
+202
-60
site/style.css
+202
-60
site/style.css
···
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,
···
27
--codebg: rgba(0, 0, 0, 0.04);
28
--shadow: rgba(0, 0, 0, 0.08);
29
}
30
}
31
32
html,
···
49
text-decoration: underline;
50
}
51
52
.app {
53
min-height: 100%;
54
display: flex;
55
flex-direction: column;
56
}
57
58
.header {
59
position: sticky;
60
top: 0;
61
-
z-index: 5;
62
display: flex;
63
-
gap: 12px;
64
align-items: center;
65
-
padding: 12px 16px;
66
border-bottom: 1px solid var(--border);
67
background: color-mix(in srgb, var(--panel) 92%, transparent);
68
backdrop-filter: blur(10px);
69
}
70
71
.brand {
72
font-weight: 700;
73
-
letter-spacing: 0.2px;
74
-
padding: 6px 10px;
75
-
border-radius: 10px;
76
}
77
.brand:hover {
78
-
background: color-mix(in srgb, var(--codebg) 70%, transparent);
79
text-decoration: none;
80
}
81
82
.header-link {
83
-
margin-left: auto;
84
-
padding: 8px 10px;
85
-
border-radius: 10px;
86
border: 1px solid var(--border);
87
color: var(--text);
88
-
opacity: 0.9;
89
}
90
.header-link:hover {
91
-
background: color-mix(in srgb, var(--codebg) 70%, transparent);
92
text-decoration: none;
93
-
opacity: 1;
94
}
95
96
.layout {
97
-
display: grid;
98
-
grid-template-columns: 280px 1fr;
99
-
gap: 16px;
100
-
padding: 16px;
101
flex: 1;
102
-
}
103
-
104
-
@media (max-width: 980px) {
105
-
.layout {
106
-
grid-template-columns: 1fr;
107
-
}
108
-
.sidebar {
109
-
position: relative;
110
-
top: auto;
111
-
max-height: none;
112
-
}
113
}
114
115
.sidebar {
116
position: sticky;
117
-
top: 64px;
118
-
align-self: start;
119
-
max-height: calc(100vh - 84px);
120
-
overflow: auto;
121
border: 1px solid var(--border);
122
border-radius: var(--radius);
123
background: var(--panel);
124
-
box-shadow: 0 12px 40px var(--shadow);
125
}
126
127
.nav {
···
133
134
.nav a {
135
display: block;
136
-
padding: 8px 10px;
137
-
border-radius: 10px;
138
color: var(--text);
139
-
opacity: 0.9;
140
}
141
.nav a:hover {
142
-
background: color-mix(in srgb, var(--codebg) 70%, transparent);
143
text-decoration: none;
144
}
145
.nav a[aria-current="page"] {
146
-
background: color-mix(in srgb, var(--link) 14%, var(--codebg));
147
-
border: 1px solid color-mix(in srgb, var(--link) 20%, var(--border));
148
}
149
150
.main {
151
-
display: flex;
152
-
justify-content: center;
153
}
154
155
.content {
156
-
width: min(var(--max), 100%);
157
border: 1px solid var(--border);
158
border-radius: var(--radius);
159
background: var(--panel);
160
-
box-shadow: 0 12px 40px var(--shadow);
161
padding: 24px;
162
}
163
164
.site-footer {
165
-
display: flex;
166
-
justify-content: center;
167
-
padding: 12px 16px;
168
border-top: 1px solid var(--border);
169
-
background: var(--panel);
170
}
171
172
.footer-link {
173
font-size: 13px;
174
color: var(--muted);
175
-
padding: 6px 10px;
176
-
border-radius: 10px;
177
-
border: 1px solid transparent;
178
}
179
.footer-link:hover {
180
color: var(--text);
181
-
background: color-mix(in srgb, var(--codebg) 70%, transparent);
182
-
border-color: var(--border);
183
text-decoration: none;
184
}
185
186
.content h1,
187
.content h2,
188
.content h3 {
189
-
scroll-margin-top: 84px;
190
}
191
192
.content h1 {
193
margin-top: 0;
194
-
font-size: 34px;
195
}
196
197
.content p,
198
.content li {
199
-
line-height: 1.6;
200
}
201
202
.content code {
203
font-family: var(--mono);
204
-
font-size: 0.95em;
205
background: var(--codebg);
206
padding: 2px 6px;
207
-
border-radius: 8px;
208
}
209
210
.content pre {
211
-
overflow: auto;
212
-
padding: 14px 16px;
213
-
border-radius: 12px;
214
background: var(--codebg);
215
border: 1px solid var(--border);
216
}
217
218
.content pre code {
···
220
padding: 0;
221
}
222
223
.empty {
224
color: var(--muted);
225
}
···
10
--shadow: rgba(0, 0, 0, 0.35);
11
--max: 900px;
12
--radius: 12px;
13
+
--gutter: 16px;
14
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
15
"Courier New", monospace;
16
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica,
···
28
--codebg: rgba(0, 0, 0, 0.04);
29
--shadow: rgba(0, 0, 0, 0.08);
30
}
31
+
}
32
+
33
+
* {
34
+
box-sizing: border-box;
35
}
36
37
html,
···
54
text-decoration: underline;
55
}
56
57
+
/* App shell */
58
.app {
59
min-height: 100%;
60
display: flex;
61
flex-direction: column;
62
}
63
64
+
/* Header */
65
.header {
66
position: sticky;
67
top: 0;
68
+
z-index: 20;
69
display: flex;
70
align-items: center;
71
+
gap: 12px;
72
+
padding: 12px var(--gutter);
73
border-bottom: 1px solid var(--border);
74
background: color-mix(in srgb, var(--panel) 92%, transparent);
75
backdrop-filter: blur(10px);
76
}
77
78
+
.menu-toggle {
79
+
display: none;
80
+
align-items: center;
81
+
justify-content: center;
82
+
width: 36px;
83
+
height: 36px;
84
+
padding: 0;
85
+
background: transparent;
86
+
border: 1px solid var(--border);
87
+
border-radius: 8px;
88
+
cursor: pointer;
89
+
flex-shrink: 0;
90
+
}
91
+
.menu-toggle span {
92
+
display: block;
93
+
width: 16px;
94
+
height: 2px;
95
+
background: var(--text);
96
+
border-radius: 1px;
97
+
position: relative;
98
+
}
99
+
.menu-toggle span::before,
100
+
.menu-toggle span::after {
101
+
content: "";
102
+
position: absolute;
103
+
left: 0;
104
+
width: 16px;
105
+
height: 2px;
106
+
background: var(--text);
107
+
border-radius: 1px;
108
+
transition: transform 0.2s;
109
+
}
110
+
.menu-toggle span::before {
111
+
top: -5px;
112
+
}
113
+
.menu-toggle span::after {
114
+
top: 5px;
115
+
}
116
+
.menu-toggle[aria-expanded="true"] span {
117
+
background: transparent;
118
+
}
119
+
.menu-toggle[aria-expanded="true"] span::before {
120
+
transform: translateY(5px) rotate(45deg);
121
+
}
122
+
.menu-toggle[aria-expanded="true"] span::after {
123
+
transform: translateY(-5px) rotate(-45deg);
124
+
}
125
+
126
.brand {
127
font-weight: 700;
128
+
font-size: 15px;
129
+
color: var(--text);
130
+
padding: 6px 0;
131
}
132
.brand:hover {
133
text-decoration: none;
134
+
opacity: 0.8;
135
+
}
136
+
137
+
.header-links {
138
+
display: flex;
139
+
gap: 8px;
140
+
margin-left: auto;
141
}
142
143
.header-link {
144
+
padding: 6px 12px;
145
+
font-size: 14px;
146
+
border-radius: 8px;
147
border: 1px solid var(--border);
148
color: var(--text);
149
}
150
.header-link:hover {
151
+
background: var(--codebg);
152
text-decoration: none;
153
}
154
155
+
/* Overlay */
156
+
.overlay {
157
+
display: none;
158
+
position: fixed;
159
+
inset: 0;
160
+
z-index: 15;
161
+
background: rgba(0, 0, 0, 0.5);
162
+
}
163
+
.overlay.open {
164
+
display: block;
165
+
}
166
+
167
+
/* Layout */
168
.layout {
169
+
display: flex;
170
+
gap: var(--gutter);
171
+
padding: var(--gutter);
172
flex: 1;
173
+
max-width: 1200px;
174
+
margin: 0 auto;
175
+
width: 100%;
176
}
177
178
+
/* Sidebar */
179
.sidebar {
180
+
width: 240px;
181
+
flex-shrink: 0;
182
position: sticky;
183
+
top: 72px;
184
+
align-self: flex-start;
185
+
max-height: calc(100vh - 88px);
186
+
overflow-y: auto;
187
border: 1px solid var(--border);
188
border-radius: var(--radius);
189
background: var(--panel);
190
}
191
192
.nav {
···
198
199
.nav a {
200
display: block;
201
+
padding: 10px 12px;
202
+
border-radius: 8px;
203
color: var(--text);
204
+
font-size: 14px;
205
}
206
.nav a:hover {
207
+
background: var(--codebg);
208
text-decoration: none;
209
}
210
.nav a[aria-current="page"] {
211
+
background: color-mix(in srgb, var(--link) 15%, transparent);
212
}
213
214
+
/* Main content */
215
.main {
216
+
flex: 1;
217
+
min-width: 0;
218
}
219
220
.content {
221
border: 1px solid var(--border);
222
border-radius: var(--radius);
223
background: var(--panel);
224
padding: 24px;
225
}
226
227
+
/* Footer */
228
.site-footer {
229
+
padding: 16px var(--gutter);
230
+
text-align: center;
231
border-top: 1px solid var(--border);
232
}
233
234
.footer-link {
235
font-size: 13px;
236
color: var(--muted);
237
}
238
.footer-link:hover {
239
color: var(--text);
240
text-decoration: none;
241
}
242
243
+
/* Content typography */
244
.content h1,
245
.content h2,
246
.content h3 {
247
+
scroll-margin-top: 80px;
248
}
249
250
.content h1 {
251
margin-top: 0;
252
+
font-size: 28px;
253
+
}
254
+
255
+
.content h2 {
256
+
font-size: 20px;
257
+
margin-top: 32px;
258
+
}
259
+
260
+
.content h3 {
261
+
font-size: 16px;
262
+
margin-top: 24px;
263
}
264
265
.content p,
266
.content li {
267
+
line-height: 1.65;
268
}
269
270
.content code {
271
font-family: var(--mono);
272
+
font-size: 0.9em;
273
background: var(--codebg);
274
padding: 2px 6px;
275
+
border-radius: 6px;
276
}
277
278
.content pre {
279
+
overflow-x: auto;
280
+
padding: 16px;
281
+
border-radius: 10px;
282
background: var(--codebg);
283
border: 1px solid var(--border);
284
+
font-size: 14px;
285
+
line-height: 1.5;
286
}
287
288
.content pre code {
···
290
padding: 0;
291
}
292
293
+
.content details {
294
+
margin: 16px 0;
295
+
}
296
+
297
+
.content details summary {
298
+
cursor: pointer;
299
+
padding: 8px 0;
300
+
}
301
+
302
.empty {
303
color: var(--muted);
304
}
305
+
306
+
/* Mobile */
307
+
@media (max-width: 768px) {
308
+
:root {
309
+
--gutter: 16px;
310
+
}
311
+
312
+
.menu-toggle {
313
+
display: flex;
314
+
}
315
+
316
+
.layout {
317
+
flex-direction: column;
318
+
}
319
+
320
+
.sidebar {
321
+
position: fixed;
322
+
top: 0;
323
+
left: 0;
324
+
bottom: 0;
325
+
width: 280px;
326
+
max-width: 80vw;
327
+
z-index: 16;
328
+
border: none;
329
+
border-radius: 0;
330
+
border-right: 1px solid var(--border);
331
+
max-height: none;
332
+
padding-top: 60px;
333
+
transform: translateX(-100%);
334
+
transition: transform 0.2s ease-out;
335
+
}
336
+
337
+
.sidebar.open {
338
+
transform: translateX(0);
339
+
}
340
+
341
+
.nav {
342
+
padding: 12px;
343
+
}
344
+
345
+
.nav a {
346
+
padding: 12px 14px;
347
+
font-size: 15px;
348
+
}
349
+
350
+
.content {
351
+
padding: 20px;
352
+
border-radius: 10px;
353
+
}
354
+
355
+
.content h1 {
356
+
font-size: 24px;
357
+
}
358
+
359
+
.content h2 {
360
+
font-size: 18px;
361
+
}
362
+
363
+
.content pre {
364
+
font-size: 13px;
365
+
padding: 14px;
366
+
}
367
+
}
+107
-3
src/internal/json.zig
+107
-3
src/internal/json.zig
···
6
//! two approaches:
7
//! - runtime paths: getString(value, "embed.external.uri") - for dynamic paths
8
//! - comptime paths: extractAt(T, alloc, value, .{"embed", "external"}) - for static paths with type safety
9
10
const std = @import("std");
11
12
/// navigate a json value by dot-separated path
13
/// returns null if any segment is missing or wrong type
···
92
/// extract a typed struct from a nested path
93
/// uses comptime tuple for path segments - no runtime string parsing
94
/// leverages std.json.parseFromValueLeaky for type-safe extraction
95
pub fn extractAt(
96
comptime T: type,
97
allocator: std.mem.Allocator,
···
101
var current = value;
102
inline for (path) |segment| {
103
current = switch (current) {
104
-
.object => |obj| obj.get(segment) orelse return error.MissingField,
105
-
else => return error.UnexpectedToken,
106
};
107
}
108
-
return std.json.parseFromValueLeaky(T, allocator, current, .{});
109
}
110
111
/// extract a typed value, returning null if path doesn't exist
···
278
const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"});
279
try std.testing.expect(missing == null);
280
}
···
6
//! two approaches:
7
//! - runtime paths: getString(value, "embed.external.uri") - for dynamic paths
8
//! - comptime paths: extractAt(T, alloc, value, .{"embed", "external"}) - for static paths with type safety
9
+
//!
10
+
//! debug logging:
11
+
//! enable with `pub const std_options = .{ .log_scope_levels = &.{.{ .scope = .zat, .level = .debug }} };`
12
13
const std = @import("std");
14
+
const log = std.log.scoped(.zat);
15
16
/// navigate a json value by dot-separated path
17
/// returns null if any segment is missing or wrong type
···
96
/// extract a typed struct from a nested path
97
/// uses comptime tuple for path segments - no runtime string parsing
98
/// leverages std.json.parseFromValueLeaky for type-safe extraction
99
+
///
100
+
/// on failure, logs diagnostic info when debug logging is enabled for .zat scope
101
pub fn extractAt(
102
comptime T: type,
103
allocator: std.mem.Allocator,
···
107
var current = value;
108
inline for (path) |segment| {
109
current = switch (current) {
110
+
.object => |obj| obj.get(segment) orelse {
111
+
log.debug("extractAt: missing field \"{s}\" in path {any}, expected {s}", .{
112
+
segment,
113
+
path,
114
+
@typeName(T),
115
+
});
116
+
return error.MissingField;
117
+
},
118
+
else => {
119
+
log.debug("extractAt: expected object at \"{s}\" in path {any}, got {s}", .{
120
+
segment,
121
+
path,
122
+
@tagName(current),
123
+
});
124
+
return error.UnexpectedToken;
125
+
},
126
};
127
}
128
+
return std.json.parseFromValueLeaky(T, allocator, current, .{ .ignore_unknown_fields = true }) catch |err| {
129
+
log.debug("extractAt: parse failed for {s} at path {any}: {s} (json type: {s})", .{
130
+
@typeName(T),
131
+
path,
132
+
@errorName(err),
133
+
@tagName(current),
134
+
});
135
+
return err;
136
+
};
137
}
138
139
/// extract a typed value, returning null if path doesn't exist
···
306
const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"});
307
try std.testing.expect(missing == null);
308
}
309
+
310
+
test "extractAt logs diagnostic on enum parse failure" {
311
+
// simulates the issue: unknown enum value from external API
312
+
const json_str =
313
+
\\{"op": {"action": "archive", "path": "app.bsky.feed.post/abc"}}
314
+
;
315
+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
316
+
defer arena.deinit();
317
+
318
+
const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{});
319
+
320
+
const Action = enum { create, update, delete };
321
+
const Op = struct {
322
+
action: Action,
323
+
path: []const u8,
324
+
};
325
+
326
+
// "archive" is not a valid Action variant - this should fail
327
+
// with debug logging enabled, you'd see:
328
+
// debug(zat): extractAt: parse failed for json.Op at path { "op" }: InvalidEnumTag (json type: object)
329
+
const result = extractAtOptional(Op, arena.allocator(), parsed.value, .{"op"});
330
+
try std.testing.expect(result == null);
331
+
}
332
+
333
+
test "extractAt logs diagnostic on missing field" {
334
+
const json_str =
335
+
\\{"data": {"name": "test"}}
336
+
;
337
+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
338
+
defer arena.deinit();
339
+
340
+
const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{});
341
+
342
+
const Thing = struct { value: i64 };
343
+
344
+
// path "data.missing" doesn't exist
345
+
// with debug logging enabled, you'd see:
346
+
// debug(zat): extractAt: missing field "missing" in path { "data", "missing" }, expected json.Thing
347
+
const result = extractAtOptional(Thing, arena.allocator(), parsed.value, .{ "data", "missing" });
348
+
try std.testing.expect(result == null);
349
+
}
350
+
351
+
test "extractAt ignores unknown fields" {
352
+
// real-world case: TAP messages have extra fields (live, rev, cid) that we don't need
353
+
const json_str =
354
+
\\{
355
+
\\ "record": {
356
+
\\ "live": true,
357
+
\\ "did": "did:plc:abc123",
358
+
\\ "rev": "3mbspmpaidl2a",
359
+
\\ "collection": "pub.leaflet.document",
360
+
\\ "rkey": "xyz789",
361
+
\\ "action": "create",
362
+
\\ "cid": "bafyreitest"
363
+
\\ }
364
+
\\}
365
+
;
366
+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
367
+
defer arena.deinit();
368
+
369
+
const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{});
370
+
371
+
// only extract the fields we care about
372
+
const Record = struct {
373
+
collection: []const u8,
374
+
action: []const u8,
375
+
did: []const u8,
376
+
rkey: []const u8,
377
+
};
378
+
379
+
const rec = try extractAt(Record, arena.allocator(), parsed.value, .{"record"});
380
+
try std.testing.expectEqualStrings("pub.leaflet.document", rec.collection);
381
+
try std.testing.expectEqualStrings("create", rec.action);
382
+
try std.testing.expectEqualStrings("did:plc:abc123", rec.did);
383
+
try std.testing.expectEqualStrings("xyz789", rec.rkey);
384
+
}