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.

You can now actually create publications

Also added some conveniences around development workflow, loosened the restriction on multiple "sites" using the same URL, and added a skip verification button (dev only).

Johanna Larsson (Jun 22, 2026, 8:52 AM +0100) eee39f16 a52b7e1d

+277 -78
+2
README.md
··· 2 2 3 3 A service for automatically publishing content to the Atmosphere, using the standard.site lexicon. Register your publication and a discovery mechanism, and your content is automatically published. 4 4 5 + 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. 6 + 5 7 ## TODO 6 8 7 9 - [x] Sign up/sign in with Bluesky
+3
config/config.exs
··· 82 82 tag: "AES.GCM.V1", key: Base.decode64!("8h8cn7yiPKyVKiCURGpw7/lUPqxEOahH2C+0CdGQIbI=")} 83 83 ] 84 84 85 + config :annot_at, 86 + dev_routes: false 87 + 85 88 # Import environment specific config. This must remain at the bottom 86 89 # of this file so it overrides the configuration defined above. 87 90 import_config "#{config_env()}.exs"
+3 -3
lib/annot_at/atproto/standard_site.ex
··· 17 17 @doc """ 18 18 Creates or updates the user's publication record. 19 19 """ 20 - @spec put_publication(integer(), Publication.t()) :: {:ok, map()} | {:error, term()} 21 - def put_publication(user_id, %Publication{} = publication) do 20 + @spec put_publication(integer(), String.t(), Publication.t()) :: {:ok, map()} | {:error, term()} 21 + def put_publication(user_id, rkey, %Publication{} = publication) do 22 22 with {:ok, user} <- fetch_user(user_id), 23 23 {:ok, icon} <- upload_image(user_id, publication.icon) do 24 - put_record(user, @publication, "self", publication_record(publication, icon)) 24 + put_record(user, @publication, rkey, publication_record(publication, icon)) 25 25 end 26 26 end 27 27
+11 -9
lib/annot_at/publishing.ex
··· 18 18 Repo.all(from s in Site, where: s.user_id == ^user_id, order_by: [desc: s.inserted_at]) 19 19 end 20 20 21 + def list_sites_for_url(%Scope{user: %User{id: user_id}}, url) do 22 + Repo.all( 23 + from s in Site, 24 + where: s.user_id == ^user_id and s.url == ^url, 25 + order_by: [desc: s.inserted_at] 26 + ) 27 + end 28 + 21 29 def get_site!(%Scope{user: %User{id: user_id}}, id) do 22 30 Repo.get_by!(Site, id: id, user_id: user_id) 23 31 end 24 32 25 33 def create_site(%Scope{user: %User{id: user_id}}, url) do 26 - case Repo.get_by(Site, user_id: user_id, url: url) do 27 - nil -> 28 - %Site{user_id: user_id} 29 - |> Site.changeset(%{url: url}) 30 - |> Repo.insert() 31 - 32 - %Site{} = site -> 33 - {:ok, site} 34 - end 34 + %Site{user_id: user_id} 35 + |> Site.changeset(%{url: url}) 36 + |> Repo.insert() 35 37 end 36 38 37 39 def use_new_publication(%Scope{user: %User{id: user_id}}, %Site{} = site) do
-1
lib/annot_at/publishing/site.ex
··· 43 43 |> validate_length(:name, max: 255) 44 44 |> validate_length(:description, max: 1000) 45 45 |> validate_length(:feed_url, max: 2048) 46 - |> unique_constraint(:url, name: :sites_user_id_url_index) 47 46 |> foreign_key_constraint(:user_id) 48 47 end 49 48
+129 -13
lib/annot_at_web/controllers/live/site_live.ex
··· 2 2 use AnnotAtWeb, :live_view 3 3 4 4 alias AnnotAt.Atproto.StandardSite 5 + alias AnnotAt.Atproto.StandardSite.Publication 5 6 alias AnnotAt.Feeds.Client 6 7 alias AnnotAt.Publishing 7 8 alias AnnotAt.Publishing.Site 8 9 alias AnnotAt.URL 9 10 alias Phoenix.LiveView.AsyncResult 10 11 11 - @impl Phoenix.LiveView 12 - def mount(%{"id" => id}, _session, socket) do 13 - site = Publishing.get_site!(socket.assigns.current_scope, id) 14 - 15 - socket = 16 - socket 17 - |> assign(page_title: site.url, site: site) 18 - |> advance(phase(site)) 12 + require Logger 19 13 20 - {:ok, socket} 21 - end 14 + @dev_routes Application.compile_env!(:annot_at, :dev_routes) 22 15 23 16 @impl Phoenix.LiveView 24 17 def render(assigns) do ··· 39 32 </span> 40 33 <button 41 34 :if={is_nil(@site.published_at)} 42 - phx-click="publish" 35 + phx-click="open_publish" 43 36 class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl 44 37 border-2 border-ink bg-ink px-5 py-2.5 text-sm font-bold text-paper 45 38 shadow-[4px_4px_0px_0px_var(--color-peach-bold)] transition-all ··· 62 55 <.well_known_step verification={@verification} at_uri={@at_uri} /> 63 56 <% end %> 64 57 </div> 58 + <div :if={@confirm_publish} class="fixed inset-0 z-50 flex items-center 59 + justify-center p-4"> 60 + <div class="absolute inset-0 bg-ink/50" phx-click="close_publish" /> 61 + <div class="relative w-full max-w-lg rounded-2xl border-2 border-ink 62 + bg-paper p-6 shadow-[8px_8px_0px_0px_var(--color-ink)]"> 63 + <h2 class="font-display text-2xl font-bold tracking-tight">Publish to 64 + the ATmosphere</h2> 65 + <p class="mt-2 text-sm text-ink/70"> 66 + This writes a public <span class="font-mono text-xs">site.standard.publication</span> 67 + record into your atproto repo, making your blog discoverable to any 68 + ATmosphere reader. You can update or remove it later. 69 + </p> 70 + 71 + <.async_result :let={record} assign={@record}> 72 + <:loading> 73 + <p class="mt-4 text-sm text-ink/50">Reading your 74 + site…</p> 75 + </:loading> 76 + <:failed :let={_}> 77 + <p class="mt-4 text-sm font-bold text-red-600">Couldn't read your 78 + site.</p> 79 + </:failed> 80 + 81 + <div class="mt-4 rounded-xl border-2 border-ink bg-sky-light p-4"> 82 + <div class="text-xs font-bold uppercase tracking-wide 83 + text-ink/55">Name</div> 84 + <div class="font-bold">{record["name"] || "Untitled"}</div> 85 + <div class="mt-2 text-xs font-bold uppercase tracking-wide 86 + text-ink/55">URL</div> 87 + <div class="break-all text-sm font-medium">{record["url"]}</div> 88 + </div> 89 + </.async_result> 90 + 91 + <div class="mt-6 flex justify-end gap-3"> 92 + <button 93 + phx-click="close_publish" 94 + class="rounded-xl border-2 border-ink bg-paper px-5 py-2.5 text-sm 95 + font-bold transition-all hover:bg-sky-light" 96 + > 97 + Cancel 98 + </button> 99 + <button 100 + phx-click="confirm_publish" 101 + class="inline-flex items-center gap-1.5 rounded-xl border-2 102 + border-ink bg-ink px-5 py-2.5 text-sm font-bold text-paper 103 + shadow-[4px_4px_0px_0px_var(--color-peach-bold)] transition-all 104 + hover:-translate-y-0.5 active:translate-y-0" 105 + > 106 + <.icon name="hero-paper-airplane" class="size-5" /> Publish 107 + </button> 108 + </div> 109 + </div> 110 + </div> 65 111 </Layouts.dashboard> 66 112 """ 67 113 end 68 114 69 115 @impl Phoenix.LiveView 116 + def mount(%{"id" => id}, _session, socket) do 117 + site = Publishing.get_site!(socket.assigns.current_scope, id) 118 + 119 + socket = 120 + socket 121 + |> assign(page_title: site.url, site: site, confirm_publish: false) 122 + |> advance(phase(site)) 123 + 124 + {:ok, socket} 125 + end 126 + 127 + @impl Phoenix.LiveView 70 128 def handle_event("pick_feed", %{"url" => feed_url}, socket) do 71 129 {:ok, site} = 72 130 Publishing.update_site(socket.assigns.current_scope, socket.assigns.site, %{ ··· 120 178 {:noreply, advance(socket, :well_known)} 121 179 end 122 180 123 - def handle_event("publish", _params, socket) do 124 - {:noreply, put_flash(socket, :info, "Publishing to the Atmosphere is coming soon.")} 181 + def handle_event("open_publish", _params, socket) do 182 + {:noreply, assign(socket, confirm_publish: true)} 183 + end 184 + 185 + def handle_event("close_publish", _params, socket) do 186 + {:noreply, assign(socket, confirm_publish: false)} 187 + end 188 + 189 + def handle_event("confirm_publish", _params, socket) do 190 + %{site: site, current_scope: scope, record: record} = socket.assigns 191 + 192 + with %AsyncResult{ok?: true, result: draft} <- record, 193 + publication = to_publication(draft), 194 + {:ok, _} <- StandardSite.put_publication(scope.user.id, site.rkey, publication), 195 + {:ok, site} <- Publishing.mark_published(scope, site) do 196 + socket = 197 + socket 198 + |> assign(site: site, confirm_publish: false) 199 + |> advance(phase(site)) 200 + 201 + {:noreply, socket} 202 + else 203 + other -> 204 + Logger.warning("SiteLive: Publishing failed", reason: inspect(other)) 205 + 206 + socket = 207 + socket 208 + |> assign(confirm_publish: false) 209 + |> put_flash(:error, "Couldn't publish, try again") 210 + 211 + {:noreply, socket} 212 + end 125 213 end 126 214 127 215 defp phase(%Site{verified_at: %DateTime{}}), do: :done ··· 208 296 end 209 297 end 210 298 299 + defp to_publication(draft) do 300 + %Publication{ 301 + name: draft["name"] || draft["url"], 302 + url: draft["url"], 303 + description: draft["description"] 304 + } 305 + end 306 + 211 307 attr :feeds, :any, required: true 212 308 213 309 defp feed_step(assigns) do ··· 383 479 > 384 480 <.icon name="hero-arrow-path" class="size-5" /> Check again 385 481 </button> 482 + <.dev_skip_button /> 386 483 </div> 387 484 </:failed> 388 485 ··· 563 660 </.info_card> 564 661 </div> 565 662 """ 663 + end 664 + 665 + if @dev_routes do 666 + defp dev_skip_button(assigns) do 667 + ~H""" 668 + <button 669 + phx-click="confirm_verified" 670 + class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl 671 + border-2 border-ink bg-paper px-5 py-3 text-sm font-bold transition-all 672 + hover:-translate-y-0.5 active:translate-y-0" 673 + > 674 + <.icon name="hero-x-mark" class="size-5" /> Skip (dev only) 675 + </button> 676 + """ 677 + end 678 + else 679 + defp dev_skip_button(assigns) do 680 + ~H"" 681 + end 566 682 end 567 683 end
+110 -42
lib/annot_at_web/controllers/live/site_new_live.ex
··· 1 1 defmodule AnnotAtWeb.SiteNewLive do 2 2 use AnnotAtWeb, :live_view 3 3 4 + import AnnotAtWeb.SiteComponents, only: [site_row: 1] 5 + 4 6 alias AnnotAt.Publishing 5 7 alias AnnotAt.URL 6 8 7 9 @impl Phoenix.LiveView 10 + 8 11 def render(assigns) do 9 12 ~H""" 10 13 <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:overview}> ··· 13 16 ← Back 14 17 </.link> 15 18 16 - <div class="mt-6 rounded-3xl border-2 border-ink bg-sky-light p-8 19 + <%= if @matches == [] do %> 20 + <div class="mt-6 rounded-3xl border-2 border-ink bg-sky-light p-8 17 21 shadow-[10px_10px_0px_0px_var(--color-sky-bold)] sm:p-12"> 18 - <div class="flex items-center gap-4"> 19 - <div class="grid size-16 flex-none -rotate-3 place-items-center 22 + <div class="flex items-center gap-4"> 23 + <div class="grid size-16 flex-none -rotate-3 place-items-center 20 24 rounded-2xl border-2 border-ink bg-peach-bold 21 25 shadow-[4px_4px_0px_0px_var(--color-ink)]"> 22 - <.icon name="hero-rss" class="size-8" /> 23 - </div> 24 - <div> 25 - <h1 class="font-display text-4xl font-bold tracking-tight">Add a 26 - site</h1> 27 - <p class="mt-1 text-ink/60">Paste your blog's URL, we'll sniff out 28 - its feed.</p> 26 + <.icon name="hero-rss" class="size-8" /> 27 + </div> 28 + <div> 29 + <h1 class="font-display text-4xl font-bold tracking-tight">Add a 30 + site</h1> 31 + <p class="mt-1 text-ink/60">Paste your blog's URL, we'll sniff out 32 + its feed.</p> 33 + </div> 29 34 </div> 30 - </div> 31 35 32 - <.form for={@form} id="site-form" phx-change="validate" phx-submit="submit" class="mt-8"> 33 - <div class="flex flex-col gap-3 sm:flex-row sm:items-end"> 34 - <div class="flex-1"> 35 - <.input 36 - field={@form[:url]} 37 - type="url" 38 - label="Website URL" 39 - placeholder="https://yourblog.com" 40 - class="w-full rounded-xl border-2 border-ink bg-paper px-5 py-4 41 - text-lg font-medium placeholder:text-ink/30 focus:outline-none focus:ring-2 36 + <.form for={@form} id="site-form" phx-change="validate" phx-submit="submit" class="mt-8"> 37 + <div class="flex flex-col gap-3 sm:flex-row sm:items-end"> 38 + <div class="flex-1"> 39 + <.input 40 + field={@form[:url]} 41 + type="url" 42 + label="Website URL" 43 + placeholder="https://yourblog.com" 44 + class="w-full rounded-xl border-2 border-ink bg-paper px-5 45 + py-4 text-lg font-medium placeholder:text-ink/30 focus:outline-none focus:ring-2 42 46 focus:ring-sky-bold" 43 - /> 44 - </div> 45 - <button 46 - type="submit" 47 - disabled={!@valid?} 48 - class={[ 49 - "w-full rounded-xl border-2 border-ink bg-ink px-6 py-4 text-lg 50 - font-bold text-paper transition-all whitespace-nowrap sm:mb-3 sm:w-auto", 51 - @valid? && 52 - "cursor-pointer 47 + /> 48 + </div> 49 + <button 50 + type="submit" 51 + disabled={!@valid?} 52 + class={[ 53 + "w-full rounded-xl border-2 border-ink bg-ink px-6 py-4 54 + text-lg font-bold text-paper transition-all whitespace-nowrap sm:mb-3 55 + sm:w-auto", 56 + @valid? && 57 + "cursor-pointer 53 58 shadow-[4px_4px_0px_0px_var(--color-peach-bold)] hover:-translate-y-0.5 54 59 hover:shadow-[6px_6px_0px_0px_var(--color-peach-bold)] active:translate-y-0 55 60 active:shadow-[2px_2px_0px_0px_var(--color-peach-bold)]", 56 - !@valid? && "cursor-not-allowed opacity-40" 57 - ]} 61 + !@valid? && "cursor-not-allowed opacity-40" 62 + ]} 63 + > 64 + Check feed 65 + </button> 66 + </div> 67 + </.form> 68 + </div> 69 + <% else %> 70 + <div class="mt-6 rounded-3xl border-2 border-ink bg-sky-light p-8 71 + shadow-[10px_10px_0px_0px_var(--color-sky-bold)] sm:p-10"> 72 + <h1 class="font-display text-3xl font-bold tracking-tight">You've 73 + added this before</h1> 74 + <p class="mt-1 text-ink/60">Continue an existing site for {@url}, or 75 + start a fresh one.</p> 76 + 77 + <div class="mt-6 space-y-3"> 78 + <.site_row :for={site <- @matches} site={site} /> 79 + 80 + <button 81 + phx-click="create_new" 82 + class="flex w-full cursor-pointer items-center gap-3 rounded-2xl 83 + border-2 border-dashed border-ink bg-paper p-5 text-left transition-all 84 + hover:-translate-y-0.5 active:translate-y-0" 58 85 > 59 - Check feed 86 + <.icon name="hero-plus-circle" class="size-6 flex-none" /> 87 + <div> 88 + <div class="font-bold">Start a new one</div> 89 + <div class="text-xs text-ink/55">Register {@url} again as a 90 + separate site.</div> 91 + </div> 60 92 </button> 61 93 </div> 62 - </.form> 63 - </div> 94 + 95 + <button phx-click="reset" class="mt-5 text-sm font-bold text-ink/50 96 + hover:text-ink"> 97 + ← Use a different URL 98 + </button> 99 + </div> 100 + <% end %> 64 101 </Layouts.dashboard> 65 102 """ 66 103 end ··· 71 108 assign(socket, 72 109 page_title: "Add a site", 73 110 form: to_form(%{"url" => ""}, as: :site), 74 - valid?: false 111 + valid?: false, 112 + matches: [], 113 + url: nil 75 114 )} 76 115 end 77 116 ··· 86 125 87 126 def handle_event("submit", %{"site" => %{"url" => url}}, socket) do 88 127 if URL.valid?(url) do 89 - case Publishing.create_site(socket.assigns.current_scope, URL.canonical(url)) do 90 - {:ok, site} -> 91 - {:noreply, push_navigate(socket, to: ~p"/sites/#{site.id}")} 128 + canonical = URL.canonical(url) 129 + scope = socket.assigns.current_scope 130 + 131 + case Publishing.list_sites_for_url(scope, canonical) do 132 + [] -> 133 + case Publishing.create_site(scope, canonical) do 134 + {:ok, site} -> 135 + {:noreply, push_navigate(socket, to: ~p"/sites/#{site.id}")} 136 + 137 + {:error, changeset} -> 138 + {:noreply, assign(socket, form: to_form(changeset, as: :site))} 139 + end 92 140 93 - {:error, changeset} -> 94 - {:noreply, assign(socket, form: to_form(changeset, as: :site))} 141 + matches -> 142 + {:noreply, assign(socket, matches: matches, url: canonical)} 95 143 end 96 144 else 97 145 {:noreply, socket} 98 146 end 147 + end 148 + 149 + def handle_event("create_new", _params, socket) do 150 + case Publishing.create_site(socket.assigns.current_scope, socket.assigns.url) do 151 + {:ok, site} -> 152 + {:noreply, push_navigate(socket, to: ~p"/sites/#{site.id}")} 153 + 154 + {:error, changeset} -> 155 + {:noreply, 156 + assign(socket, 157 + form: 158 + to_form(changeset, 159 + as: :site 160 + ) 161 + )} 162 + end 163 + end 164 + 165 + def handle_event("reset", _params, socket) do 166 + {:noreply, assign(socket, matches: [], url: nil)} 99 167 end 100 168 end
+8
priv/repo/migrations/20260622070339_allow_duplicate_site_urls.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.AllowDuplicateSiteUrls do 2 + use Ecto.Migration 3 + 4 + def change do 5 + drop unique_index(:sites, [:user_id, :url]) 6 + create index(:sites, [:user_id]) 7 + end 8 + end
+5 -5
test/annot_at/atproto/standard_site_test.exs
··· 20 20 assert user.id == user_id 21 21 assert @did == body.repo 22 22 assert "site.standard.publication" == body.collection 23 - assert "self" == body.rkey 23 + assert "abc123" == body.rkey 24 24 assert "site.standard.publication" == body.record["$type"] 25 25 assert "jola.dev" == body.record["name"] 26 26 {:ok, %{"uri" => "at://x"}} 27 27 end) 28 28 29 29 pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} 30 - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 30 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, "abc123", pub) 31 31 end 32 32 33 33 test "returns :no_session when the user does not exist" do 34 34 reject(&Client.procedure/3) 35 35 36 36 assert {:error, :no_session} = 37 - StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) 37 + StandardSite.put_publication(-1, "abc123", %Publication{name: "x", url: "y"}) 38 38 end 39 39 40 40 test "uploads the icon and embeds the returned blob" do ··· 63 63 icon: {<<1, 2, 3>>, "image/png"} 64 64 } 65 65 66 - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 66 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, "abc123", pub) 67 67 end 68 68 69 69 test "omits optional fields that are nil" do ··· 76 76 end) 77 77 78 78 assert {:ok, %{}} = 79 - StandardSite.put_publication(user.id, %Publication{ 79 + StandardSite.put_publication(user.id, "abc123", %Publication{ 80 80 name: "n", 81 81 url: "https://n.example" 82 82 })
+6 -5
test/annot_at/publishing_test.exs
··· 41 41 assert "3mope7jyypk22" == updated.rkey 42 42 end 43 43 44 - test "create_site/2 resumes the same site for a url", %{scope: scope} do 45 - {:ok, first} = Publishing.create_site(scope, "https://example.com/") 46 - {:ok, again} = Publishing.create_site(scope, "https://example.com/") 47 - assert first.id == again.id 48 - assert first.rkey == again.rkey 44 + test "list_sites_for_url/2 returns the scope's sites for that url", %{scope: scope} do 45 + {:ok, _} = Publishing.create_site(scope, "https://example.com") 46 + {:ok, _} = Publishing.create_site(scope, "https://other.com") 47 + 48 + assert [%Site{url: "https://example.com"}] = 49 + Publishing.list_sites_for_url(scope, "https://example.com") 49 50 end 50 51 51 52 test "create_site/2 keeps distinct sites per url", %{scope: scope} do