annot.at is a service for syncing static websites, such as blogs, to atproto and standard.site annot.at
elixir atproto standardsite
3

Configure Feed

Select the types of activity you want to include in your feed.

Look up existing post link rel="site.standard.document" hrefs

So turns out document rkeys having to be TIDs is really annoying for us. Because the blog owner has to add `<link rel="site.standard.document" href="<aturi>" /> with the correct aturi in it, and it contains the rkey, and the rkey has to be a TID, it means either we generate it and the blog owner then has to copy paste and redeploy their blog, or we use a deterministic TID implementation based on eg post publish date, which means they have to use the exact same one. We just can't generate the rkey for the user.

It would be much easier if standard.site allowed other rkeys, so that you could eg use the RSS feed post guid. That would make it trivial, blog owner uses their guid (eg slug) and we do the same.

Of course, all this led to me realizing that the cleanest way to build this is to rely on the blow owner to set the link aturi and we just use that. So the blog owner generates a TID when they post their blog post, eg deterministically from publishing date, but we don't have to match the exact implementation of the algorithm, or they just get a random one and store it on their side. They can deploy once and we discover the aturi from the post.

Problem solved!

Johanna Larsson (Jun 27, 2026, 8:42 PM +0100) aae6ac66 79b5eebb

+114 -11
+10
lib/annot_at/atproto/standard_site.ex
··· 36 36 end 37 37 end 38 38 39 + def delete_document(user_id, rkey) do 40 + with {:ok, user} <- fetch_user(user_id) do 41 + Client.procedure(user.id, "com.atproto.repo.deleteRecord", %{ 42 + repo: user.did, 43 + collection: @document, 44 + rkey: rkey 45 + }) 46 + end 47 + end 48 + 39 49 @doc """ 40 50 The AT-URI of the user's publication record, used as a document's `site`. 41 51 """
+10
lib/annot_at/atproto/standard_site/document.ex
··· 27 27 tags: [String.t()] | nil, 28 28 cover_image: {binary(), String.t()} | nil 29 29 } 30 + 31 + def split_aturi(aturi) do 32 + case String.split(aturi, "/") do 33 + ["at:", "", did, "site.standard.document", rkey] -> 34 + {:ok, %{did: did, rkey: rkey}} 35 + 36 + _ -> 37 + {:error, :invalid} 38 + end 39 + end 30 40 end
+8
lib/annot_at/feeds.ex
··· 69 69 } 70 70 end 71 71 72 + def document_uri(html) do 73 + html 74 + |> LazyHTML.from_document() 75 + |> LazyHTML.query(~s(link[rel="site.standard.document"])) 76 + |> LazyHTML.attribute("href") 77 + |> List.first() 78 + end 79 + 72 80 defp source_from_attributes(attributes, base_url) do 73 81 attributes = Map.new(attributes) 74 82
+60
lib/annot_at/feeds/client.ex
··· 4 4 them to the dicovery/parsing. 5 5 """ 6 6 7 + alias AnnotAt.Atproto.StandardSite.Document 7 8 alias AnnotAt.Feeds 9 + alias AnnotAt.Feeds.Entry 8 10 alias AnnotAt.Feeds.Feed 9 11 alias AnnotAt.Feeds.Source 10 12 ··· 49 51 end 50 52 end 51 53 54 + @doc """ 55 + Fetches a page to extract the <link rel="site.standard.document"> href if it exists. 56 + """ 57 + @spec document_rkey(String.t(), String.t()) :: 58 + {:ok, String.t()} 59 + | {:error, 60 + :invalid 61 + | :not_declared 62 + | :did_mismatch 63 + | {:http_status, pos_integer()} 64 + | {:transport, term()}} 65 + def document_rkey(url, expected_did) do 66 + with {:ok, html} <- get(url), 67 + {:ok, uri} <- document_uri(html) do 68 + extract_rkey(uri, expected_did) 69 + end 70 + end 71 + 72 + def resolve_documents(%Feed{entries: entries} = feed, did) do 73 + entries = 74 + entries 75 + |> Task.async_stream(&put_rkey(&1, did), timeout: :infinity, max_concurrency: 4) 76 + |> Enum.map(fn {:ok, entry} -> entry end) 77 + 78 + %{feed | entries: entries} 79 + end 80 + 52 81 defp get(url) do 53 82 case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 54 83 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> ··· 59 88 60 89 {:error, reason} -> 61 90 {:error, {:transport, reason}} 91 + end 92 + end 93 + 94 + defp document_uri(html) do 95 + if uri = Feeds.document_uri(html) do 96 + {:ok, uri} 97 + else 98 + {:error, :not_declared} 99 + end 100 + end 101 + 102 + defp extract_rkey(uri, expected_did) do 103 + case Document.split_aturi(uri) do 104 + {:ok, %{rkey: rkey, did: ^expected_did}} -> 105 + {:ok, rkey} 106 + 107 + {:ok, _} -> 108 + {:error, :did_mismatch} 109 + 110 + {:error, _error} = error -> 111 + error 112 + end 113 + end 114 + 115 + defp put_rkey(%Entry{url: url} = entry, did) do 116 + case document_rkey(url, did) do 117 + {:ok, rkey} -> 118 + %{entry | rkey: rkey} 119 + 120 + {:error, _} -> 121 + entry 62 122 end 63 123 end 64 124 end
+3 -2
lib/annot_at/feeds/entry.ex
··· 6 6 different forms like RSS and atom. 7 7 """ 8 8 9 - defstruct [:id, :url, :title, :published_at, :summary, :content] 9 + defstruct [:id, :url, :title, :published_at, :summary, :content, :rkey] 10 10 11 11 @type t :: %__MODULE__{ 12 12 id: String.t() | nil, ··· 14 14 title: String.t() | nil, 15 15 published_at: DateTime.t() | nil, 16 16 summary: String.t() | nil, 17 - content: String.t() | nil 17 + content: String.t() | nil, 18 + rkey: String.t() | nil 18 19 } 19 20 20 21 def hash(%__MODULE__{} = entry) do
+10 -9
lib/annot_at_web/controllers/live/posts_live.ex
··· 3 3 4 4 alias AnnotAt.Atproto.StandardSite 5 5 alias AnnotAt.Atproto.StandardSite.Document 6 - alias AnnotAt.Atproto.TID 7 6 alias AnnotAt.Feeds.Client 8 7 alias AnnotAt.Feeds.Entry 9 8 alias AnnotAt.Publishing ··· 84 83 :if={has_date?(entry)} 85 84 variant="primary" 86 85 size="sm" 87 - disabled={publishing?(@publishing, entry)} 86 + disabled={is_nil(entry.rkey) or publishing?(@publishing, entry)} 88 87 phx-value-guid={entry.id} 89 88 phx-click={ 90 89 "select_post" ··· 257 256 to_publish = 258 257 entries 259 258 |> pending(posts) 260 - |> Enum.filter(&has_date?/1) 259 + |> Enum.filter(fn post -> has_date?(post) && post.rkey end) 261 260 262 261 socket = 263 262 socket ··· 310 309 311 310 defp load_feed(socket, site) do 312 311 if connected?(socket) do 312 + user_did = socket.assigns.current_scope.user.did 313 + 313 314 assign_async(socket, :feed, fn -> 314 315 with {:ok, feed} <- Client.load(site.feed_url) do 316 + feed = Client.resolve_documents(feed, user_did) 315 317 {:ok, %{feed: feed}} 316 318 end 317 319 end) ··· 320 322 end 321 323 end 322 324 323 - defp to_document(entry, site, user, rkey) do 325 + defp to_document(entry, site, user) do 324 326 %Document{ 325 - rkey: rkey, 327 + rkey: entry.rkey, 326 328 site: StandardSite.publication_uri(user.did, site.rkey), 327 329 title: entry.title, 328 330 path: path_of(entry.url), ··· 336 338 defp path_of(url), do: URI.parse(url).path 337 339 338 340 defp create_document(scope, site, entry) do 339 - rkey = TID.at_time(entry.published_at) 340 - document = to_document(entry, site, scope.user, rkey) 341 + document = to_document(entry, site, scope.user) 341 342 342 343 with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do 343 344 Publishing.create_post(site, %{ 344 345 guid: entry.id, 345 - rkey: rkey, 346 + rkey: document.rkey, 346 347 content_hash: Entry.hash(entry) 347 348 }) 348 349 end 349 350 end 350 351 351 352 defp update_document(scope, site, %Post{} = post, entry) do 352 - document = to_document(entry, site, scope.user, post.rkey) 353 + document = to_document(entry, site, scope.user) 353 354 354 355 with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do 355 356 Publishing.update_post(post, %{content_hash: Entry.hash(entry)})
+13
test/annot_at_web/live/posts_live_test.exs
··· 7 7 alias AnnotAt.Accounts 8 8 alias AnnotAt.Accounts.Scope 9 9 alias AnnotAt.Atproto.StandardSite 10 + alias AnnotAt.Atproto.TID 10 11 alias AnnotAt.Feeds.Client 11 12 alias AnnotAt.Feeds.Entry 12 13 alias AnnotAt.Feeds.Feed ··· 43 44 } 44 45 45 46 expect(Client, :load, fn _url -> {:ok, feed} end) 47 + 48 + expect(Client, :resolve_documents, fn _feed, _user_did -> 49 + %{entries: entries} = feed 50 + 51 + %{ 52 + feed 53 + | entries: 54 + Enum.map(entries, fn entry -> 55 + %{entry | rkey: TID.at_time(entry.published_at)} 56 + end) 57 + } 58 + end) 46 59 47 60 expect(StandardSite, :put_document, fn user_id, document -> 48 61 assert user_id == user.id