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.

Expose public document rendering

Mostly for fun, but since we're rendering docs in the app, we can render any doc from public routes too!

Will need to add rate limits and stuff won't I.

Johanna Larsson (Jul 5, 2026, 5:21 PM +0100) 2f6fccf0 59ea5acc

+166 -5
+8 -2
lib/annot_at/atproto/identity.ex
··· 54 54 with :ok <- validate_handle(handle), 55 55 {:ok, did} <- handle_to_did(handle), 56 56 :ok <- validate_did(did), 57 - {:ok, document} <- did_to_document(did), 58 - {:ok, parsed} <- DIDDocument.parse(document, did), 57 + {:ok, parsed} <- resolve_did(did), 59 58 :ok <- confirm_bidirectional(parsed, handle) do 60 59 {:ok, %__MODULE__{did: did, handle: handle, pds_endpoint: parsed.pds_endpoint}} 60 + end 61 + end 62 + 63 + @spec resolve_did(String.t()) :: {:ok, DIDDocument.t()} | {:error, term()} 64 + def resolve_did(did) when is_binary(did) do 65 + with {:ok, doc} <- did_to_document(did) do 66 + DIDDocument.parse(doc, did) 61 67 end 62 68 end 63 69
+33
lib/annot_at/atproto/standard_site.ex
··· 6 6 7 7 alias AnnotAt.Accounts 8 8 alias AnnotAt.Atproto.HTTP 9 + alias AnnotAt.Atproto.Identity 9 10 alias AnnotAt.Atproto.OAuth.Client 10 11 alias AnnotAt.Atproto.StandardSite.Document 11 12 alias AnnotAt.Atproto.StandardSite.Publication ··· 145 146 "at://#{did}/site.standard.document/#{rkey}" 146 147 end 147 148 149 + def get_public_document(did, rkey), do: get_public_record(did, @document, rkey) 150 + 151 + def get_public_publication("at://" <> _ = site_uri) do 152 + with {:ok, did, rkey} <- parse_aturi(site_uri, @publication) do 153 + get_public_record(did, @publication, rkey) 154 + end 155 + end 156 + 157 + def get_public_publication(_site), do: {:error, :no_publication} 158 + 159 + def get_public_record(did, collection, rkey) do 160 + with {:ok, did_doc} <- Identity.resolve_did(did), 161 + url = record_url(did_doc.pds_endpoint, did, collection, rkey), 162 + {:ok, %{"value" => value}} <- HTTP.get_json(url) do 163 + {:ok, value, did_doc} 164 + end 165 + end 166 + 148 167 defp fetch_user(user_id) do 149 168 case Accounts.get_user(user_id) do 150 169 nil -> {:error, :no_session} ··· 229 248 String.slice(text, 0, max) 230 249 end 231 250 end 251 + 252 + defp record_url(pds, did, collection, rkey) do 253 + query = URI.encode_query(repo: did, collection: collection, rkey: rkey) 254 + "#{pds}/xrpc/com.atproto.repo.getRecord?#{query}" 255 + end 256 + 257 + defp parse_aturi("at://" <> rest, collection) do 258 + case String.split(rest, "/") do 259 + [did, ^collection, rkey] -> {:ok, did, rkey} 260 + _ -> {:error, :invalid} 261 + end 262 + end 263 + 264 + defp parse_aturi(_uri, _collection), do: {:error, :invalid} 232 265 end
+2 -2
lib/annot_at_web/components/document_components.ex
··· 17 17 :if={@cover_url} 18 18 src={@cover_url} 19 19 alt={@doc["title"]} 20 - class="aspect-[1.91/1] w-full rounded-2dxl border-2 border-ink object-cover" 20 + class="aspect-[1.91/1] w-full rounded-2xl border-2 border-ink object-cover" 21 21 /> 22 22 23 23 <h1 class="mt-6 font-display text-4xl font-bold tracking-tight">{@doc["title"]}</h1> ··· 30 30 /> 31 31 <span class="font-bold text-ink/75">{@author.display_name || @author.handle}</span> 32 32 <span aria-hidden="true">-</span> 33 - <time :if={@doc["published_at"]}>{format_date(@doc["published_at"])}</time> 33 + <time :if={@doc["publishedAt"]}>{format_date(@doc["publishedAt"])}</time> 34 34 </div> 35 35 36 36 <div :if={@doc["tags"] not in [nil, []]} class="mt-4 flex flex-wrap gap-2">
+13
lib/annot_at_web/components/layouts.ex
··· 250 250 </div> 251 251 """ 252 252 end 253 + 254 + slot :inner_block, required: true 255 + 256 + def public(assigns) do 257 + ~H""" 258 + <div class="min-h-screen bg-paper text-ink antialiased"> 259 + <header class="border-b-2 border-ink px-6 py-4"> 260 + <.link navigate={~p"/"} class="font-display text-xl font-bold tracking-tight">annot.at</.link> 261 + </header> 262 + <main class="px-6 py-10">{render_slot(@inner_block)}</main> 263 + </div> 264 + """ 265 + end 253 266 end
+1 -1
lib/annot_at_web/controllers/live/posts_live.ex
··· 106 106 {if publishing?(@publishing, entry), do: "Publishing…", else: "Publish"} 107 107 </.button> 108 108 <span :if={not has_date?(entry)} class="flex-none text-xs text-ink/40">No 109 - date</span> 109 + date</span> 110 110 </div> 111 111 </div> 112 112
+45
lib/annot_at_web/controllers/public_post_controller.ex
··· 1 + defmodule AnnotAtWeb.PublicPostController do 2 + use AnnotAtWeb, :controller 3 + 4 + alias AnnotAt.Atproto 5 + alias AnnotAt.Atproto.StandardSite 6 + 7 + require Logger 8 + 9 + def show(conn, %{"did" => did, "rkey" => rkey}) do 10 + case StandardSite.get_public_document(did, rkey) do 11 + {:ok, doc, did_doc} -> 12 + render(conn, :show, 13 + doc: doc, 14 + author: author(doc, did_doc), 15 + cover_url: blob(did_doc.pds_endpoint, did, doc["coverImage"]), 16 + page_title: doc["title"] 17 + ) 18 + 19 + {:error, error} -> 20 + Logger.warning("PublicPost: failed to get public doc", reason: error) 21 + 22 + conn 23 + |> put_status(:not_found) 24 + |> put_view(html: AnnotAtWeb.ErrorHTML) 25 + |> render(:"404") 26 + end 27 + end 28 + 29 + defp author(doc, did_doc) do 30 + case StandardSite.get_public_publication(doc["site"]) do 31 + {:ok, pub, pub_doc} -> 32 + %{ 33 + avatar_url: blob(pub_doc.pds_endpoint, pub_doc.did, pub["icon"]), 34 + display_name: pub["name"], 35 + handle: did_doc.handle 36 + } 37 + 38 + {:error, _} -> 39 + %{display_name: nil, handle: did_doc.handle, avatar_url: nil} 40 + end 41 + end 42 + 43 + defp blob(_pds, _did, nil), do: nil 44 + defp blob(pds, did, blob), do: Atproto.blob_url(pds, did, blob) 45 + end
+6
lib/annot_at_web/controllers/public_post_html.ex
··· 1 + defmodule AnnotAtWeb.PublicPostHTML do 2 + use AnnotAtWeb, :html 3 + import AnnotAtWeb.DocumentComponents 4 + 5 + embed_templates "public_post_html/*" 6 + end
+3
lib/annot_at_web/controllers/public_post_html/show.html.heex
··· 1 + <Layouts.public> 2 + <.document doc={@doc} author={@author} cover_url={@cover_url} /> 3 + </Layouts.public>
+6
lib/annot_at_web/router.ex
··· 18 18 end 19 19 20 20 scope "/", AnnotAtWeb do 21 + pipe_through :browser 22 + 23 + get "/p/:did/:rkey", PublicPostController, :show 24 + end 25 + 26 + scope "/", AnnotAtWeb do 21 27 pipe_through [:browser, :redirect_if_user_is_authenticated] 22 28 23 29 get "/", PageController, :home
+49
test/annot_at_web/controllers/public_post_controller_test.exs
··· 1 + defmodule AnnotAtWeb.PublicPostControllerTest do 2 + use AnnotAtWeb.ConnCase, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.DIDDocument 6 + alias AnnotAt.Atproto.StandardSite 7 + 8 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 + @rkey "3mlhhbujc22gw" 10 + 11 + test "GET /p/:did/rkey renders the document", %{conn: conn} do 12 + did_doc = %DIDDocument{did: @did, handle: "jola.dev", pds_endpoint: "https://pds.example"} 13 + 14 + doc = %{ 15 + "title" => "Running local models on M4", 16 + "publishedAt" => "2026-03-23T12:00:00Z", 17 + "site" => "at://#{@did}/site.standard.publication/pub1", 18 + "tags" => ["ai"], 19 + "content" => %{ 20 + "$type" => "org.wordpress.html", 21 + "html" => "<p>Hello<strong>world</strong>.</p>" 22 + }, 23 + "textContent" => "Hello world." 24 + } 25 + 26 + pub = %{"name" => "jola.dev blog", "icon" => nil} 27 + 28 + expect(StandardSite, :get_public_document, fn @did, @rkey -> {:ok, doc, did_doc} end) 29 + expect(StandardSite, :get_public_publication, fn "at://" <> _ -> {:ok, pub, did_doc} end) 30 + 31 + html = 32 + conn 33 + |> get(~p"/p/#{@did}/#{@rkey}") 34 + |> html_response(200) 35 + 36 + assert html =~ "Running local models on M4" 37 + assert html =~ "<strong>world</strong>" 38 + assert html =~ "jola.dev blog" 39 + end 40 + 41 + test "GET /p/:did/:rkey 404s when the record can't be loaded", %{conn: conn} do 42 + expect(StandardSite, :get_public_document, fn 43 + @did, @rkey -> {:error, {:http_status, 404}} 44 + end) 45 + 46 + conn = get(conn, ~p"/p/#{@did}/#{@rkey}") 47 + assert response(conn, 404) 48 + end 49 + end