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.

Add native rendering of documents

If a document is using org.wordpress.html, like the ones we create, we can render them in-app. Haven't put much effort into the design, but it works!

Johanna Larsson (Jul 5, 2026, 4:04 PM +0100) 59ea5acc 142a12de

+161 -3
+4
lib/annot_at/atproto.ex
··· 14 14 def inspect_url(aturi) do 15 15 @inspect_site <> aturi 16 16 end 17 + 18 + def blob_url(pds_host, did, %{"ref" => %{"$link" => cid}}) do 19 + "#{pds_host}/xrpc/com.atproto.sync.getBlob?did=#{did}&cid=#{cid}" 20 + end 17 21 end
+12
lib/annot_at/atproto/standard_site.ex
··· 27 27 end 28 28 end 29 29 30 + def get_document(user_id, rkey) do 31 + with {:ok, user} <- fetch_user(user_id), 32 + {:ok, %{"value" => value}} <- 33 + Client.query(user.id, "com.atproto.repo.getRecord", 34 + repo: user.did, 35 + collection: @document, 36 + rkey: rkey 37 + ) do 38 + {:ok, value} 39 + end 40 + end 41 + 30 42 @doc """ 31 43 Creates or updates a document record. 32 44 """
+4
lib/annot_at/publishing.ex
··· 93 93 Repo.all(from p in Post, where: p.site_id == ^site_id, order_by: [desc: p.inserted_at]) 94 94 end 95 95 96 + def get_post!(%Site{id: site_id}, id) do 97 + Repo.get_by!(Post, id: id, site_id: site_id) 98 + end 99 + 96 100 def create_post(%Site{id: site_id}, attrs) do 97 101 %Post{site_id: site_id} 98 102 |> Post.changeset(attrs)
+69
lib/annot_at_web/components/document_components.ex
··· 1 + defmodule AnnotAtWeb.DocumentComponents do 2 + @moduledoc """ 3 + Renders a `site.standard.document` record, reading mode. 4 + """ 5 + 6 + use Phoenix.Component 7 + import Phoenix.HTML, only: [raw: 1] 8 + 9 + attr :doc, :map, required: true 10 + attr :author, :map, required: true 11 + attr :cover_url, :string, default: nil 12 + 13 + def document(assigns) do 14 + ~H""" 15 + <article class="mx-auto max-w-2xl"> 16 + <img 17 + :if={@cover_url} 18 + src={@cover_url} 19 + alt={@doc["title"]} 20 + class="aspect-[1.91/1] w-full rounded-2dxl border-2 border-ink object-cover" 21 + /> 22 + 23 + <h1 class="mt-6 font-display text-4xl font-bold tracking-tight">{@doc["title"]}</h1> 24 + <div class="mt-3 flex items-center gap-2.5 text-sm text-ink/55"> 25 + <img 26 + :if={@author.avatar_url} 27 + src={@author.avatar_url} 28 + alt={@author.display_name || @author.handle} 29 + class="size-6 rounded-full border-2 border-ink" 30 + /> 31 + <span class="font-bold text-ink/75">{@author.display_name || @author.handle}</span> 32 + <span aria-hidden="true">-</span> 33 + <time :if={@doc["published_at"]}>{format_date(@doc["published_at"])}</time> 34 + </div> 35 + 36 + <div :if={@doc["tags"] not in [nil, []]} class="mt-4 flex flex-wrap gap-2"> 37 + <span 38 + :for={tag <- @doc["tags"]} 39 + class="rounded-full border-2 border-ink px-2.5 py-0.5 text-xs font-bold" 40 + > 41 + {tag} 42 + </span> 43 + </div> 44 + 45 + <div class="mt-8 space-y-4 leading-relaxed text-ink/85"> 46 + <%= case @doc["content"] do %> 47 + <% %{"$type" => "org.wordpress.html", "html" => html} -> %> 48 + {raw(sanitize(html))} 49 + <% _ -> %> 50 + <p>{preview(@doc["textContent"])}</p> 51 + <% end %> 52 + </div> 53 + </article> 54 + """ 55 + end 56 + 57 + defp sanitize(html), do: HtmlSanitizeEx.markdown_html(html) 58 + 59 + defp preview(nil), do: nil 60 + defp preview(text) when byte_size(text) <= 280, do: text 61 + defp preview(text), do: String.slice(text, 0, 280) <> "..." 62 + 63 + defp format_date(iso) do 64 + case DateTime.from_iso8601(iso) do 65 + {:ok, dt, _} -> Calendar.strftime(dt, "%B %-d, %Y") 66 + _ -> iso 67 + end 68 + end 69 + end
+60
lib/annot_at_web/controllers/live/post_live.ex
··· 1 + defmodule AnnotAtWeb.PostLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + import AnnotAtWeb.DocumentComponents 5 + 6 + alias AnnotAt.Atproto 7 + alias AnnotAt.Atproto.StandardSite 8 + alias AnnotAt.Publishing 9 + 10 + require Logger 11 + 12 + def render(assigns) do 13 + ~H""" 14 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:sites}> 15 + <.link navigate={~p"/sites/#{@site.id}/posts"} class="text-sm text-ink/60 hover:text-ink"> 16 + ← Posts 17 + </.link> 18 + 19 + <div class="mt-6"> 20 + <.document 21 + doc={@doc} 22 + author={author(@current_scope.user)} 23 + cover_url={cover_url(@current_scope.user, @doc)} 24 + /> 25 + </div> 26 + </Layouts.dashboard> 27 + """ 28 + end 29 + 30 + def mount(%{"id" => site_id, "post_id" => post_id}, _session, socket) do 31 + scope = socket.assigns.current_scope 32 + site = Publishing.get_site!(scope, site_id) 33 + post = Publishing.get_post!(site, post_id) 34 + 35 + case StandardSite.get_document(scope.user.id, post.rkey) do 36 + {:ok, doc} -> 37 + {:ok, assign(socket, site: site, doc: doc, page_title: doc["title"])} 38 + 39 + {:error, error} -> 40 + Logger.warning("PostLive: unable to load post", reason: inspect(error)) 41 + 42 + socket = 43 + socket 44 + |> put_flash(:error, "Couldn't load that post") 45 + |> push_navigate(to: ~p"/sites/#{site_id}/posts") 46 + 47 + {:ok, socket} 48 + end 49 + end 50 + 51 + defp author(user) do 52 + %{display_name: user.display_name, handle: user.handle, avatar_url: user.avatar_url} 53 + end 54 + 55 + defp cover_url(user, %{"coverImage" => blob}) do 56 + Atproto.blob_url(user.pds_host, user.did, blob) 57 + end 58 + 59 + defp cover_url(_user, _doc), do: nil 60 + end
+7 -2
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 ··· 122 122 <.cover_thumb entry={entry} /> 123 123 124 124 <div class="min-w-0 flex-1"> 125 - <div class="truncate font-bold text-ink/70">{entry.title}</div> 125 + <.link 126 + navigate={~p"/sites/#{@site.id}/posts/#{entry_to_post(@posts, entry).id}"} 127 + class="block truncate font-bold text-ink/70 hover:text-ink" 128 + > 129 + {entry.title} 130 + </.link> 126 131 <div :if={entry.summary} class="mt-0.5 truncate text-sm text-ink/50"> 127 132 {entry.summary} 128 133 </div>
+1
lib/annot_at_web/router.ex
··· 40 40 live "/sites/new", SiteNewLive 41 41 live "/sites/:id", SiteLive 42 42 live "/sites/:id/posts", PostsLive 43 + live "/sites/:id/posts/:post_id", PostLive 43 44 end 44 45 end 45 46
+2 -1
mix.exs
··· 72 72 {:cloak, "~> 1.1"}, 73 73 {:cloak_ecto, "~> 1.3"}, 74 74 {:saxy, "~> 1.6"}, 75 - {:date_time_parser, "~> 1.3"} 75 + {:date_time_parser, "~> 1.3"}, 76 + {:html_sanitize_ex, "~> 1.4"} 76 77 ] 77 78 end 78 79
+2
mix.lock
··· 21 21 "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, 22 22 "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, 23 23 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 24 + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.2", "2b7d55ed9879e04f021dd7d476ff5b35249ce9f4935e76db9761e1979ced50cc", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "807dc922a7ce3c388dd888e22550e25600e10a4fcb4433c291240ea17b18d1ad"}, 24 25 "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, 25 26 "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, 26 27 "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, ··· 29 30 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 30 31 "mimic": {:hex, :mimic, "2.3.0", "88b1d13c285e57df6ea57204317bb56e49e7329668006cdcb80a9aafc73a9616", [:mix], [{:ham, "~> 0.3", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "52771f23689398c5d41c7d05e91c2c28e10df273b784f40ca8b02e35e46850d3"}, 31 32 "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, 33 + "mochiweb": {:hex, :mochiweb, "3.4.0", "fa743f14ba09c431a7f4ef85f4bbb8563bd2d45ddb3c1572fd8c6dee8651472a", [:rebar3], [], "hexpm", "f07586c67e5d6a76120aebccb86bf123b8d153b2744d3a6acdd7e91853535674"}, 32 34 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 33 35 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 34 36 "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},