diff --git a/.tangled/workflows/publish-docs.yml b/.tangled/workflows/publish-docs.yml new file mode 100644 index 0000000..2b5c0d9 --- /dev/null +++ b/.tangled/workflows/publish-docs.yml @@ -0,0 +1,15 @@ +when: + - event: push + tag: "v*" + +engine: nixery + +dependencies: + nixpkgs: + - zig + +steps: + - name: build and publish docs to ATProto + command: | + zig build + ./zig-out/bin/publish-docs diff --git a/build.zig b/build.zig index 0c360b6..16d3419 100644 --- a/build.zig +++ b/build.zig @@ -15,4 +15,16 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "run unit tests"); test_step.dependOn(&run_tests.step); + + // publish-docs script (uses zat to publish docs to ATProto) + const publish_docs = b.addExecutable(.{ + .name = "publish-docs", + .root_module = b.createModule(.{ + .root_source_file = b.path("scripts/publish-docs.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "zat", .module = mod }}, + }), + }); + b.installArtifact(publish_docs); } diff --git a/scripts/publish-docs.zig b/scripts/publish-docs.zig new file mode 100644 index 0000000..033753f --- /dev/null +++ b/scripts/publish-docs.zig @@ -0,0 +1,217 @@ +const std = @import("std"); +const zat = @import("zat"); + +const Allocator = std.mem.Allocator; + +/// docs to publish as site.standard.document records +const docs = [_]struct { path: []const u8, file: []const u8 }{ + .{ .path = "/", .file = "README.md" }, + .{ .path = "/roadmap", .file = "docs/roadmap.md" }, + .{ .path = "/changelog", .file = "CHANGELOG.md" }, +}; + +pub fn main() !void { + // use page_allocator for CLI tool - OS reclaims on exit + const allocator = std.heap.page_allocator; + + const handle = "zat.dev"; + + const password = std.posix.getenv("ATPROTO_PASSWORD") orelse { + std.debug.print("error: ATPROTO_PASSWORD not set\n", .{}); + return error.MissingEnv; + }; + + const pds = std.posix.getenv("ATPROTO_PDS") orelse "https://bsky.social"; + + var client = zat.XrpcClient.init(allocator, pds); + defer client.deinit(); + + const session = try createSession(&client, allocator, handle, password); + defer { + allocator.free(session.did); + allocator.free(session.access_token); + } + + std.debug.print("authenticated as {s}\n", .{session.did}); + client.setAuth(session.access_token); + + // generate TID for publication (fixed timestamp for deterministic rkey) + // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds) + const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0); + const pub_record = Publication{ + .url = "https://zat.dev", + .name = "zat", + .description = "AT Protocol building blocks for zig", + }; + + try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record); + std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() }); + + var pub_uri_buf: std.ArrayList(u8) = .empty; + defer pub_uri_buf.deinit(allocator); + try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() }); + const pub_uri = pub_uri_buf.items; + + // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id) + const now = timestamp(); + + for (docs, 0..) |doc, i| { + const content = std.fs.cwd().readFileAlloc(allocator, doc.file, 1024 * 1024) catch |err| { + std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err }); + continue; + }; + defer allocator.free(content); + + const title = extractTitle(content) orelse doc.file; + const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3... + + const doc_record = Document{ + .site = pub_uri, + .title = title, + .path = doc.path, + .textContent = content, + .publishedAt = &now, + }; + + try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); + std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() }); + } + + std.debug.print("done\n", .{}); +} + +const Publication = struct { + @"$type": []const u8 = "site.standard.publication", + url: []const u8, + name: []const u8, + description: ?[]const u8 = null, +}; + +const Document = struct { + @"$type": []const u8 = "site.standard.document", + site: []const u8, + title: []const u8, + path: ?[]const u8 = null, + textContent: ?[]const u8 = null, + publishedAt: []const u8, +}; + +const Session = struct { + did: []const u8, + access_token: []const u8, +}; + +fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session { + const CreateSessionInput = struct { + identifier: []const u8, + password: []const u8, + }; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(allocator); + try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{ + .identifier = handle, + .password = password, + }, .{})}); + + const nsid = zat.Nsid.parse("com.atproto.server.createSession").?; + var response = try client.procedure(nsid, buf.items); + defer response.deinit(); + + if (!response.ok()) { + std.debug.print("createSession failed: {s}\n", .{response.body}); + return error.AuthFailed; + } + + var parsed = try response.json(); + defer parsed.deinit(); + + const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid; + const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken; + + return .{ + .did = try allocator.dupe(u8, did), + .access_token = try allocator.dupe(u8, token), + }; +} + +fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void { + // serialize record to json + var record_buf: std.ArrayList(u8) = .empty; + defer record_buf.deinit(allocator); + try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})}); + + // build request body + var body: std.ArrayList(u8) = .empty; + defer body.deinit(allocator); + + try body.appendSlice(allocator, "{\"repo\":\""); + try body.appendSlice(allocator, repo); + try body.appendSlice(allocator, "\",\"collection\":\""); + try body.appendSlice(allocator, collection); + try body.appendSlice(allocator, "\",\"rkey\":\""); + try body.appendSlice(allocator, rkey); + try body.appendSlice(allocator, "\",\"record\":"); + try body.appendSlice(allocator, record_buf.items); + try body.append(allocator, '}'); + + const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?; + var response = try client.procedure(nsid, body.items); + defer response.deinit(); + + if (!response.ok()) { + std.debug.print("putRecord failed: {s}\n", .{response.body}); + return error.PutFailed; + } +} + +fn extractTitle(content: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') { + var title = trimmed[2..]; + // strip markdown link: [text](url) -> text + if (std.mem.indexOf(u8, title, "](")) |bracket| { + if (title[0] == '[') { + title = title[1..bracket]; + } + } + return title; + } + } + return null; +} + +fn timestamp() [24]u8 { + const epoch_seconds = std.time.timestamp(); + const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day)); + const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day)); + + // calculate year/month/day from days since epoch (1970-01-01) + var y: i32 = 1970; + var remaining = days; + while (true) { + const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365; + if (remaining < year_days) break; + remaining -= year_days; + y += 1; + } + + const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0); + const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + var m: usize = 0; + while (m < 12 and remaining >= month_days[m]) : (m += 1) { + remaining -= month_days[m]; + } + + const hours = day_secs / 3600; + const mins = (day_secs % 3600) / 60; + const secs = day_secs % 60; + + var buf: [24]u8 = undefined; + _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + @as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs, + }) catch unreachable; + return buf; +} diff --git a/src/internal/xrpc.zig b/src/internal/xrpc.zig index b3dd218..1aa169a 100644 --- a/src/internal/xrpc.zig +++ b/src/internal/xrpc.zig @@ -18,6 +18,9 @@ pub const XrpcClient = struct { /// bearer token for authenticated requests access_token: ?[]const u8 = null, + /// atproto JWTs are ~1KB; buffer needs room for "Bearer " prefix + const max_auth_header_len = 2048; + pub fn init(allocator: std.mem.Allocator, host: []const u8) XrpcClient { return .{ .allocator = allocator, @@ -89,8 +92,9 @@ pub const XrpcClient = struct { // https://github.com/ziglang/zig/issues/25021 var extra_headers: std.http.Client.Request.Headers = .{ .accept_encoding = .{ .override = "identity" }, + .content_type = if (body != null) .{ .override = "application/json" } else .default, }; - var auth_header_buf: [256]u8 = undefined; + var auth_header_buf: [max_auth_header_len]u8 = undefined; if (self.access_token) |token| { const auth_value = try std.fmt.bufPrint(&auth_header_buf, "Bearer {s}", .{token}); extra_headers.authorization = .{ .override = auth_value };