+8
.tangled/workflows/publish-docs.yml
+8
.tangled/workflows/publish-docs.yml
···
1
1
when:
2
2
- event: push
3
3
tag: "v*"
4
+
- event: push
5
+
branch: main
6
+
path:
7
+
- "README.md"
8
+
- "CHANGELOG.md"
9
+
- "docs/**"
10
+
- "devlog/**"
4
11
5
12
engine: nixery
6
13
···
11
18
steps:
12
19
- name: build and publish docs to ATProto
13
20
command: |
21
+
test -n "$ATPROTO_PASSWORD"
14
22
zig build
15
23
./zig-out/bin/publish-docs
+196
scripts/publish-docs.zig
+196
scripts/publish-docs.zig
···
82
82
83
83
try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record);
84
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() });
85
100
}
86
101
87
102
// devlog publication (clock_id 100 to separate from docs)
···
121
136
122
137
try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record);
123
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() });
124
154
}
125
155
126
156
std.debug.print("done\n", .{});
···
142
172
publishedAt: []const u8,
143
173
};
144
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
+
145
224
const Session = struct {
146
225
did: []const u8,
147
226
access_token: []const u8,
···
261
340
}) catch unreachable;
262
341
return buf;
263
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
+
}