+15
.tangled/workflows/publish-docs.yml
+15
.tangled/workflows/publish-docs.yml
+12
build.zig
+12
build.zig
···
15
15
16
16
const test_step = b.step("test", "run unit tests");
17
17
test_step.dependOn(&run_tests.step);
18
+
19
+
// publish-docs script (uses zat to publish docs to ATProto)
20
+
const publish_docs = b.addExecutable(.{
21
+
.name = "publish-docs",
22
+
.root_module = b.createModule(.{
23
+
.root_source_file = b.path("scripts/publish-docs.zig"),
24
+
.target = target,
25
+
.optimize = optimize,
26
+
.imports = &.{.{ .name = "zat", .module = mod }},
27
+
}),
28
+
});
29
+
b.installArtifact(publish_docs);
18
30
}
+218
scripts/publish-docs.zig
+218
scripts/publish-docs.zig
···
1
+
const std = @import("std");
2
+
const zat = @import("zat");
3
+
4
+
const Allocator = std.mem.Allocator;
5
+
6
+
/// docs to publish as site.standard.document records
7
+
const docs = [_]struct { path: []const u8, file: []const u8 }{
8
+
.{ .path = "/", .file = "README.md" },
9
+
.{ .path = "/roadmap", .file = "docs/roadmap.md" },
10
+
.{ .path = "/changelog", .file = "CHANGELOG.md" },
11
+
};
12
+
13
+
pub fn main() !void {
14
+
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
15
+
defer _ = gpa.deinit();
16
+
const allocator = gpa.allocator();
17
+
18
+
const handle = "zat.dev";
19
+
20
+
const password = std.posix.getenv("ATPROTO_PASSWORD") orelse {
21
+
std.debug.print("error: ATPROTO_PASSWORD not set\n", .{});
22
+
return error.MissingEnv;
23
+
};
24
+
25
+
const pds = std.posix.getenv("ATPROTO_PDS") orelse "https://bsky.social";
26
+
27
+
var client = zat.XrpcClient.init(allocator, pds);
28
+
defer client.deinit();
29
+
30
+
const session = try createSession(&client, allocator, handle, password);
31
+
defer {
32
+
allocator.free(session.did);
33
+
allocator.free(session.access_token);
34
+
}
35
+
36
+
std.debug.print("authenticated as {s}\n", .{session.did});
37
+
client.setAuth(session.access_token);
38
+
39
+
// generate TID for publication (fixed timestamp for deterministic rkey)
40
+
// using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds)
41
+
const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0);
42
+
const pub_record = Publication{
43
+
.url = "https://zat.dev",
44
+
.name = "zat",
45
+
.description = "AT Protocol building blocks for zig",
46
+
};
47
+
48
+
try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record);
49
+
std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() });
50
+
51
+
var pub_uri_buf: std.ArrayList(u8) = .empty;
52
+
defer pub_uri_buf.deinit(allocator);
53
+
try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() });
54
+
const pub_uri = pub_uri_buf.items;
55
+
56
+
// publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id)
57
+
const now = timestamp();
58
+
59
+
for (docs, 0..) |doc, i| {
60
+
const content = std.fs.cwd().readFileAlloc(allocator, doc.file, 1024 * 1024) catch |err| {
61
+
std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err });
62
+
continue;
63
+
};
64
+
defer allocator.free(content);
65
+
66
+
const title = extractTitle(content) orelse doc.file;
67
+
const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3...
68
+
69
+
const doc_record = Document{
70
+
.site = pub_uri,
71
+
.title = title,
72
+
.path = doc.path,
73
+
.textContent = content,
74
+
.publishedAt = &now,
75
+
};
76
+
77
+
try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record);
78
+
std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() });
79
+
}
80
+
81
+
std.debug.print("done\n", .{});
82
+
}
83
+
84
+
const Publication = struct {
85
+
@"$type": []const u8 = "site.standard.publication",
86
+
url: []const u8,
87
+
name: []const u8,
88
+
description: ?[]const u8 = null,
89
+
};
90
+
91
+
const Document = struct {
92
+
@"$type": []const u8 = "site.standard.document",
93
+
site: []const u8,
94
+
title: []const u8,
95
+
path: ?[]const u8 = null,
96
+
textContent: ?[]const u8 = null,
97
+
publishedAt: []const u8,
98
+
};
99
+
100
+
const Session = struct {
101
+
did: []const u8,
102
+
access_token: []const u8,
103
+
};
104
+
105
+
fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session {
106
+
const CreateSessionInput = struct {
107
+
identifier: []const u8,
108
+
password: []const u8,
109
+
};
110
+
111
+
var buf: std.ArrayList(u8) = .empty;
112
+
defer buf.deinit(allocator);
113
+
try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{
114
+
.identifier = handle,
115
+
.password = password,
116
+
}, .{})});
117
+
118
+
const nsid = zat.Nsid.parse("com.atproto.server.createSession").?;
119
+
var response = try client.procedure(nsid, buf.items);
120
+
defer response.deinit();
121
+
122
+
if (!response.ok()) {
123
+
std.debug.print("createSession failed: {s}\n", .{response.body});
124
+
return error.AuthFailed;
125
+
}
126
+
127
+
var parsed = try response.json();
128
+
defer parsed.deinit();
129
+
130
+
const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid;
131
+
const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken;
132
+
133
+
return .{
134
+
.did = try allocator.dupe(u8, did),
135
+
.access_token = try allocator.dupe(u8, token),
136
+
};
137
+
}
138
+
139
+
fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void {
140
+
// serialize record to json
141
+
var record_buf: std.ArrayList(u8) = .empty;
142
+
defer record_buf.deinit(allocator);
143
+
try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})});
144
+
145
+
// build request body
146
+
var body: std.ArrayList(u8) = .empty;
147
+
defer body.deinit(allocator);
148
+
149
+
try body.appendSlice(allocator, "{\"repo\":\"");
150
+
try body.appendSlice(allocator, repo);
151
+
try body.appendSlice(allocator, "\",\"collection\":\"");
152
+
try body.appendSlice(allocator, collection);
153
+
try body.appendSlice(allocator, "\",\"rkey\":\"");
154
+
try body.appendSlice(allocator, rkey);
155
+
try body.appendSlice(allocator, "\",\"record\":");
156
+
try body.appendSlice(allocator, record_buf.items);
157
+
try body.append(allocator, '}');
158
+
159
+
const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?;
160
+
var response = try client.procedure(nsid, body.items);
161
+
defer response.deinit();
162
+
163
+
if (!response.ok()) {
164
+
std.debug.print("putRecord failed: {s}\n", .{response.body});
165
+
return error.PutFailed;
166
+
}
167
+
}
168
+
169
+
fn extractTitle(content: []const u8) ?[]const u8 {
170
+
var lines = std.mem.splitScalar(u8, content, '\n');
171
+
while (lines.next()) |line| {
172
+
const trimmed = std.mem.trim(u8, line, " \t\r");
173
+
if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') {
174
+
var title = trimmed[2..];
175
+
// strip markdown link: [text](url) -> text
176
+
if (std.mem.indexOf(u8, title, "](")) |bracket| {
177
+
if (title[0] == '[') {
178
+
title = title[1..bracket];
179
+
}
180
+
}
181
+
return title;
182
+
}
183
+
}
184
+
return null;
185
+
}
186
+
187
+
fn timestamp() [24]u8 {
188
+
const epoch_seconds = std.time.timestamp();
189
+
const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day));
190
+
const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day));
191
+
192
+
// calculate year/month/day from days since epoch (1970-01-01)
193
+
var y: i32 = 1970;
194
+
var remaining = days;
195
+
while (true) {
196
+
const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365;
197
+
if (remaining < year_days) break;
198
+
remaining -= year_days;
199
+
y += 1;
200
+
}
201
+
202
+
const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0);
203
+
const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
204
+
var m: usize = 0;
205
+
while (m < 12 and remaining >= month_days[m]) : (m += 1) {
206
+
remaining -= month_days[m];
207
+
}
208
+
209
+
const hours = day_secs / 3600;
210
+
const mins = (day_secs % 3600) / 60;
211
+
const secs = day_secs % 60;
212
+
213
+
var buf: [24]u8 = undefined;
214
+
_ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
215
+
@as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs,
216
+
}) catch unreachable;
217
+
return buf;
218
+
}
+5
-1
src/internal/xrpc.zig
+5
-1
src/internal/xrpc.zig
···
18
18
/// bearer token for authenticated requests
19
19
access_token: ?[]const u8 = null,
20
20
21
+
/// atproto JWTs are ~1KB; buffer needs room for "Bearer " prefix
22
+
const max_auth_header_len = 2048;
23
+
21
24
pub fn init(allocator: std.mem.Allocator, host: []const u8) XrpcClient {
22
25
return .{
23
26
.allocator = allocator,
···
89
92
// https://github.com/ziglang/zig/issues/25021
90
93
var extra_headers: std.http.Client.Request.Headers = .{
91
94
.accept_encoding = .{ .override = "identity" },
95
+
.content_type = if (body != null) .{ .override = "application/json" } else .default,
92
96
};
93
-
var auth_header_buf: [256]u8 = undefined;
97
+
var auth_header_buf: [max_auth_header_len]u8 = undefined;
94
98
if (self.access_token) |token| {
95
99
const auth_value = try std.fmt.bufPrint(&auth_header_buf, "Bearer {s}", .{token});
96
100
extra_headers.authorization = .{ .override = auth_value };