atproto utils for zig zat.dev
atproto sdk zig

publish docs to leaflet alongside site.standard

- add minimal markdown→leaflet blocks converter (headers, code, paragraphs)
- dual-publish to both site.standard.document and pub.leaflet.document
- trigger publish workflow on doc changes (README, CHANGELOG, docs/*, devlog/*)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+204
.tangled
workflows
scripts
+8
.tangled/workflows/publish-docs.yml
··· 1 when: 2 - event: push 3 tag: "v*" 4 5 engine: nixery 6 ··· 11 steps: 12 - name: build and publish docs to ATProto 13 command: | 14 zig build 15 ./zig-out/bin/publish-docs
··· 1 when: 2 - event: push 3 tag: "v*" 4 + - event: push 5 + branch: main 6 + path: 7 + - "README.md" 8 + - "CHANGELOG.md" 9 + - "docs/**" 10 + - "devlog/**" 11 12 engine: nixery 13 ··· 18 steps: 19 - name: build and publish docs to ATProto 20 command: | 21 + test -n "$ATPROTO_PASSWORD" 22 zig build 23 ./zig-out/bin/publish-docs
+196
scripts/publish-docs.zig
··· 82 83 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 84 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() }); 85 } 86 87 // devlog publication (clock_id 100 to separate from docs) ··· 121 122 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 123 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, session.did, tid.str() }); 124 } 125 126 std.debug.print("done\n", .{}); ··· 142 publishedAt: []const u8, 143 }; 144 145 const Session = struct { 146 did: []const u8, 147 access_token: []const u8, ··· 261 }) catch unreachable; 262 return buf; 263 }
··· 82 83 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 84 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() }); 85 + 86 + // also publish to leaflet 87 + const leaflet_blocks = try parseMarkdownToLeafletBlocks(allocator, content); 88 + const leaflet_page = LeafletPage{ .blocks = leaflet_blocks }; 89 + const leaflet_pages = try allocator.alloc(LeafletPage, 1); 90 + leaflet_pages[0] = leaflet_page; 91 + 92 + const leaflet_record = LeafletDocument{ 93 + .author = session.did, 94 + .title = title, 95 + .publishedAt = &now, 96 + .pages = leaflet_pages, 97 + }; 98 + try putRecord(&client, allocator, session.did, "pub.leaflet.document", tid.str(), leaflet_record); 99 + std.debug.print("published: {s} -> at://{s}/pub.leaflet.document/{s}\n", .{ doc.file, session.did, tid.str() }); 100 } 101 102 // devlog publication (clock_id 100 to separate from docs) ··· 136 137 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 138 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, session.did, tid.str() }); 139 + 140 + // also publish to leaflet 141 + const leaflet_blocks = try parseMarkdownToLeafletBlocks(allocator, content); 142 + const leaflet_page = LeafletPage{ .blocks = leaflet_blocks }; 143 + const leaflet_pages = try allocator.alloc(LeafletPage, 1); 144 + leaflet_pages[0] = leaflet_page; 145 + 146 + const leaflet_record = LeafletDocument{ 147 + .author = session.did, 148 + .title = title, 149 + .publishedAt = &now, 150 + .pages = leaflet_pages, 151 + }; 152 + try putRecord(&client, allocator, session.did, "pub.leaflet.document", tid.str(), leaflet_record); 153 + std.debug.print("published: {s} -> at://{s}/pub.leaflet.document/{s}\n", .{ entry.file, session.did, tid.str() }); 154 } 155 156 std.debug.print("done\n", .{}); ··· 172 publishedAt: []const u8, 173 }; 174 175 + // leaflet types 176 + const LeafletDocument = struct { 177 + @"$type": []const u8 = "pub.leaflet.document", 178 + author: []const u8, 179 + title: []const u8, 180 + publishedAt: ?[]const u8 = null, 181 + pages: []const LeafletPage, 182 + }; 183 + 184 + const LeafletPage = struct { 185 + @"$type": []const u8 = "pub.leaflet.pages.linearDocument", 186 + blocks: []const LeafletBlockWrapper, 187 + }; 188 + 189 + const LeafletBlockWrapper = struct { 190 + block: LeafletBlock, 191 + }; 192 + 193 + const LeafletBlock = union(enum) { 194 + header: HeaderBlock, 195 + text: TextBlock, 196 + code: CodeBlock, 197 + 198 + pub fn jsonStringify(self: @This(), jw: anytype) !void { 199 + switch (self) { 200 + .header => |h| try jw.write(h), 201 + .text => |t| try jw.write(t), 202 + .code => |c| try jw.write(c), 203 + } 204 + } 205 + }; 206 + 207 + const HeaderBlock = struct { 208 + @"$type": []const u8 = "pub.leaflet.blocks.header", 209 + plaintext: []const u8, 210 + level: u8, 211 + }; 212 + 213 + const TextBlock = struct { 214 + @"$type": []const u8 = "pub.leaflet.blocks.text", 215 + plaintext: []const u8, 216 + }; 217 + 218 + const CodeBlock = struct { 219 + @"$type": []const u8 = "pub.leaflet.blocks.code", 220 + plaintext: []const u8, 221 + language: ?[]const u8 = null, 222 + }; 223 + 224 const Session = struct { 225 did: []const u8, 226 access_token: []const u8, ··· 340 }) catch unreachable; 341 return buf; 342 } 343 + 344 + /// parse markdown into leaflet blocks (minimal: headers, code, paragraphs) 345 + fn parseMarkdownToLeafletBlocks(allocator: Allocator, markdown: []const u8) ![]LeafletBlockWrapper { 346 + var blocks: std.ArrayList(LeafletBlockWrapper) = .empty; 347 + errdefer blocks.deinit(allocator); 348 + 349 + var lines = std.mem.splitScalar(u8, markdown, '\n'); 350 + var in_code_block = false; 351 + var code_content: std.ArrayList(u8) = .empty; 352 + defer code_content.deinit(allocator); 353 + var code_lang: ?[]const u8 = null; 354 + var paragraph_lines: std.ArrayList([]const u8) = .empty; 355 + defer paragraph_lines.deinit(allocator); 356 + 357 + while (lines.next()) |line| { 358 + const trimmed = std.mem.trim(u8, line, " \t\r"); 359 + 360 + // handle code blocks 361 + if (std.mem.startsWith(u8, trimmed, "```")) { 362 + if (in_code_block) { 363 + // end code block 364 + try blocks.append(allocator, .{ .block = .{ .code = .{ 365 + .plaintext = try allocator.dupe(u8, code_content.items), 366 + .language = code_lang, 367 + } } }); 368 + code_content.clearRetainingCapacity(); 369 + code_lang = null; 370 + in_code_block = false; 371 + } else { 372 + // flush any pending paragraph first 373 + if (paragraph_lines.items.len > 0) { 374 + const para_text = try std.mem.join(allocator, " ", paragraph_lines.items); 375 + if (para_text.len > 0) { 376 + try blocks.append(allocator, .{ .block = .{ .text = .{ .plaintext = para_text } } }); 377 + } 378 + paragraph_lines.clearRetainingCapacity(); 379 + } 380 + // start code block 381 + in_code_block = true; 382 + const lang_part = trimmed[3..]; 383 + if (lang_part.len > 0) { 384 + code_lang = try allocator.dupe(u8, lang_part); 385 + } 386 + } 387 + continue; 388 + } 389 + 390 + if (in_code_block) { 391 + if (code_content.items.len > 0) { 392 + try code_content.append(allocator, '\n'); 393 + } 394 + try code_content.appendSlice(allocator, line); 395 + continue; 396 + } 397 + 398 + // handle headers 399 + if (trimmed.len > 0 and trimmed[0] == '#') { 400 + // flush any pending paragraph first 401 + if (paragraph_lines.items.len > 0) { 402 + const para_text = try std.mem.join(allocator, " ", paragraph_lines.items); 403 + if (para_text.len > 0) { 404 + try blocks.append(allocator, .{ .block = .{ .text = .{ .plaintext = para_text } } }); 405 + } 406 + paragraph_lines.clearRetainingCapacity(); 407 + } 408 + 409 + var level: u8 = 0; 410 + for (trimmed) |c| { 411 + if (c == '#') level += 1 else break; 412 + } 413 + if (level > 0 and level <= 6 and trimmed.len > level and trimmed[level] == ' ') { 414 + var header_text = trimmed[level + 1 ..]; 415 + // strip markdown link: [text](url) -> text 416 + if (std.mem.indexOf(u8, header_text, "](")) |bracket| { 417 + if (header_text[0] == '[') { 418 + header_text = header_text[1..bracket]; 419 + } 420 + } 421 + try blocks.append(allocator, .{ .block = .{ .header = .{ 422 + .plaintext = try allocator.dupe(u8, header_text), 423 + .level = level, 424 + } } }); 425 + continue; 426 + } 427 + } 428 + 429 + // blank line ends paragraph 430 + if (trimmed.len == 0) { 431 + if (paragraph_lines.items.len > 0) { 432 + const para_text = try std.mem.join(allocator, " ", paragraph_lines.items); 433 + if (para_text.len > 0) { 434 + try blocks.append(allocator, .{ .block = .{ .text = .{ .plaintext = para_text } } }); 435 + } 436 + paragraph_lines.clearRetainingCapacity(); 437 + } 438 + continue; 439 + } 440 + 441 + // accumulate paragraph lines 442 + try paragraph_lines.append(allocator, try allocator.dupe(u8, trimmed)); 443 + } 444 + 445 + // flush remaining content 446 + if (in_code_block and code_content.items.len > 0) { 447 + try blocks.append(allocator, .{ .block = .{ .code = .{ 448 + .plaintext = try allocator.dupe(u8, code_content.items), 449 + .language = code_lang, 450 + } } }); 451 + } else if (paragraph_lines.items.len > 0) { 452 + const para_text = try std.mem.join(allocator, " ", paragraph_lines.items); 453 + if (para_text.len > 0) { 454 + try blocks.append(allocator, .{ .block = .{ .text = .{ .plaintext = para_text } } }); 455 + } 456 + } 457 + 458 + return blocks.toOwnedSlice(allocator); 459 + }