1const std = @import("std");
2const zat = @import("zat");
3
4const Allocator = std.mem.Allocator;
5
6const DocEntry = struct { path: []const u8, file: []const u8 };
7
8/// docs to publish as site.standard.document records
9const docs = [_]DocEntry{
10 .{ .path = "/", .file = "README.md" },
11 .{ .path = "/roadmap", .file = "docs/roadmap.md" },
12 .{ .path = "/changelog", .file = "CHANGELOG.md" },
13};
14
15/// devlog entries
16const devlog = [_]DocEntry{
17 .{ .path = "/devlog/001", .file = "devlog/001-self-publishing-docs.md" },
18};
19
20pub fn main() !void {
21 // use page_allocator for CLI tool - OS reclaims on exit
22 const allocator = std.heap.page_allocator;
23
24 const handle = "zat.dev";
25
26 const password = std.posix.getenv("ATPROTO_PASSWORD") orelse {
27 std.debug.print("error: ATPROTO_PASSWORD not set\n", .{});
28 return error.MissingEnv;
29 };
30
31 const pds = std.posix.getenv("ATPROTO_PDS") orelse "https://bsky.social";
32
33 var client = zat.XrpcClient.init(allocator, pds);
34 defer client.deinit();
35
36 const session = try createSession(&client, allocator, handle, password);
37 defer {
38 allocator.free(session.did);
39 allocator.free(session.access_token);
40 }
41
42 std.debug.print("authenticated as {s}\n", .{session.did});
43 client.setAuth(session.access_token);
44
45 // generate TID for publication (fixed timestamp for deterministic rkey)
46 // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds)
47 const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0);
48 const pub_record = Publication{
49 .url = "https://zat.dev",
50 .name = "zat",
51 .description = "AT Protocol building blocks for zig",
52 };
53
54 try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record);
55 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() });
56
57 var pub_uri_buf: std.ArrayList(u8) = .empty;
58 defer pub_uri_buf.deinit(allocator);
59 try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() });
60 const pub_uri = pub_uri_buf.items;
61
62 // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id)
63 const now = timestamp();
64
65 for (docs, 0..) |doc, i| {
66 const content = std.fs.cwd().readFileAlloc(allocator, doc.file, 1024 * 1024) catch |err| {
67 std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err });
68 continue;
69 };
70 defer allocator.free(content);
71
72 const title = extractTitle(content) orelse doc.file;
73 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3...
74
75 const doc_record = Document{
76 .site = pub_uri,
77 .title = title,
78 .path = doc.path,
79 .textContent = content,
80 .publishedAt = &now,
81 };
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)
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 };
94
95 try putRecord(&client, allocator, session.did, "site.standard.publication", devlog_tid.str(), devlog_pub);
96 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, devlog_tid.str() });
97
98 var devlog_uri_buf: std.ArrayList(u8) = .empty;
99 defer devlog_uri_buf.deinit(allocator);
100 try devlog_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, devlog_tid.str() });
101 const devlog_uri = devlog_uri_buf.items;
102
103 // publish devlog entries (clock_id 101, 102, ...)
104 for (devlog, 0..) |entry, i| {
105 const content = std.fs.cwd().readFileAlloc(allocator, entry.file, 1024 * 1024) catch |err| {
106 std.debug.print("warning: could not read {s}: {}\n", .{ entry.file, err });
107 continue;
108 };
109 defer allocator.free(content);
110
111 const title = extractTitle(content) orelse entry.file;
112 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(101 + i));
113
114 const doc_record = Document{
115 .site = devlog_uri,
116 .title = title,
117 .path = entry.path,
118 .textContent = content,
119 .publishedAt = &now,
120 };
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", .{});
127}
128
129const Publication = struct {
130 @"$type": []const u8 = "site.standard.publication",
131 url: []const u8,
132 name: []const u8,
133 description: ?[]const u8 = null,
134};
135
136const Document = struct {
137 @"$type": []const u8 = "site.standard.document",
138 site: []const u8,
139 title: []const u8,
140 path: ?[]const u8 = null,
141 textContent: ?[]const u8 = null,
142 publishedAt: []const u8,
143};
144
145const Session = struct {
146 did: []const u8,
147 access_token: []const u8,
148};
149
150fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session {
151 const CreateSessionInput = struct {
152 identifier: []const u8,
153 password: []const u8,
154 };
155
156 var buf: std.ArrayList(u8) = .empty;
157 defer buf.deinit(allocator);
158 try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{
159 .identifier = handle,
160 .password = password,
161 }, .{})});
162
163 const nsid = zat.Nsid.parse("com.atproto.server.createSession").?;
164 var response = try client.procedure(nsid, buf.items);
165 defer response.deinit();
166
167 if (!response.ok()) {
168 std.debug.print("createSession failed: {s}\n", .{response.body});
169 return error.AuthFailed;
170 }
171
172 var parsed = try response.json();
173 defer parsed.deinit();
174
175 const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid;
176 const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken;
177
178 return .{
179 .did = try allocator.dupe(u8, did),
180 .access_token = try allocator.dupe(u8, token),
181 };
182}
183
184fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void {
185 // serialize record to json
186 var record_buf: std.ArrayList(u8) = .empty;
187 defer record_buf.deinit(allocator);
188 try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})});
189
190 // build request body
191 var body: std.ArrayList(u8) = .empty;
192 defer body.deinit(allocator);
193
194 try body.appendSlice(allocator, "{\"repo\":\"");
195 try body.appendSlice(allocator, repo);
196 try body.appendSlice(allocator, "\",\"collection\":\"");
197 try body.appendSlice(allocator, collection);
198 try body.appendSlice(allocator, "\",\"rkey\":\"");
199 try body.appendSlice(allocator, rkey);
200 try body.appendSlice(allocator, "\",\"record\":");
201 try body.appendSlice(allocator, record_buf.items);
202 try body.append(allocator, '}');
203
204 const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?;
205 var response = try client.procedure(nsid, body.items);
206 defer response.deinit();
207
208 if (!response.ok()) {
209 std.debug.print("putRecord failed: {s}\n", .{response.body});
210 return error.PutFailed;
211 }
212}
213
214fn extractTitle(content: []const u8) ?[]const u8 {
215 var lines = std.mem.splitScalar(u8, content, '\n');
216 while (lines.next()) |line| {
217 const trimmed = std.mem.trim(u8, line, " \t\r");
218 if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') {
219 var title = trimmed[2..];
220 // strip markdown link: [text](url) -> text
221 if (std.mem.indexOf(u8, title, "](")) |bracket| {
222 if (title[0] == '[') {
223 title = title[1..bracket];
224 }
225 }
226 return title;
227 }
228 }
229 return null;
230}
231
232fn timestamp() [20]u8 {
233 const epoch_seconds = std.time.timestamp();
234 const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day));
235 const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day));
236
237 // calculate year/month/day from days since epoch (1970-01-01)
238 var y: i32 = 1970;
239 var remaining = days;
240 while (true) {
241 const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365;
242 if (remaining < year_days) break;
243 remaining -= year_days;
244 y += 1;
245 }
246
247 const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0);
248 const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
249 var m: usize = 0;
250 while (m < 12 and remaining >= month_days[m]) : (m += 1) {
251 remaining -= month_days[m];
252 }
253
254 const hours = day_secs / 3600;
255 const mins = (day_secs % 3600) / 60;
256 const secs = day_secs % 60;
257
258 var buf: [20]u8 = undefined;
259 _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
260 @as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs,
261 }) catch unreachable;
262 return buf;
263}