···11# changelog
2233-## 0.1.0
33+## 0.1.2
44+55+- `extractAt` now logs diagnostic info on parse failures (enable with `.zat` debug scope)
66+77+## 0.1.1
4855-first feature release. adds protocol-level enums for firehose consumption.
99+- xrpc client sets `Content-Type: application/json` for POST requests
1010+- docs published as `site.standard.document` records on tag releases
61177-### what's new
1212+## 0.1.0
81399-**sync types** - enums from `com.atproto.sync.subscribeRepos` lexicon:
1414+sync types for firehose consumption:
10151116- `CommitAction` - `.create`, `.update`, `.delete`
1217- `EventKind` - `.commit`, `.sync`, `.identity`, `.account`, `.info`
1318- `AccountStatus` - `.takendown`, `.suspended`, `.deleted`, `.deactivated`, `.desynchronized`, `.throttled`
14191515-these integrate with zig's `std.json` for automatic parsing. define struct fields as enums instead of strings, and get exhaustive switch checking.
1616-1717-### migration
1818-1919-if you're currently doing string comparisons:
2020-2121-```zig
2222-// before: string comparisons everywhere
2323-const TapRecord = struct {
2424- action: []const u8,
2525- collection: []const u8,
2626- // ...
2727-};
2828-2929-if (mem.eql(u8, rec.action, "create") or mem.eql(u8, rec.action, "update")) {
3030- // handle upsert
3131-} else if (mem.eql(u8, rec.action, "delete")) {
3232- // handle delete
3333-}
3434-```
3535-3636-switch to enum fields:
3737-3838-```zig
3939-// after: type-safe enums
4040-const TapRecord = struct {
4141- action: zat.CommitAction, // parsed automatically by std.json
4242- collection: []const u8,
4343- // ...
4444-};
4545-4646-switch (rec.action) {
4747- .create, .update => processUpsert(rec),
4848- .delete => processDelete(rec),
4949-}
5050-```
5151-5252-the compiler enforces exhaustive handling - if AT Protocol adds a new action, your code won't compile until you handle it.
5353-5454-**this is backwards compatible.** your existing code continues to work. adopt the new types when you're ready.
5555-5656-### library overview
5757-5858-zat provides zig primitives for AT Protocol:
5959-6060-| feature | description |
6161-|---------|-------------|
6262-| string primitives | `Tid`, `Did`, `Handle`, `Nsid`, `Rkey`, `AtUri` - parsing and validation |
6363-| did resolution | resolve `did:plc` and `did:web` to documents |
6464-| handle resolution | resolve handles to DIDs via HTTP well-known |
6565-| xrpc client | call AT Protocol endpoints (queries and procedures) |
6666-| sync types | enums for firehose consumption |
6767-| json helpers | navigate nested json without verbose if-chains |
6868-| jwt verification | verify service auth tokens (ES256, ES256K) |
6969-| multibase/multicodec | decode public keys from DID documents |
7070-7171-### install
7272-7373-```bash
7474-zig fetch --save https://tangled.sh/zzstoatzz.io/zat/archive/main
7575-```
7676-7777-```zig
7878-// build.zig
7979-const zat = b.dependency("zat", .{}).module("zat");
8080-exe.root_module.addImport("zat", zat);
8181-```
2020+these integrate with `std.json` for automatic parsing.
82218322## 0.0.2
8423···87268827## 0.0.1
89289090-- initial release
9129- string primitives (Tid, Did, Handle, Nsid, Rkey, AtUri)
9230- did/handle resolution
9331- json helpers
+15
CONTRIBUTING.md
···11+# contributing
22+33+## before committing
44+55+```sh
66+just fmt
77+```
88+99+or without just:
1010+1111+```sh
1212+zig fmt .
1313+```
1414+1515+CI runs `zig fmt --check .` and will fail on unformatted code.
+16-3
README.md
···11-# zat
11+# [zat](https://zat.dev)
22+33+AT Protocol building blocks for zig.
44+55+<details>
66+<summary><strong>this readme is an ATProto record</strong></summary>
77+88+โ [view in zat.dev's repository](https://at-me.zzstoatzz.io/view?handle=zat.dev)
2933-zig primitives for AT Protocol.
1010+zat publishes these docs as [`site.standard.document`](https://standard.site) records, signed by its DID.
1111+1212+</details>
413514## install
615716```bash
88-zig fetch --save https://tangled.sh/zzstoatzz.io/zat/archive/main
1717+zig fetch --save https://tangled.sh/zat.dev/zat/archive/main
918```
10191120then in `build.zig`:
···185194## license
186195187196MIT
197197+198198+---
199199+200200+[roadmap](docs/roadmap.md) ยท [changelog](CHANGELOG.md)
···11+# zat publishes its own docs to ATProto
22+33+zat uses itself to publish these docs as `site.standard.document` records. here's how.
44+55+## the idea
66+77+i'm working on [search for leaflet](https://leaflet-search.pages.dev/) and more generally, search for [standard.site](https://standard.site/) records. many are [currently thinking about how to facilitate better idea sharing on atproto right now](https://bsky.app/profile/eugenevinitsky.bsky.social/post/3mbpqpylv3s2e).
88+99+this is me doing a rep of shipping a "standard.site", so i know what i'll be searching through, and to better understand why blogging platforms choose their schema extensions etc for i start indexing/searching their record types.
1010+1111+## what we built
1212+1313+a zig script ([`scripts/publish-docs.zig`](https://tangled.sh/zat.dev/zat/tree/main/scripts/publish-docs.zig)) that:
1414+1515+1. authenticates with the PDS via `com.atproto.server.createSession`
1616+2. creates a `site.standard.publication` record
1717+3. publishes each doc as a `site.standard.document` pointing to that publication
1818+4. uses deterministic TIDs so records get the same rkey every time (idempotent updates)
1919+2020+## the mechanics
2121+2222+### TIDs
2323+2424+timestamp identifiers. base32-sortable. we use a fixed base timestamp with incrementing clock_id so each doc gets a stable rkey:
2525+2626+```zig
2727+const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0); // publication
2828+const doc_tid = zat.Tid.fromTimestamp(1704067200000000, i + 1); // docs get 1, 2, 3...
2929+```
3030+3131+### CI
3232+3333+[`.tangled/workflows/publish-docs.yml`](https://tangled.sh/zat.dev/zat/tree/main/.tangled/workflows/publish-docs.yml) triggers on `v*` tags. tag a release, docs publish automatically.
3434+3535+`putRecord` with the same rkey overwrites, so the CI job overwrites `standard.site` records when you cut a tag.
+102
docs/archive/plan-expanded.md
···11+# archived: expanded plan (partially implemented)
22+33+This file is preserved for context/history. Current direction lives in `docs/roadmap.md`.
44+55+# zat - expanded scope
66+77+the initial release delivered string primitives (Tid, Did, Handle, Nsid, Rkey, AtUri). this plan expands toward a usable AT Protocol sdk.
88+99+## motivation
1010+1111+real-world usage shows repeated implementations of:
1212+- DID resolution (plc.directory lookups, did:web fetches)
1313+- JWT parsing and signature verification
1414+- ECDSA verification (P256, secp256k1)
1515+- base58/base64url decoding
1616+- XRPC calls with manual json navigation
1717+1818+this is shared infrastructure across any atproto app. zat can absorb it incrementally.
1919+2020+## next: did resolution
2121+2222+```zig
2323+pub const DidResolver = struct {
2424+ /// resolve a did to its document
2525+ pub fn resolve(self: *DidResolver, did: Did) !DidDocument
2626+2727+ /// resolve did:plc via plc.directory
2828+ fn resolvePlc(self: *DidResolver, id: []const u8) !DidDocument
2929+3030+ /// resolve did:web via .well-known
3131+ fn resolveWeb(self: *DidResolver, domain: []const u8) !DidDocument
3232+};
3333+3434+pub const DidDocument = struct {
3535+ id: Did,
3636+ also_known_as: [][]const u8, // handles
3737+ verification_methods: []VerificationMethod,
3838+ services: []Service,
3939+4040+ pub fn pdsEndpoint(self: DidDocument) ?[]const u8
4141+ pub fn handle(self: DidDocument) ?[]const u8
4242+};
4343+```
4444+4545+## next: cid (content identifiers)
4646+4747+```zig
4848+pub const Cid = struct {
4949+ raw: []const u8,
5050+5151+ pub fn parse(s: []const u8) ?Cid
5252+ pub fn version(self: Cid) u8
5353+ pub fn codec(self: Cid) u64
5454+ pub fn hash(self: Cid) []const u8
5555+};
5656+```
5757+5858+## later: xrpc client
5959+6060+```zig
6161+pub const XrpcClient = struct {
6262+ pds: []const u8,
6363+ access_token: ?[]const u8,
6464+6565+ pub fn query(self: *XrpcClient, nsid: Nsid, params: anytype) !JsonValue
6666+ pub fn procedure(self: *XrpcClient, nsid: Nsid, input: anytype) !JsonValue
6767+};
6868+```
6969+7070+## later: jwt verification
7171+7272+```zig
7373+pub const Jwt = struct {
7474+ header: JwtHeader,
7575+ payload: JwtPayload,
7676+ signature: []const u8,
7777+7878+ pub fn parse(token: []const u8) ?Jwt
7979+ pub fn verify(self: Jwt, public_key: PublicKey) bool
8080+};
8181+```
8282+8383+## out of scope
8484+8585+- lexicon codegen (separate project)
8686+- session management / token refresh (app-specific)
8787+- jetstream client (websocket.zig + json is enough)
8888+- application frameworks (too opinionated)
8989+9090+## design principles
9191+9292+1. **layered** - each piece usable independently (use Did without DidResolver)
9393+2. **explicit** - no hidden allocations, pass allocators where needed
9494+3. **borrowing** - parse returns slices into input where possible
9595+4. **fallible** - return errors/optionals, don't panic
9696+5. **protocol-focused** - AT Protocol primitives, not app-specific features
9797+9898+## open questions
9999+100100+- should DidResolver cache? or leave that to caller?
101101+- should XrpcClient handle auth refresh? or just expose tokens?
102102+- how to handle json parsing without imposing a specific json library?
+192
docs/archive/plan-initial.md
···11+# archived: initial plan (out of date)
22+33+This file is preserved for context/history. Current direction lives in `docs/roadmap.md`.
44+55+# zat - zig atproto primitives
66+77+low-level building blocks for atproto applications in zig. not a full sdk - just the pieces that everyone reimplements.
88+99+## philosophy
1010+1111+from studying the wishlists: the pain is real, but the suggested solutions often over-engineer. we want:
1212+1313+1. **primitives, not frameworks** - types and parsers, not http clients or feed scaffolds
1414+2. **layered design** - each piece usable independently
1515+3. **zig idioms** - explicit buffers, comptime validation, no hidden allocations
1616+4. **minimal scope** - solve the repeated pain, not every possible need
1717+1818+## scope
1919+2020+### in scope (v0.1)
2121+2222+**tid** - timestamp identifiers
2323+- parse tid string to timestamp (microseconds)
2424+- generate tid from timestamp
2525+- extract clock id
2626+- comptime validation of format
2727+2828+**at-uri** - `at://did:plc:xyz/collection/rkey`
2929+- parse to components (did, collection, rkey)
3030+- construct from components
3131+- validation
3232+3333+**did** - decentralized identifiers
3434+- parse did:plc and did:web
3535+- validate format
3636+- type-safe wrapper (not just `[]const u8`)
3737+3838+### maybe v0.2
3939+4040+**facets** - extract links/mentions/tags from post records
4141+- given a json value with `text` and `facets`, extract urls
4242+- byte-offset handling for utf-8
4343+4444+**cid** - content identifiers
4545+- parse cid strings
4646+- validate format
4747+4848+### out of scope (for now)
4949+5050+- lexicon codegen (too big, could be its own project)
5151+- xrpc client (std.http.Client is fine)
5252+- session management (app-specific)
5353+- jetstream client (websocket.zig exists, just wire it)
5454+- feed generator framework (each feed is unique)
5555+- did resolution (requires http, out of primitive scope)
5656+5757+## design
5858+5959+### tid.zig
6060+6161+```zig
6262+pub const Tid = struct {
6363+ raw: [13]u8,
6464+6565+ /// parse a tid string. returns null if invalid.
6666+ pub fn parse(s: []const u8) ?Tid
6767+6868+ /// timestamp in microseconds since unix epoch
6969+ pub fn timestamp(self: Tid) u64
7070+7171+ /// clock identifier (lower 10 bits)
7272+ pub fn clockId(self: Tid) u10
7373+7474+ /// generate tid for current time
7575+ pub fn now() Tid
7676+7777+ /// generate tid for specific timestamp
7878+ pub fn fromTimestamp(ts: u64, clock_id: u10) Tid
7979+8080+ /// format to string
8181+ pub fn format(self: Tid, buf: *[13]u8) void
8282+};
8383+```
8484+8585+encoding: base32-sortable (chars `234567abcdefghijklmnopqrstuvwxyz`), 13 chars, first 11 encode 53-bit timestamp, last 2 encode 10-bit clock id.
8686+8787+### at_uri.zig
8888+8989+```zig
9090+pub const AtUri = struct {
9191+ /// the full uri string (borrowed, not owned)
9292+ raw: []const u8,
9393+9494+ /// offsets into raw for each component
9595+ did_end: usize,
9696+ collection_end: usize,
9797+9898+ pub fn parse(s: []const u8) ?AtUri
9999+100100+ pub fn did(self: AtUri) []const u8
101101+ pub fn collection(self: AtUri) []const u8
102102+ pub fn rkey(self: AtUri) []const u8
103103+104104+ /// construct a new uri. caller owns the buffer.
105105+ pub fn format(
106106+ buf: []u8,
107107+ did: []const u8,
108108+ collection: []const u8,
109109+ rkey: []const u8,
110110+ ) ?[]const u8
111111+};
112112+```
113113+114114+### did.zig
115115+116116+```zig
117117+pub const Did = union(enum) {
118118+ plc: [24]u8, // the identifier after "did:plc:"
119119+ web: []const u8, // the domain after "did:web:"
120120+121121+ pub fn parse(s: []const u8) ?Did
122122+123123+ /// format to string
124124+ pub fn format(self: Did, buf: []u8) ?[]const u8
125125+126126+ /// check if this is a plc did
127127+ pub fn isPlc(self: Did) bool
128128+};
129129+```
130130+131131+## structure
132132+133133+```
134134+zat/
135135+โโโ build.zig
136136+โโโ build.zig.zon
137137+โโโ src/
138138+โ โโโ root.zig # public API (stable exports)
139139+โ โโโ internal.zig # internal API (experimental)
140140+โ โโโ internal/
141141+โ โโโ tid.zig
142142+โ โโโ at_uri.zig
143143+โ โโโ did.zig
144144+โโโ docs/
145145+ โโโ plan.md
146146+```
147147+148148+## internal โ public promotion
149149+150150+new features start in `internal` where we can iterate freely. when an API stabilizes:
151151+152152+```zig
153153+// in root.zig, uncomment to promote:
154154+pub const Tid = internal.Tid;
155155+```
156156+157157+users who need bleeding-edge access can always use:
158158+159159+```zig
160160+const zat = @import("zat");
161161+const tid = zat.internal.Tid.parse("...");
162162+```
163163+164164+this pattern exists indefinitely - even after 1.0, new experimental features start in internal.
165165+166166+## decisions
167167+168168+### why not typed lexicons?
169169+170170+codegen from lexicon json is a big project on its own. the core pain (json navigation) can be partially addressed by documenting patterns, and the sdk should work regardless of how people parse json.
171171+172172+### why not an http client wrapper?
173173+174174+zig 0.15's `std.http.Client` with `Io.Writer.Allocating` works well. wrapping it doesn't add much value. the real pain is around auth token refresh and rate limiting - those are better solved at the application level where retry logic is domain-specific.
175175+176176+### why not websocket/jetstream?
177177+178178+websocket.zig already exists and works well. the jetstream protocol is simple json messages. a thin wrapper doesn't justify a dependency.
179179+180180+### borrowing vs owning
181181+182182+for parse operations, we borrow slices into the input rather than allocating. callers who need owned data can dupe. this matches zig's explicit memory style.
183183+184184+## next steps
185185+186186+1. ~~implement tid.zig with tests~~ done
187187+2. ~~implement at_uri.zig with tests~~ done
188188+3. ~~implement did.zig with tests~~ done
189189+4. ~~wire up build.zig as a module~~ done
190190+5. try using it in find-bufo or music-atmosphere-feed to validate the api
191191+6. iterate on internal APIs based on real usage
192192+7. promote stable APIs to root.zig
-98
docs/plan-expanded.md
···11-# zat - expanded scope
22-33-the initial release delivered string primitives (Tid, Did, Handle, Nsid, Rkey, AtUri). this plan expands toward a usable AT Protocol sdk.
44-55-## motivation
66-77-real-world usage shows repeated implementations of:
88-- DID resolution (plc.directory lookups, did:web fetches)
99-- JWT parsing and signature verification
1010-- ECDSA verification (P256, secp256k1)
1111-- base58/base64url decoding
1212-- XRPC calls with manual json navigation
1313-1414-this is shared infrastructure across any atproto app. zat can absorb it incrementally.
1515-1616-## next: did resolution
1717-1818-```zig
1919-pub const DidResolver = struct {
2020- /// resolve a did to its document
2121- pub fn resolve(self: *DidResolver, did: Did) !DidDocument
2222-2323- /// resolve did:plc via plc.directory
2424- fn resolvePlc(self: *DidResolver, id: []const u8) !DidDocument
2525-2626- /// resolve did:web via .well-known
2727- fn resolveWeb(self: *DidResolver, domain: []const u8) !DidDocument
2828-};
2929-3030-pub const DidDocument = struct {
3131- id: Did,
3232- also_known_as: [][]const u8, // handles
3333- verification_methods: []VerificationMethod,
3434- services: []Service,
3535-3636- pub fn pdsEndpoint(self: DidDocument) ?[]const u8
3737- pub fn handle(self: DidDocument) ?[]const u8
3838-};
3939-```
4040-4141-## next: cid (content identifiers)
4242-4343-```zig
4444-pub const Cid = struct {
4545- raw: []const u8,
4646-4747- pub fn parse(s: []const u8) ?Cid
4848- pub fn version(self: Cid) u8
4949- pub fn codec(self: Cid) u64
5050- pub fn hash(self: Cid) []const u8
5151-};
5252-```
5353-5454-## later: xrpc client
5555-5656-```zig
5757-pub const XrpcClient = struct {
5858- pds: []const u8,
5959- access_token: ?[]const u8,
6060-6161- pub fn query(self: *XrpcClient, nsid: Nsid, params: anytype) !JsonValue
6262- pub fn procedure(self: *XrpcClient, nsid: Nsid, input: anytype) !JsonValue
6363-};
6464-```
6565-6666-## later: jwt verification
6767-6868-```zig
6969-pub const Jwt = struct {
7070- header: JwtHeader,
7171- payload: JwtPayload,
7272- signature: []const u8,
7373-7474- pub fn parse(token: []const u8) ?Jwt
7575- pub fn verify(self: Jwt, public_key: PublicKey) bool
7676-};
7777-```
7878-7979-## out of scope
8080-8181-- lexicon codegen (separate project)
8282-- session management / token refresh (app-specific)
8383-- jetstream client (websocket.zig + json is enough)
8484-- application frameworks (too opinionated)
8585-8686-## design principles
8787-8888-1. **layered** - each piece usable independently (use Did without DidResolver)
8989-2. **explicit** - no hidden allocations, pass allocators where needed
9090-3. **borrowing** - parse returns slices into input where possible
9191-4. **fallible** - return errors/optionals, don't panic
9292-5. **protocol-focused** - AT Protocol primitives, not app-specific features
9393-9494-## open questions
9595-9696-- should DidResolver cache? or leave that to caller?
9797-- should XrpcClient handle auth refresh? or just expose tokens?
9898-- how to handle json parsing without imposing a specific json library?
-188
docs/plan-initial.md
···11-# zat - zig atproto primitives
22-33-low-level building blocks for atproto applications in zig. not a full sdk - just the pieces that everyone reimplements.
44-55-## philosophy
66-77-from studying the wishlists: the pain is real, but the suggested solutions often over-engineer. we want:
88-99-1. **primitives, not frameworks** - types and parsers, not http clients or feed scaffolds
1010-2. **layered design** - each piece usable independently
1111-3. **zig idioms** - explicit buffers, comptime validation, no hidden allocations
1212-4. **minimal scope** - solve the repeated pain, not every possible need
1313-1414-## scope
1515-1616-### in scope (v0.1)
1717-1818-**tid** - timestamp identifiers
1919-- parse tid string to timestamp (microseconds)
2020-- generate tid from timestamp
2121-- extract clock id
2222-- comptime validation of format
2323-2424-**at-uri** - `at://did:plc:xyz/collection/rkey`
2525-- parse to components (did, collection, rkey)
2626-- construct from components
2727-- validation
2828-2929-**did** - decentralized identifiers
3030-- parse did:plc and did:web
3131-- validate format
3232-- type-safe wrapper (not just `[]const u8`)
3333-3434-### maybe v0.2
3535-3636-**facets** - extract links/mentions/tags from post records
3737-- given a json value with `text` and `facets`, extract urls
3838-- byte-offset handling for utf-8
3939-4040-**cid** - content identifiers
4141-- parse cid strings
4242-- validate format
4343-4444-### out of scope (for now)
4545-4646-- lexicon codegen (too big, could be its own project)
4747-- xrpc client (std.http.Client is fine)
4848-- session management (app-specific)
4949-- jetstream client (websocket.zig exists, just wire it)
5050-- feed generator framework (each feed is unique)
5151-- did resolution (requires http, out of primitive scope)
5252-5353-## design
5454-5555-### tid.zig
5656-5757-```zig
5858-pub const Tid = struct {
5959- raw: [13]u8,
6060-6161- /// parse a tid string. returns null if invalid.
6262- pub fn parse(s: []const u8) ?Tid
6363-6464- /// timestamp in microseconds since unix epoch
6565- pub fn timestamp(self: Tid) u64
6666-6767- /// clock identifier (lower 10 bits)
6868- pub fn clockId(self: Tid) u10
6969-7070- /// generate tid for current time
7171- pub fn now() Tid
7272-7373- /// generate tid for specific timestamp
7474- pub fn fromTimestamp(ts: u64, clock_id: u10) Tid
7575-7676- /// format to string
7777- pub fn format(self: Tid, buf: *[13]u8) void
7878-};
7979-```
8080-8181-encoding: base32-sortable (chars `234567abcdefghijklmnopqrstuvwxyz`), 13 chars, first 11 encode 53-bit timestamp, last 2 encode 10-bit clock id.
8282-8383-### at_uri.zig
8484-8585-```zig
8686-pub const AtUri = struct {
8787- /// the full uri string (borrowed, not owned)
8888- raw: []const u8,
8989-9090- /// offsets into raw for each component
9191- did_end: usize,
9292- collection_end: usize,
9393-9494- pub fn parse(s: []const u8) ?AtUri
9595-9696- pub fn did(self: AtUri) []const u8
9797- pub fn collection(self: AtUri) []const u8
9898- pub fn rkey(self: AtUri) []const u8
9999-100100- /// construct a new uri. caller owns the buffer.
101101- pub fn format(
102102- buf: []u8,
103103- did: []const u8,
104104- collection: []const u8,
105105- rkey: []const u8,
106106- ) ?[]const u8
107107-};
108108-```
109109-110110-### did.zig
111111-112112-```zig
113113-pub const Did = union(enum) {
114114- plc: [24]u8, // the identifier after "did:plc:"
115115- web: []const u8, // the domain after "did:web:"
116116-117117- pub fn parse(s: []const u8) ?Did
118118-119119- /// format to string
120120- pub fn format(self: Did, buf: []u8) ?[]const u8
121121-122122- /// check if this is a plc did
123123- pub fn isPlc(self: Did) bool
124124-};
125125-```
126126-127127-## structure
128128-129129-```
130130-zat/
131131-โโโ build.zig
132132-โโโ build.zig.zon
133133-โโโ src/
134134-โ โโโ root.zig # public API (stable exports)
135135-โ โโโ internal.zig # internal API (experimental)
136136-โ โโโ internal/
137137-โ โโโ tid.zig
138138-โ โโโ at_uri.zig
139139-โ โโโ did.zig
140140-โโโ docs/
141141- โโโ plan.md
142142-```
143143-144144-## internal โ public promotion
145145-146146-new features start in `internal` where we can iterate freely. when an API stabilizes:
147147-148148-```zig
149149-// in root.zig, uncomment to promote:
150150-pub const Tid = internal.Tid;
151151-```
152152-153153-users who need bleeding-edge access can always use:
154154-155155-```zig
156156-const zat = @import("zat");
157157-const tid = zat.internal.Tid.parse("...");
158158-```
159159-160160-this pattern exists indefinitely - even after 1.0, new experimental features start in internal.
161161-162162-## decisions
163163-164164-### why not typed lexicons?
165165-166166-codegen from lexicon json is a big project on its own. the core pain (json navigation) can be partially addressed by documenting patterns, and the sdk should work regardless of how people parse json.
167167-168168-### why not an http client wrapper?
169169-170170-zig 0.15's `std.http.Client` with `Io.Writer.Allocating` works well. wrapping it doesn't add much value. the real pain is around auth token refresh and rate limiting - those are better solved at the application level where retry logic is domain-specific.
171171-172172-### why not websocket/jetstream?
173173-174174-websocket.zig already exists and works well. the jetstream protocol is simple json messages. a thin wrapper doesn't justify a dependency.
175175-176176-### borrowing vs owning
177177-178178-for parse operations, we borrow slices into the input rather than allocating. callers who need owned data can dupe. this matches zig's explicit memory style.
179179-180180-## next steps
181181-182182-1. ~~implement tid.zig with tests~~ done
183183-2. ~~implement at_uri.zig with tests~~ done
184184-3. ~~implement did.zig with tests~~ done
185185-4. ~~wire up build.zig as a module~~ done
186186-5. try using it in find-bufo or music-atmosphere-feed to validate the api
187187-6. iterate on internal APIs based on real usage
188188-7. promote stable APIs to root.zig
+40
docs/roadmap.md
···11+# roadmap
22+33+zat started as a small set of string primitives for AT Protocol - the types everyone reimplements (`Tid`, `Did`, `Handle`, `Nsid`, `Rkey`, `AtUri`). the scope grew based on real usage.
44+55+## history
66+77+**initial scope** - string primitives with parsing and validation. the philosophy: primitives not frameworks, layered design, zig idioms, minimal scope.
88+99+**what grew from usage:**
1010+- DID resolution was originally "out of scope" - real projects needed it, so `DidResolver` and `DidDocument` got added
1111+- XRPC client and JSON helpers - same story
1212+- JWT verification for service auth
1313+- handle resolution via HTTP well-known
1414+- handle resolution via DNS-over-HTTP (community contribution)
1515+- sync types for firehose consumption (`CommitAction`, `EventKind`, `AccountStatus`)
1616+1717+this pattern - start minimal, expand based on real pain - continues.
1818+1919+## now
2020+2121+use zat in real projects. let usage drive what's next.
2222+2323+the primitives are reasonably complete. what's missing will show up when people build things. until then, no speculative features.
2424+2525+## maybe later
2626+2727+these stay out of scope unless real demand emerges:
2828+2929+- lexicon codegen - probably a separate project
3030+- higher-level clients/frameworks - too opinionated
3131+- token refresh/session management - app-specific
3232+- feed generator scaffolding - each feed is unique
3333+3434+## non-goals
3535+3636+zat is not trying to be:
3737+3838+- a "one true SDK" that does everything
3939+- an opinionated app framework
4040+- a replacement for understanding the protocol
+17
justfile
···11+# zat
22+33+# show available commands
44+default:
55+ @just --list
66+77+# format code
88+fmt:
99+ zig fmt .
1010+1111+# check formatting (CI)
1212+check:
1313+ zig fmt --check .
1414+1515+# run tests
1616+test:
1717+ zig build test
+215
scripts/build-site.mjs
···11+import {
22+ readdir,
33+ readFile,
44+ mkdir,
55+ rm,
66+ cp,
77+ writeFile,
88+ access,
99+} from "node:fs/promises";
1010+import path from "node:path";
1111+import { execFile } from "node:child_process";
1212+import { promisify } from "node:util";
1313+1414+const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
1515+const docsDir = path.join(repoRoot, "docs");
1616+const devlogDir = path.join(repoRoot, "devlog");
1717+const siteSrcDir = path.join(repoRoot, "site");
1818+const outDir = path.join(repoRoot, "site-out");
1919+const outDocsDir = path.join(outDir, "docs");
2020+2121+const execFileAsync = promisify(execFile);
2222+2323+async function exists(filePath) {
2424+ try {
2525+ await access(filePath);
2626+ return true;
2727+ } catch {
2828+ return false;
2929+ }
3030+}
3131+3232+function isMarkdown(filePath) {
3333+ return filePath.toLowerCase().endsWith(".md");
3434+}
3535+3636+async function listMarkdownFiles(dir, prefix = "") {
3737+ const entries = await readdir(dir, { withFileTypes: true });
3838+ const out = [];
3939+ for (const e of entries) {
4040+ if (e.name.startsWith(".")) continue;
4141+ const rel = path.join(prefix, e.name);
4242+ const abs = path.join(dir, e.name);
4343+ if (e.isDirectory()) {
4444+ out.push(...(await listMarkdownFiles(abs, rel)));
4545+ } else if (e.isFile() && isMarkdown(e.name)) {
4646+ out.push(rel.replaceAll(path.sep, "/"));
4747+ }
4848+ }
4949+ return out.sort((a, b) => a.localeCompare(b));
5050+}
5151+5252+function titleFromMarkdown(md, fallback) {
5353+ const lines = md.split(/\r?\n/);
5454+ for (const line of lines) {
5555+ const m = /^#\s+(.+)\s*$/.exec(line);
5656+ if (m) return m[1].trim();
5757+ }
5858+ return fallback.replace(/\.md$/i, "");
5959+}
6060+6161+function normalizeTitle(title) {
6262+ let t = String(title || "").trim();
6363+ // Strip markdown links: [text](url) -> text
6464+ t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
6565+ // If pages follow a "zat - ..." style, drop the redundant prefix in the nav.
6666+ t = t.replace(/^zat\s*-\s*/i, "");
6767+ // Cheaply capitalize (keeps the rest as-authored).
6868+ if (t.length) t = t[0].toUpperCase() + t.slice(1);
6969+ return t;
7070+}
7171+7272+async function getBuildId() {
7373+ try {
7474+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
7575+ cwd: repoRoot,
7676+ });
7777+ const full = String(stdout || "").trim();
7878+ if (full) return full.slice(0, 12);
7979+ } catch {
8080+ // ignore
8181+ }
8282+ return String(Date.now());
8383+}
8484+8585+async function main() {
8686+ await rm(outDir, { recursive: true, force: true });
8787+ await mkdir(outDir, { recursive: true });
8888+8989+ // Copy static site shell
9090+ await cp(siteSrcDir, outDir, { recursive: true });
9191+9292+ // Cache-bust immutable assets on Wisp by appending a per-commit query string.
9393+ const buildId = await getBuildId();
9494+ const outIndex = path.join(outDir, "index.html");
9595+ if (await exists(outIndex)) {
9696+ let html = await readFile(outIndex, "utf8");
9797+ html = html.replaceAll('href="./style.css"', `href="./style.css?v=${buildId}"`);
9898+ html = html.replaceAll(
9999+ 'src="./vendor/marked.min.js"',
100100+ `src="./vendor/marked.min.js?v=${buildId}"`,
101101+ );
102102+ html = html.replaceAll(
103103+ 'src="./app.js"',
104104+ `src="./app.js?v=${buildId}"`,
105105+ );
106106+ html = html.replaceAll(
107107+ 'href="./favicon.svg"',
108108+ `href="./favicon.svg?v=${buildId}"`,
109109+ );
110110+ await writeFile(outIndex, html, "utf8");
111111+ }
112112+113113+ // Copy docs
114114+ await mkdir(outDocsDir, { recursive: true });
115115+116116+ const pages = [];
117117+118118+ // Prefer an explicit docs homepage if present; otherwise use repo README as index.
119119+ const docsIndex = path.join(docsDir, "index.md");
120120+ if (!(await exists(docsIndex))) {
121121+ const readme = path.join(repoRoot, "README.md");
122122+ if (await exists(readme)) {
123123+ let md = await readFile(readme, "utf8");
124124+ // Strip docs/ prefix from links since we're now inside the docs context.
125125+ md = md.replace(/\]\(docs\//g, "](");
126126+ await writeFile(path.join(outDocsDir, "index.md"), md, "utf8");
127127+ pages.push({
128128+ path: "index.md",
129129+ title: normalizeTitle(titleFromMarkdown(md, "index.md")),
130130+ });
131131+ }
132132+ }
133133+134134+ const changelog = path.join(repoRoot, "CHANGELOG.md");
135135+ const docsChangelog = path.join(docsDir, "changelog.md");
136136+ if ((await exists(changelog)) && !(await exists(docsChangelog))) {
137137+ const md = await readFile(changelog, "utf8");
138138+ await writeFile(path.join(outDocsDir, "changelog.md"), md, "utf8");
139139+ pages.push({
140140+ path: "changelog.md",
141141+ title: normalizeTitle(titleFromMarkdown(md, "changelog.md")),
142142+ });
143143+ }
144144+145145+ const mdFiles = (await exists(docsDir)) ? await listMarkdownFiles(docsDir) : [];
146146+147147+ // Copy all markdown under docs/ (including archives), but only include non-archive
148148+ // paths in the sidebar manifest.
149149+ for (const rel of mdFiles) {
150150+ const src = path.join(docsDir, rel);
151151+ const dst = path.join(outDocsDir, rel);
152152+ await mkdir(path.dirname(dst), { recursive: true });
153153+ await cp(src, dst);
154154+155155+ const md = await readFile(src, "utf8");
156156+ if (!rel.startsWith("archive/")) {
157157+ pages.push({ path: rel, title: normalizeTitle(titleFromMarkdown(md, rel)) });
158158+ }
159159+ }
160160+161161+ // Copy devlog files to docs/devlog/ and generate an index
162162+ const devlogFiles = (await exists(devlogDir)) ? await listMarkdownFiles(devlogDir) : [];
163163+ const devlogEntries = [];
164164+165165+ for (const rel of devlogFiles) {
166166+ const src = path.join(devlogDir, rel);
167167+ const dst = path.join(outDocsDir, "devlog", rel);
168168+ await mkdir(path.dirname(dst), { recursive: true });
169169+ await cp(src, dst);
170170+171171+ const md = await readFile(src, "utf8");
172172+ devlogEntries.push({
173173+ path: `devlog/${rel}`,
174174+ title: titleFromMarkdown(md, rel),
175175+ });
176176+ }
177177+178178+ // Generate devlog index listing all entries (newest first by filename)
179179+ if (devlogEntries.length > 0) {
180180+ devlogEntries.sort((a, b) => b.path.localeCompare(a.path));
181181+ const indexMd = [
182182+ "# devlog",
183183+ "",
184184+ ...devlogEntries.map((e) => `- [${e.title}](${e.path})`),
185185+ "",
186186+ ].join("\n");
187187+ await writeFile(path.join(outDocsDir, "devlog", "index.md"), indexMd, "utf8");
188188+ }
189189+190190+ // Stable nav order: README homepage, then roadmap, then changelog, then the rest.
191191+ pages.sort((a, b) => {
192192+ const order = (p) => {
193193+ if (p === "index.md") return 0;
194194+ if (p === "roadmap.md") return 1;
195195+ if (p === "changelog.md") return 2;
196196+ return 3;
197197+ };
198198+ const ao = order(a.path);
199199+ const bo = order(b.path);
200200+ if (ao !== bo) return ao - bo;
201201+ return a.title.localeCompare(b.title);
202202+ });
203203+204204+ await writeFile(
205205+ path.join(outDir, "manifest.json"),
206206+ JSON.stringify({ pages }, null, 2) + "\n",
207207+ "utf8",
208208+ );
209209+210210+ process.stdout.write(
211211+ `Built Wisp docs site: ${pages.length} markdown file(s) -> ${outDir}\n`,
212212+ );
213213+}
214214+215215+await main();