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.

Upload document cover image

With this commit we now grab the OG image on the post and reupload it as a blob on publish, attaching it to the document.

Verified against my own post. Cool.

Johanna Larsson (Jun 30, 2026, 9:42 PM +0100) 09e71ce3 4cf83135

+352 -50
+9
README.md
··· 6 6 7 7 Requires your site to have a `link rel=alternate` for a feed set, and that you're able to verify your ownership by serving a `/.well-known` file. 8 8 9 + Document rkeys are derived from the post `<link rel="site.standard.document" href="<aturi>"`, so your blog must set that up. Document rkeys have to be TIDs to be valid, and should be stable, so either generate them and store them in your blog post fronmatter/metadata, or use a deterministic implementation that can run multiple times for the same post and give the same result. 10 + 9 11 ## TODO 10 12 11 13 - [x] Sign up/sign in with Bluesky 12 14 - [x] Maybe some design? 13 15 - [x] Add site including verification 16 + - [ ] Write a guide for using this 17 + - [ ] Document cover images 18 + - [ ] Document content with html content type 14 19 - [ ] RSS poller 15 20 - [ ] standard.site document reader 16 21 - [ ] Post to Bluesky 22 + - [ ] Let atproto drive things more 23 + - [ ] List publications, including not explicitly added ones 24 + - [ ] List documents, including not explicitly added ones 25 + - [ ] List dangling documents/documents that no longer validate against post etc 17 26 18 27 ## Authenticating 19 28
+15
lib/annot_at/feeds.ex
··· 78 78 |> List.first() 79 79 end 80 80 81 + @spec image(binary(), String.t()) :: String.t() | nil 82 + def image(html, base_url) when is_binary(html) and is_binary(base_url) do 83 + doc = LazyHTML.from_document(html) 84 + 85 + content = 86 + meta_content(doc, ~s(meta[property="og:image"])) || 87 + meta_content(doc, ~s(meta[name="twitter:image"])) 88 + 89 + if content do 90 + base_url 91 + |> URI.merge(content) 92 + |> URI.to_string() 93 + end 94 + end 95 + 81 96 defp source_from_attributes(attributes, base_url) do 82 97 attributes = Map.new(attributes) 83 98
+103 -23
lib/annot_at/feeds/client.ex
··· 11 11 alias AnnotAt.Feeds.Source 12 12 13 13 @receive_timeout 10_000 14 + @cover_max_bytes 1_000_000 14 15 15 16 @doc """ 16 17 Fetches a page and returns the feeds discovered. ··· 51 52 end 52 53 end 53 54 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 55 def resolve_documents(%Feed{entries: entries} = feed, did) do 73 56 entries = 74 57 entries 75 - |> Task.async_stream(&put_rkey(&1, did), timeout: :infinity, max_concurrency: 4) 58 + |> Task.async_stream(&resolve_entry(&1, did), timeout: :infinity, max_concurrency: 4) 76 59 |> Enum.map(fn {:ok, entry} -> entry end) 77 60 78 61 %{feed | entries: entries} 79 62 end 80 63 64 + @spec fetch_image(String.t()) :: 65 + {:ok, {binary(), String.t()}} 66 + | {:error, 67 + :not_an_image | :too_large | {:http_status, pos_integer()} | {:transport, term()}} 68 + def fetch_image(url) do 69 + case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 70 + {:ok, %Req.Response{status: status, body: body, headers: headers}} 71 + when status in 200..299 -> 72 + type = content_type(headers) 73 + 74 + cond do 75 + is_nil(type) or not image_type?(type) -> {:error, :not_an_image} 76 + byte_size(body) > @cover_max_bytes -> {:error, :too_large} 77 + true -> {:ok, {body, type}} 78 + end 79 + 80 + {:ok, %Req.Response{status: status}} -> 81 + {:error, {:http_status, status}} 82 + 83 + {:error, reason} -> 84 + {:error, {:transport, reason}} 85 + end 86 + end 87 + 81 88 defp get(url) do 82 89 case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 83 90 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> ··· 112 119 end 113 120 end 114 121 115 - defp put_rkey(%Entry{url: url} = entry, did) do 116 - case document_rkey(url, did) do 117 - {:ok, rkey} -> 118 - %{entry | rkey: rkey} 122 + defp resolve_entry(%Entry{url: url} = entry, did) when is_binary(url) do 123 + case get(url) do 124 + {:ok, html} -> 125 + entry 126 + |> put_cover(html, url) 127 + |> put_rkey(html, did) 119 128 120 129 {:error, _} -> 121 130 entry 122 131 end 123 132 end 133 + 134 + defp resolve_entry(entry, _did), do: entry 135 + 136 + defp put_cover(entry, html, url) do 137 + image = Feeds.image(html, url) 138 + %{entry | image: image, cover_status: cover_status(image)} 139 + end 140 + 141 + defp put_rkey(entry, html, did) do 142 + case resolve_rkey(html, did) do 143 + {:ok, rkey} -> %{entry | rkey: rkey} 144 + {:error, _} -> entry 145 + end 146 + end 147 + 148 + defp resolve_rkey(html, did) do 149 + with {:ok, uri} <- document_uri(html) do 150 + extract_rkey(uri, did) 151 + end 152 + end 153 + 154 + defp cover_status(nil), do: :none 155 + 156 + defp cover_status(url) do 157 + case Req.request(url: url, method: :head, receive_timeout: @receive_timeout) do 158 + {:ok, %Req.Response{status: status, headers: headers}} when status in 200..299 -> 159 + classify(content_type(headers), content_length(headers)) 160 + 161 + _ -> 162 + :unknown 163 + end 164 + end 165 + 166 + defp classify(nil, _length), do: :unknown 167 + 168 + defp classify(type, length) do 169 + cond do 170 + not image_type?(type) -> :not_image 171 + is_nil(length) -> :unknown 172 + length > @cover_max_bytes -> :too_large 173 + true -> :ok 174 + end 175 + end 176 + 177 + defp content_type(headers) do 178 + case headers["content-type"] do 179 + [value | _] -> 180 + value 181 + |> String.split(";") 182 + |> List.first() 183 + |> String.trim() 184 + 185 + _ -> 186 + nil 187 + end 188 + end 189 + 190 + defp content_length(headers) do 191 + case headers["content-length"] do 192 + [value | _] -> 193 + case Integer.parse(value) do 194 + {bytes, _} -> bytes 195 + :error -> nil 196 + end 197 + 198 + _ -> 199 + nil 200 + end 201 + end 202 + 203 + defp image_type?(type), do: String.starts_with?(type, "image/") 124 204 end
+16 -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, :rkey] 9 + defstruct [ 10 + :id, 11 + :url, 12 + :title, 13 + :published_at, 14 + :summary, 15 + :content, 16 + :rkey, 17 + :image, 18 + cover_status: :none 19 + ] 20 + 21 + @type cover_status :: :ok | :too_large | :not_image | :unknown | :none 10 22 11 23 @type t :: %__MODULE__{ 12 24 id: String.t() | nil, ··· 15 27 published_at: DateTime.t() | nil, 16 28 summary: String.t() | nil, 17 29 content: String.t() | nil, 18 - rkey: String.t() | nil 30 + rkey: String.t() | nil, 31 + image: String.t() | nil, 32 + cover_status: cover_status() 19 33 } 20 34 21 35 def hash(%__MODULE__{} = entry) do
+1 -2
lib/annot_at_web/components/layouts.ex
··· 117 117 <.dash_nav_item icon="hero-globe-alt" navigate={~p"/sites"} active={@active == :sites}> 118 118 Sites 119 119 </.dash_nav_item> 120 - <.dash_nav_item icon="hero-document-text">Posts</.dash_nav_item> 121 120 122 121 <div class="px-3 pt-5 pb-1.5 text-[11px] font-bold uppercase 123 122 tracking-widest text-ink/40"> ··· 160 159 </div> 161 160 </aside> 162 161 163 - <main class="flex-1 px-6 py-8 sm:px-10"> 162 + <main class="min-w-0 flex-1 px-6 py-8 sm:px-10"> 164 163 {render_slot(@inner_block)} 165 164 </main> 166 165 </div>
+80 -23
lib/annot_at_web/controllers/live/posts_live.ex
··· 69 69 <div :if={pending(feed.entries, @posts) != []} class="mt-6 space-y-3"> 70 70 <div 71 71 :for={entry <- pending(feed.entries, @posts)} 72 - class="flex items-center justify-between gap-3 rounded-2xl border-2 73 - border-ink bg-paper p-4" 72 + class="flex items-center gap-4 rounded-2xl border-2 border-ink bg-paper p-4" 74 73 > 75 - <div class="min-w-0"> 74 + <.cover_thumb entry={entry} /> 75 + 76 + <div class="min-w-0 flex-1"> 76 77 <div class="truncate font-bold">{entry.title}</div> 77 - <div :if={entry.published_at} class="mt-0.5 text-xs text-ink/50"> 78 - {Calendar.strftime(entry.published_at, "%b %d, %Y")} 78 + <div :if={entry.summary} class="mt-0.5 truncate text-sm text-ink/55"> 79 + {entry.summary} 80 + </div> 81 + <div class="mt-1 flex items-center gap-2 text-xs text-ink/45"> 82 + <span :if={entry.published_at}>{Calendar.strftime(entry.published_at, "%b 83 + %d, %Y")}</span> 84 + <span 85 + :if={entry.cover_status in [:too_large, :not_image]} 86 + class="text-ink/45" 87 + > 88 + {cover_note(entry.cover_status)} 89 + </span> 79 90 </div> 80 91 </div> 81 92 ··· 85 96 size="sm" 86 97 disabled={is_nil(entry.rkey) or publishing?(@publishing, entry)} 87 98 phx-value-guid={entry.id} 88 - phx-click={ 89 - "select_post" 90 - |> JS.push() 91 - |> show_modal("publish-modal") 92 - } 99 + phx-click={"select_post" |> JS.push() |> show_modal("publish-modal")} 93 100 > 94 101 <.icon 95 102 :if={publishing?(@publishing, entry)} ··· 98 105 /> 99 106 {if publishing?(@publishing, entry), do: "Publishing…", else: "Publish"} 100 107 </.button> 101 - <span :if={not has_date?(entry)} class="flex-none text-xs 102 - text-ink/40">No date</span> 108 + <span :if={not has_date?(entry)} class="flex-none text-xs text-ink/40">No 109 + date</span> 103 110 </div> 104 111 </div> 105 112 ··· 112 119 class="flex items-center justify-between gap-3 rounded-2xl 113 120 border-2 border-ink/15 px-4 py-3" 114 121 > 115 - <div class="min-w-0"> 116 - <div class="truncate font-bold text-ink/70"> 117 - {entry.title} 118 - <span class="text-primary/50 text-xs ml-4"> 119 - {entry_to_post(@posts, entry).rkey} 122 + <.cover_thumb entry={entry} /> 123 + 124 + <div class="min-w-0 flex-1"> 125 + <div class="truncate font-bold text-ink/70">{entry.title}</div> 126 + <div :if={entry.summary} class="mt-0.5 truncate text-sm text-ink/50"> 127 + {entry.summary} 128 + </div> 129 + <div class="mt-1 flex items-center gap-2 text-xs text-ink/45"> 130 + <span class="flex items-center gap-1"> 131 + <.icon name="hero-check" class="size-3.5" /> Published 132 + </span> 133 + <span class="truncate text-ink/40"> 134 + {entry_to_post( 135 + @posts, 136 + entry 137 + ).rkey} 138 + </span> 139 + <span :if={entry.cover_status in [:too_large, :not_image]}> 140 + {cover_note(entry.cover_status)} 120 141 </span> 121 - </div> 122 - <div class="mt-0.5 flex items-center gap-1.5 text-xs 123 - text-ink/45"> 124 - <.icon name="hero-check" class="size-3.5" /> Published 125 142 </div> 126 143 </div> 127 144 ··· 234 251 &(&1.id == 235 252 guid) 236 253 ), 237 - %Post{} = post <- Map.get(posts, guid) do 254 + {:ok, %Post{} = post} <- Map.fetch(posts, guid) do 238 255 socket = 239 256 socket 240 257 |> assign(publishing: MapSet.put(socket.assigns.publishing, guid)) ··· 346 363 path: path_of(entry.url), 347 364 published_at: entry.published_at, 348 365 description: entry.summary, 349 - text_content: entry.content 366 + text_content: entry.content, 367 + cover_image: fetch_cover(entry) 350 368 } 351 369 end 352 370 ··· 439 457 _ -> 440 458 nil 441 459 end 460 + end 461 + 462 + defp fetch_cover(%Entry{cover_status: status, image: url}) 463 + when status in [:ok, :unknown] and is_binary(url) do 464 + case Client.fetch_image(url) do 465 + {:ok, image} -> image 466 + {:error, _} -> nil 467 + end 468 + end 469 + 470 + defp fetch_cover(_entry), do: nil 471 + 472 + defp cover_note(:too_large), do: "Cover image too large to publish (max 1MB)" 473 + defp cover_note(:not_image), do: "Cover image isn't a supported format" 474 + 475 + attr :entry, :map, required: true 476 + 477 + def cover_thumb(assigns) do 478 + ~H""" 479 + <img 480 + :if={@entry.cover_status in [:ok, :unknown] && @entry.image} 481 + src={@entry.image} 482 + alt="" 483 + class="aspect-[1.91/1] w-24 shrink-0 rounded-lg border-2 border-ink/15 object-cover" 484 + /> 485 + <div 486 + :if={@entry.cover_status in [:too_large, :not_image]} 487 + title={cover_note(@entry.cover_status)} 488 + class="flex aspect-[1.91/1] w-24 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-ink/30 text-ink/35" 489 + > 490 + <.icon name="hero-exclamation-triangle" class="size-5" /> 491 + </div> 492 + <div 493 + :if={@entry.cover_status == :none} 494 + class="flex aspect-[1.91/1] w-24 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-ink/10 text-ink/20" 495 + > 496 + <.icon name="hero-photo" class="size-5" /> 497 + </div> 498 + """ 442 499 end 443 500 end
+128
test/annot_at_web/live/posts_live_test.exs
··· 83 83 assert [%{guid: ^id}] = Publishing.list_posts(site) 84 84 end 85 85 86 + test "publishing attaches the cover image when one is usable", %{ 87 + conn: conn, 88 + user: user, 89 + site: site 90 + } do 91 + id = "guid-1" 92 + cover = {"PNGBYTES", "image/png"} 93 + 94 + feed = %Feed{ 95 + title: "Blog", 96 + entries: [ 97 + %Entry{ 98 + id: id, 99 + url: "https://example.com/posts/first", 100 + title: "First Post", 101 + published_at: ~U[2024-10-02 13:00:00Z], 102 + summary: "a summary", 103 + content: "the body" 104 + } 105 + ] 106 + } 107 + 108 + expect(Client, :load, fn _url -> {:ok, feed} end) 109 + 110 + expect(Client, :resolve_documents, fn _feed, _did -> 111 + %{ 112 + feed 113 + | entries: 114 + Enum.map(feed.entries, fn entry -> 115 + %{ 116 + entry 117 + | rkey: TID.at_time(entry.published_at), 118 + image: "https://example.com/og.png", 119 + cover_status: :ok 120 + } 121 + end) 122 + } 123 + end) 124 + 125 + expect(Client, :fetch_image, fn "https://example.com/og.png" -> 126 + {:ok, cover} 127 + end) 128 + 129 + expect(StandardSite, :put_document, fn _user_id, document -> 130 + assert document.cover_image == cover 131 + {:ok, %{"uri" => "at://x"}} 132 + end) 133 + 134 + {:ok, lv, _html} = 135 + conn 136 + |> init_test_session(%{user_id: user.id}) 137 + |> live(~p"/sites/#{site.id}/posts") 138 + 139 + assert render_async(lv, 2000) =~ "First Post" 140 + 141 + lv 142 + |> element("button[phx-value-guid='#{id}']") 143 + |> render_click() 144 + 145 + lv 146 + |> element("#publish-modal-confirm") 147 + |> render_click() 148 + 149 + assert render_async(lv, 2000) =~ "Published" 150 + end 151 + 152 + test "publishing drops an unpublishable cover but still publishes", %{ 153 + conn: conn, 154 + user: user, 155 + site: site 156 + } do 157 + id = "guid-1" 158 + 159 + feed = %Feed{ 160 + title: "Blog", 161 + entries: [ 162 + %Entry{ 163 + id: id, 164 + url: "https://example.com/posts/first", 165 + title: "First Post", 166 + published_at: ~U[2024-10-02 13:00:00Z], 167 + summary: "a summary", 168 + content: "the body" 169 + } 170 + ] 171 + } 172 + 173 + expect(Client, :load, fn _url -> {:ok, feed} end) 174 + 175 + expect(Client, :resolve_documents, fn _feed, _did -> 176 + %{ 177 + feed 178 + | entries: 179 + Enum.map(feed.entries, fn entry -> 180 + %{ 181 + entry 182 + | rkey: TID.at_time(entry.published_at), 183 + image: "https://example.com/big.png", 184 + cover_status: :too_large 185 + } 186 + end) 187 + } 188 + end) 189 + 190 + expect(StandardSite, :put_document, fn _user_id, document -> 191 + assert document.cover_image == nil 192 + {:ok, %{"uri" => "at://x"}} 193 + end) 194 + 195 + {:ok, lv, _html} = 196 + conn 197 + |> init_test_session(%{user_id: user.id}) 198 + |> live(~p"/sites/#{site.id}/posts") 199 + 200 + assert render_async(lv, 2000) =~ "First Post" 201 + 202 + lv 203 + |> element("button[phx-value-guid='#{id}']") 204 + |> render_click() 205 + 206 + lv 207 + |> element("#publish-modal-confirm") 208 + |> render_click() 209 + 210 + assert render_async(lv, 2000) =~ "Published" 211 + assert [%{guid: ^id}] = Publishing.list_posts(site) 212 + end 213 + 86 214 defp create_user do 87 215 {:ok, user} = 88 216 Accounts.upsert_login(