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.

Full site adding flow

This commit is way too big. It is a very nice milestone though, with a full integrated site adding flow/wizard.

You can now add sites, get RSS feed, validate well-known, and everything up to creating a publication. Still can use a lot of polish but it's working.

Also includes TID generation and lots of other improvements I bumped into while I was pulling everything together.

Johanna Larsson (Jun 21, 2026, 8:31 PM +0100) 5dde1947 9dbb48e6

+1383 -179
+97 -3
lib/annot_at/atproto/standard_site.ex
··· 5 5 """ 6 6 7 7 alias AnnotAt.Accounts 8 + alias AnnotAt.Atproto.HTTP 8 9 alias AnnotAt.Atproto.OAuth.Client 9 10 alias AnnotAt.Atproto.StandardSite.Document 10 11 alias AnnotAt.Atproto.StandardSite.Publication 11 12 12 13 @publication "site.standard.publication" 13 14 @document "site.standard.document" 15 + @wellknown_path "/.well-known/site.standard.publication" 14 16 15 17 @doc """ 16 - Creates or updates the user's publication record (one per repo, rkey `self`). 18 + Creates or updates the user's publication record. 17 19 """ 18 20 @spec put_publication(integer(), Publication.t()) :: {:ok, map()} | {:error, term()} 19 21 def put_publication(user_id, %Publication{} = publication) do ··· 37 39 @doc """ 38 40 The AT-URI of the user's publication record, used as a document's `site`. 39 41 """ 40 - @spec publication_uri(String.t()) :: String.t() 41 - def publication_uri(did), do: "at://#{did}/#{@publication}/self" 42 + @spec publication_uri(String.t(), String.t()) :: String.t() 43 + def publication_uri(did, rkey), do: "at://#{did}/#{@publication}/#{rkey}" 44 + 45 + @doc """ 46 + Verifies the website hosts the publication's AT-URI at its well-known path, 47 + proving control of the domain. 48 + """ 49 + @spec verify_ownership(String.t(), String.t()) :: 50 + :ok 51 + | {:error, 52 + :mismatch | :not_found | {:http_status, pos_integer()} | {:transport, term()}} 53 + def verify_ownership(url, at_uri) do 54 + url = wellknown_url(url) 55 + 56 + case HTTP.get_text(url) do 57 + {:ok, body} -> 58 + if String.trim(body) == at_uri do 59 + :ok 60 + else 61 + {:error, :mismatch} 62 + end 63 + 64 + {:error, {:http_status, 404}} -> 65 + {:error, :not_found} 66 + 67 + {:error, _reason} = error -> 68 + error 69 + end 70 + end 71 + 72 + @spec list_publications(integer()) :: 73 + {:ok, [%{rkey: String.t(), url: String.t() | nil, name: String.t() | nil}]} 74 + | {:error, :no_session} 75 + def list_publications(user_id) do 76 + with {:ok, user} <- fetch_user(user_id), 77 + {:ok, %{"records" => records}} <- 78 + Client.query(user.id, "com.atproto.repo.listRecords", 79 + repo: user.did, 80 + collection: @publication 81 + ) do 82 + {:ok, Enum.map(records, &to_existing/1)} 83 + end 84 + end 85 + 86 + @doc """ 87 + Reads the user's publication document. 88 + """ 89 + @spec get_publication(integer(), String.t()) :: {:ok, map()} | {:error, term()} 90 + def get_publication(user_id, rkey) do 91 + with {:ok, user} <- fetch_user(user_id), 92 + {:ok, %{"value" => value}} <- 93 + Client.query(user.id, "com.atproto.repo.getRecord", 94 + repo: user.did, 95 + collection: @publication, 96 + rkey: rkey 97 + ) do 98 + {:ok, value} 99 + end 100 + end 101 + 102 + @doc """ 103 + Builds the publication document we create, from page metadata. 104 + """ 105 + @spec draft_publication(String.t(), %{title: String.t() | nil, description: String.t() | nil}) :: 106 + map() 107 + def draft_publication(url, %{title: title, description: description}) do 108 + %{ 109 + "$type" => @publication, 110 + "name" => title, 111 + "url" => url, 112 + "description" => description, 113 + "preferences" => %{"showInDiscover" => true} 114 + } 115 + end 42 116 43 117 defp fetch_user(user_id) do 44 118 case Accounts.get_user(user_id) do ··· 86 160 87 161 defp put_optional(map, _key, nil), do: map 88 162 defp put_optional(map, key, value), do: Map.put(map, key, value) 163 + 164 + defp wellknown_url(url) do 165 + uri = URI.parse(url) 166 + 167 + URI.to_string(%{ 168 + uri 169 + | path: @wellknown_path, 170 + query: nil, 171 + fragment: nil 172 + }) 173 + end 174 + 175 + defp to_existing(%{"uri" => uri, "value" => value}) do 176 + rkey = 177 + uri 178 + |> String.split("/") 179 + |> List.last() 180 + 181 + %{rkey: rkey, url: value["url"], name: value["name"]} 182 + end 89 183 end
+28
lib/annot_at/atproto/tid.ex
··· 1 + defmodule AnnotAt.Atproto.TID do 2 + @moduledoc """ 3 + atproto TID (timestamp identifier), frequenty used as record keys. 4 + 5 + https://atproto.com/specs/tid) 6 + """ 7 + 8 + @alphabet "234567abcdefghijklmnopqrstuvwxyz" 9 + 10 + @spec now() :: String.t() 11 + def now(time \\ System) do 12 + # Shifts the time to make space for a random "clock ID" which basically 13 + # just means we wouldn't get collisions if two TIDs were generated the 14 + # same microsecond. Probably overkill. 15 + clock_id = :rand.uniform(1024) - 1 16 + shifted = time.os_time(:microsecond) * 1024 17 + timestamp = shifted + clock_id 18 + new(timestamp) 19 + end 20 + 21 + @spec new(non_neg_integer()) :: String.t() 22 + def new(int) do 23 + int 24 + |> Integer.digits(32) 25 + |> Enum.map_join(&<<:binary.at(@alphabet, &1)>>) 26 + |> String.pad_leading(13, "2") 27 + end 28 + end
+44
lib/annot_at/feeds.ex
··· 49 49 |> Enum.uniq_by(& &1.url) 50 50 end 51 51 52 + @doc """ 53 + Extracts a page's title and description from its HTML. 54 + """ 55 + @spec metadata(binary()) :: %{title: String.t() | nil, description: String.t() | nil} 56 + def metadata(html) when is_binary(html) do 57 + doc = LazyHTML.from_document(html) 58 + 59 + %{ 60 + title: 61 + text_of(doc, "title") || 62 + meta_content( 63 + doc, 64 + ~s(meta[property="og:title"]) 65 + ), 66 + description: 67 + meta_content(doc, ~s(meta[name="description"])) || 68 + meta_content(doc, ~s(meta[property="og:description"])) 69 + } 70 + end 71 + 52 72 defp source_from_attributes(attributes, base_url) do 53 73 attributes = Map.new(attributes) 54 74 ··· 82 102 String.contains?(head, "<rss") -> :rss 83 103 String.contains?(head, "<feed") -> :atom 84 104 true -> :unknown 105 + end 106 + end 107 + 108 + defp text_of(doc, selector) do 109 + doc 110 + |> LazyHTML.query(selector) 111 + |> LazyHTML.text() 112 + |> presence() 113 + end 114 + 115 + defp meta_content(doc, selector) do 116 + doc 117 + |> LazyHTML.query(selector) 118 + |> LazyHTML.attribute("content") 119 + |> List.first() 120 + |> presence() 121 + end 122 + 123 + defp presence(nil), do: nil 124 + 125 + defp presence(str) do 126 + case String.trim(str) do 127 + "" -> nil 128 + trimmed -> trimmed 85 129 end 86 130 end 87 131 end
+64
lib/annot_at/feeds/client.ex
··· 1 + defmodule AnnotAt.Feeds.Client do 2 + @moduledoc """ 3 + Network stuff for feeds, like fetching pages or feeds and handing 4 + them to the dicovery/parsing. 5 + """ 6 + 7 + alias AnnotAt.Feeds 8 + alias AnnotAt.Feeds.Feed 9 + alias AnnotAt.Feeds.Source 10 + 11 + @receive_timeout 10_000 12 + 13 + @doc """ 14 + Fetches a page and returns the feeds discovered. 15 + """ 16 + @spec discover(String.t()) :: 17 + {:ok, [Source.t()]} | {:error, {:http_status, pos_integer()} | {:transport, term()}} 18 + def discover(url) do 19 + with {:ok, html} <- get(url) do 20 + {:ok, Feeds.discover(html, url)} 21 + end 22 + end 23 + 24 + @doc """ 25 + Fetches and parses a single feed. 26 + """ 27 + @spec load(String.t()) :: 28 + {:ok, Feed.t()} 29 + | {:error, 30 + :invalid_feed 31 + | :unsupported_feed 32 + | :unrecognized_feed 33 + | {:http_status, pos_integer()} 34 + | {:transport, term()}} 35 + def load(feed_url) do 36 + with {:ok, body} <- get(feed_url) do 37 + Feeds.parse(body) 38 + end 39 + end 40 + 41 + @doc """ 42 + Fetches a page and extracts its title/description. 43 + """ 44 + @spec metadata(String.t()) :: 45 + {:ok, map()} | {:error, {:http_status, pos_integer()} | {:transport, term()}} 46 + def metadata(url) do 47 + with {:ok, html} <- get(url) do 48 + {:ok, Feeds.metadata(html)} 49 + end 50 + end 51 + 52 + defp get(url) do 53 + case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 54 + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> 55 + {:ok, body} 56 + 57 + {:ok, %Req.Response{status: status}} -> 58 + {:error, {:http_status, status}} 59 + 60 + {:error, reason} -> 61 + {:error, {:transport, reason}} 62 + end 63 + end 64 + end
+47 -7
lib/annot_at/publishing.ex
··· 9 9 10 10 alias AnnotAt.Accounts.Scope 11 11 alias AnnotAt.Accounts.User 12 + alias AnnotAt.Atproto.TID 12 13 alias AnnotAt.Publishing.Site 13 14 alias AnnotAt.Repo 14 15 ··· 21 22 Repo.get_by!(Site, id: id, user_id: user_id) 22 23 end 23 24 24 - def create_site(%Scope{user: %User{id: user_id}}, rkey, attrs) do 25 - %Site{ 26 - user_id: user_id, 27 - rkey: rkey, 25 + 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 35 + end 36 + 37 + def use_new_publication(%Scope{user: %User{id: user_id}}, %Site{} = site) do 38 + verify_user_ownership!(site, user_id) 39 + rkey = TID.now() 40 + 41 + site 42 + |> Ecto.Changeset.change(%{rkey: rkey}) 43 + |> Repo.update() 44 + end 45 + 46 + def use_existing_publication(%Scope{user: %User{id: user_id}}, %Site{} = site, rkey) do 47 + verify_user_ownership!(site, user_id) 48 + 49 + site 50 + |> Ecto.Changeset.change(%{rkey: rkey, published_at: DateTime.utc_now(:second)}) 51 + |> Repo.update() 52 + end 53 + 54 + def mark_verified(%Scope{user: %User{id: user_id}}, %Site{} = site) do 55 + verify_user_ownership!(site, user_id) 56 + 57 + site 58 + |> Ecto.Changeset.change(%{ 28 59 verified_at: DateTime.utc_now(:second) 29 - } 30 - |> Site.changeset(attrs) 31 - |> Repo.insert() 60 + }) 61 + |> Repo.update() 62 + end 63 + 64 + def mark_published(%Scope{user: %User{id: user_id}}, %Site{} = site) do 65 + verify_user_ownership!(site, user_id) 66 + 67 + site 68 + |> Ecto.Changeset.change(%{ 69 + published_at: DateTime.utc_now(:second) 70 + }) 71 + |> Repo.update() 32 72 end 33 73 34 74 def update_site(%Scope{user: %User{id: user_id}}, %Site{} = site, attrs) do
+15 -10
lib/annot_at/publishing/site.ex
··· 5 5 6 6 @type t :: %__MODULE__{ 7 7 user_id: integer(), 8 - name: String.t(), 8 + name: String.t() | nil, 9 9 url: String.t(), 10 10 description: String.t() | nil, 11 - feed_url: String.t(), 12 - rkey: String.t(), 13 - verified_at: DateTime.t() 11 + feed_url: String.t() | nil, 12 + rkey: String.t() | nil, 13 + verified_at: DateTime.t() | nil, 14 + published_at: DateTime.t() | nil 14 15 } 15 16 16 17 schema "sites" do ··· 23 24 # The url of the feed 24 25 field :feed_url, :string 25 26 # rkey of the site.standard.publication record 26 - # deterministically generated from did and url 27 27 field :rkey, :string 28 28 # when the site was verified 29 29 field :verified_at, :utc_datetime 30 + # when the user clicked publish 31 + field :published_at, :utc_datetime 30 32 31 33 belongs_to :user, AnnotAt.Accounts.User 32 34 ··· 35 37 36 38 def changeset(site, attrs) do 37 39 site 38 - |> cast(attrs, [:name, :url, :description, :feed_url]) 39 - |> validate_required([:name, :url, :feed_url, :rkey]) 40 + |> cast(attrs, [:url, :name, :description, :feed_url]) 41 + |> validate_required([:url]) 42 + |> validate_length(:url, max: 2048) 40 43 |> validate_length(:name, max: 255) 41 44 |> validate_length(:description, max: 1000) 42 - |> validate_length(:url, max: 2048) 43 45 |> validate_length(:feed_url, max: 2048) 44 - |> validate_length(:rkey, max: 512) 46 + |> unique_constraint(:url, name: :sites_user_id_url_index) 45 47 |> foreign_key_constraint(:user_id) 46 - |> unique_constraint(:rkey, name: :sites_user_id_rkey_index) 47 48 end 49 + 50 + def status(%__MODULE__{published_at: %DateTime{}}), do: :published 51 + def status(%__MODULE__{verified_at: %DateTime{}}), do: :verified 52 + def status(%__MODULE__{}), do: :draft 48 53 end
+52
lib/annot_at/url.ex
··· 1 + defmodule AnnotAt.URL do 2 + @moduledoc """ 3 + URL helpers. 4 + """ 5 + 6 + @doc """ 7 + Clean up URLs so we got consistent site "identifiers". 8 + """ 9 + def canonical(url) do 10 + uri = 11 + url 12 + |> with_scheme() 13 + |> URI.parse() 14 + 15 + URI.to_string(%{ 16 + uri 17 + | host: downcase(uri.host), 18 + path: trim_slash(uri.path) 19 + }) 20 + end 21 + 22 + def valid?(url) do 23 + uri = 24 + url 25 + |> String.trim() 26 + |> canonical() 27 + |> URI.parse() 28 + 29 + case uri do 30 + %URI{host: host} when is_binary(host) -> 31 + String.contains?(host, ".") 32 + 33 + _ -> 34 + false 35 + end 36 + end 37 + 38 + defp with_scheme(url) do 39 + if url =~ ~r{^https?://}i do 40 + url 41 + else 42 + "https://" <> url 43 + end 44 + end 45 + 46 + defp downcase(nil), do: nil 47 + defp downcase(host), do: String.downcase(host) 48 + 49 + defp trim_slash(nil), do: nil 50 + defp trim_slash("/"), do: nil 51 + defp trim_slash(path), do: String.trim_trailing(path, "/") 52 + end
+16
lib/annot_at_web/components/core_components.ex
··· 453 453 """ 454 454 end 455 455 456 + attr :label, :string, required: true 457 + attr :value, :string, required: true 458 + attr :sub, :string, required: true 459 + attr :tint, :string, required: true 460 + attr :shadow, :string, required: true 461 + 462 + def stat_card(assigns) do 463 + ~H""" 464 + <div class={["rounded-2xl border-2 border-ink p-5", @tint, @shadow]}> 465 + <div class="text-xs font-bold text-ink/70">{@label}</div> 466 + <div class="mt-2 font-display text-3xl font-bold">{@value}</div> 467 + <div class="mt-1 text-xs text-ink/55">{@sub}</div> 468 + </div> 469 + """ 470 + end 471 + 456 472 ## JS Commands 457 473 458 474 def show(js \\ %JS{}, selector) do
+12 -33
lib/annot_at_web/controllers/live/dashboard_live.ex
··· 2 2 use AnnotAtWeb, :live_view 3 3 4 4 @impl Phoenix.LiveView 5 - def mount(_params, _session, socket) do 6 - {:ok, assign(socket, page_title: "Dashboard", sites: [])} 7 - end 8 - 9 - @impl Phoenix.LiveView 10 - def handle_event("add_site", _params, socket) do 11 - {:noreply, put_flash(socket, :info, "Adding sites is coming next.")} 12 - end 13 - 14 - @impl Phoenix.LiveView 15 5 def render(assigns) do 16 6 ~H""" 17 7 <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:overview}> 18 8 <div class="flex justify-end"> 19 - <button 20 - phx-click="add_site" 21 - class="inline-flex items-center gap-1.5 rounded-xl bg-ink px-4 py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] active:scale-[0.98]" 9 + <.link 10 + navigate={~p"/sites/new"} 11 + class="mt-5 inline-flex items-center gap-1.5 rounded-xl bg-ink px-5 py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] active:scale-[0.98]" 22 12 > 23 - <.icon name="hero-plus" class="size-4" /> Add a site 24 - </button> 13 + <.icon name="hero-plus" class="size-4" /> Add your first site 14 + </.link> 25 15 </div> 26 16 27 17 <h1 class="mt-2 font-display text-3xl font-bold tracking-tight sm:text-4xl"> ··· 64 54 65 55 <div 66 56 :if={@sites == []} 67 - class="max-w-xl -rotate-1 rounded-2xl border-2 border-ink bg-sky-light p-8 text-center shadow-[8px_8px_0px_0px_var(--color-sky-bold)]" 57 + class="-rotate-1 rounded-2xl border-2 border-ink bg-sky-light p-8 text-center shadow-[8px_8px_0px_0px_var(--color-sky-bold)]" 68 58 > 69 59 <div class="mx-auto grid size-14 place-items-center rounded-full border-2 border-ink bg-peach-bold"> 70 60 <.icon name="hero-globe-alt" class="size-7" /> ··· 73 63 <p class="mx-auto mt-1 max-w-sm text-sm text-ink/65"> 74 64 Connect a blog's RSS feed and we'll publish every new post to the ATmosphere. 75 65 </p> 76 - <button 77 - phx-click="add_site" 66 + <.link 67 + navigate={~p"/sites/new"} 78 68 class="mt-5 inline-flex items-center gap-1.5 rounded-xl bg-ink px-5 py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] active:scale-[0.98]" 79 69 > 80 70 <.icon name="hero-plus" class="size-4" /> Add your first site 81 - </button> 71 + </.link> 82 72 </div> 83 73 </Layouts.dashboard> 84 74 """ 85 75 end 86 76 87 - attr :label, :string, required: true 88 - attr :value, :string, required: true 89 - attr :sub, :string, required: true 90 - attr :tint, :string, required: true 91 - attr :shadow, :string, required: true 92 - 93 - defp stat_card(assigns) do 94 - ~H""" 95 - <div class={["rounded-2xl border-2 border-ink p-5", @tint, @shadow]}> 96 - <div class="text-xs font-bold text-ink/70">{@label}</div> 97 - <div class="mt-2 font-display text-3xl font-bold">{@value}</div> 98 - <div class="mt-1 text-xs text-ink/55">{@sub}</div> 99 - </div> 100 - """ 77 + @impl Phoenix.LiveView 78 + def mount(_params, _session, socket) do 79 + {:ok, assign(socket, page_title: "Dashboard", sites: [])} 101 80 end 102 81 end
+567
lib/annot_at_web/controllers/live/site_live.ex
··· 1 + defmodule AnnotAtWeb.SiteLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + alias AnnotAt.Atproto.StandardSite 5 + alias AnnotAt.Feeds.Client 6 + alias AnnotAt.Publishing 7 + alias AnnotAt.Publishing.Site 8 + alias AnnotAt.URL 9 + alias Phoenix.LiveView.AsyncResult 10 + 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)) 19 + 20 + {:ok, socket} 21 + end 22 + 23 + @impl Phoenix.LiveView 24 + def render(assigns) do 25 + ~H""" 26 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:overview}> 27 + <.link navigate={~p"/dashboard"} class="text-sm text-ink/60 28 + hover:text-ink">← Back</.link> 29 + 30 + <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center 31 + sm:justify-between"> 32 + <h1 class="font-display text-3xl font-bold 33 + tracking-tight">{@site.url}</h1> 34 + 35 + <div :if={phase(@site) == :done} class="flex items-center gap-3"> 36 + <span class="inline-flex items-center gap-1.5 rounded-full border-2 37 + border-ink bg-paper px-3 py-1.5 text-sm font-bold"> 38 + <.icon name="hero-check-badge" class="size-5 text-green-600" /> Verified 39 + </span> 40 + <button 41 + :if={is_nil(@site.published_at)} 42 + phx-click="publish" 43 + class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl 44 + border-2 border-ink bg-ink px-5 py-2.5 text-sm font-bold text-paper 45 + shadow-[4px_4px_0px_0px_var(--color-peach-bold)] transition-all 46 + hover:-translate-y-0.5 active:translate-y-0" 47 + > 48 + <.icon name="hero-paper-airplane" class="size-5" /> Publish 49 + </button> 50 + </div> 51 + </div> 52 + 53 + <div class="mt-8 space-y-3"> 54 + <%= case phase(@site) do %> 55 + <% :done -> %> 56 + <.site_cards site={@site} record={@record} feed={@feed} /> 57 + <% :feed -> %> 58 + <.feed_step feeds={@feeds} /> 59 + <% :publication -> %> 60 + <.publication_step publications={@publications} /> 61 + <% :well_known -> %> 62 + <.well_known_step verification={@verification} at_uri={@at_uri} /> 63 + <% end %> 64 + </div> 65 + </Layouts.dashboard> 66 + """ 67 + end 68 + 69 + @impl Phoenix.LiveView 70 + def handle_event("pick_feed", %{"url" => feed_url}, socket) do 71 + {:ok, site} = 72 + Publishing.update_site(socket.assigns.current_scope, socket.assigns.site, %{ 73 + feed_url: feed_url 74 + }) 75 + 76 + socket = 77 + socket 78 + |> assign(site: site) 79 + |> advance(phase(site)) 80 + 81 + {:noreply, socket} 82 + end 83 + 84 + def handle_event("use_existing", %{"rkey" => rkey}, socket) do 85 + {:ok, site} = 86 + Publishing.use_existing_publication(socket.assigns.current_scope, socket.assigns.site, rkey) 87 + 88 + socket = 89 + socket 90 + |> assign(site: site) 91 + |> advance(phase(site)) 92 + 93 + {:noreply, socket} 94 + end 95 + 96 + def handle_event("use_new", _params, socket) do 97 + {:ok, site} = 98 + Publishing.use_new_publication(socket.assigns.current_scope, socket.assigns.site) 99 + 100 + socket = 101 + socket 102 + |> assign(site: site) 103 + |> advance(phase(site)) 104 + 105 + {:noreply, socket} 106 + end 107 + 108 + def handle_event("confirm_verified", _params, socket) do 109 + {:ok, site} = Publishing.mark_verified(socket.assigns.current_scope, socket.assigns.site) 110 + 111 + socket = 112 + socket 113 + |> assign(site: site) 114 + |> advance(phase(site)) 115 + 116 + {:noreply, socket} 117 + end 118 + 119 + def handle_event("revalidate", _params, socket) do 120 + {:noreply, advance(socket, :well_known)} 121 + end 122 + 123 + def handle_event("publish", _params, socket) do 124 + {:noreply, put_flash(socket, :info, "Publishing to the Atmosphere is coming soon.")} 125 + end 126 + 127 + defp phase(%Site{verified_at: %DateTime{}}), do: :done 128 + defp phase(%Site{feed_url: nil}), do: :feed 129 + defp phase(%Site{rkey: nil}), do: :publication 130 + defp phase(%Site{}), do: :well_known 131 + 132 + defp advance(socket, :feed) do 133 + url = socket.assigns.site.url 134 + 135 + if connected?(socket) do 136 + assign_async(socket, :feeds, fn -> 137 + with {:ok, feeds} <- Client.discover(url) do 138 + {:ok, %{feeds: feeds}} 139 + end 140 + end) 141 + else 142 + assign(socket, feeds: AsyncResult.loading()) 143 + end 144 + end 145 + 146 + defp advance(socket, :publication) do 147 + %{site: site, current_scope: scope} = socket.assigns 148 + 149 + if connected?(socket) do 150 + assign_async(socket, :publications, fn -> 151 + with {:ok, pubs} <- StandardSite.list_publications(scope.user.id) do 152 + {:ok, %{publications: Enum.filter(pubs, &matches_url?(&1, site.url))}} 153 + end 154 + end) 155 + else 156 + assign(socket, publications: AsyncResult.loading()) 157 + end 158 + end 159 + 160 + defp advance(socket, :well_known) do 161 + %{site: site, current_scope: scope} = socket.assigns 162 + at_uri = StandardSite.publication_uri(scope.user.did, site.rkey) 163 + socket = assign(socket, at_uri: at_uri) 164 + 165 + if connected?(socket) do 166 + assign_async(socket, :verification, fn -> 167 + case StandardSite.verify_ownership(site.url, at_uri) do 168 + :ok -> {:ok, %{verification: :ok}} 169 + {:error, _reason} = error -> error 170 + end 171 + end) 172 + else 173 + assign(socket, verification: AsyncResult.loading()) 174 + end 175 + end 176 + 177 + defp advance(socket, :done) do 178 + %{site: site, current_scope: scope} = socket.assigns 179 + 180 + if connected?(socket) do 181 + socket 182 + |> assign_async(:record, fn -> fetch_record(site, scope) end) 183 + |> assign_async(:feed, fn -> 184 + with {:ok, feed} <- Client.load(site.feed_url) do 185 + {:ok, %{feed: feed}} 186 + end 187 + end) 188 + else 189 + assign(socket, record: AsyncResult.loading(), feed: AsyncResult.loading()) 190 + end 191 + end 192 + 193 + defp matches_url?(%{url: url}, site_url) when is_binary(url) do 194 + URL.canonical(url) == site_url 195 + end 196 + 197 + defp matches_url?(_pub, _site_url), do: false 198 + 199 + defp fetch_record(%Site{published_at: %DateTime{}} = site, scope) do 200 + with {:ok, doc} <- StandardSite.get_publication(scope.user.id, site.rkey) do 201 + {:ok, %{record: doc}} 202 + end 203 + end 204 + 205 + defp fetch_record(%Site{} = site, _scope) do 206 + with {:ok, metadata} <- Client.metadata(site.url) do 207 + {:ok, %{record: StandardSite.draft_publication(site.url, metadata)}} 208 + end 209 + end 210 + 211 + attr :feeds, :any, required: true 212 + 213 + defp feed_step(assigns) do 214 + ~H""" 215 + <div class="rounded-2xl border-2 border-ink bg-paper p-6"> 216 + <div class="flex items-center gap-3"> 217 + <div class="grid size-11 flex-none -rotate-3 place-items-center 218 + rounded-xl border-2 border-ink bg-sky-bold 219 + shadow-[3px_3px_0px_0px_var(--color-ink)]"> 220 + <.icon name="hero-rss" class="size-6" /> 221 + </div> 222 + <div> 223 + <h2 class="font-display text-xl font-bold tracking-tight">Discovering 224 + your feed</h2> 225 + <p class="text-sm text-ink/60">We look for an RSS or Atom feed on your 226 + site.</p> 227 + </div> 228 + </div> 229 + 230 + <.async_result :let={feeds} assign={@feeds}> 231 + <:loading> 232 + <div class="mt-6 flex items-center gap-2 text-ink/60"> 233 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Looking… 234 + </div> 235 + </:loading> 236 + <:failed :let={_}> 237 + <p class="mt-6 text-sm font-bold text-red-600">Couldn't reach the 238 + site.</p> 239 + </:failed> 240 + 241 + <p :if={feeds == []} class="mt-6 text-sm text-ink/60">No feed found on 242 + this site.</p> 243 + 244 + <div :if={feeds != []} class="mt-6 grid gap-3 sm:grid-cols-2"> 245 + <button 246 + :for={feed <- feeds} 247 + phx-click="pick_feed" 248 + phx-value-url={feed.url} 249 + class="flex cursor-pointer flex-col items-start gap-2 rounded-2xl 250 + border-2 border-ink bg-paper p-4 text-left transition-all 251 + shadow-[4px_4px_0px_0px_var(--color-sky-bold)] hover:-translate-y-0.5 252 + hover:shadow-[6px_6px_0px_0px_var(--color-sky-bold)] active:translate-y-0 253 + active:shadow-[2px_2px_0px_0px_var(--color-sky-bold)]" 254 + > 255 + <span class="inline-flex items-center rounded-full border-2 256 + border-ink bg-peach-light px-2.5 py-0.5 text-xs font-bold uppercase 257 + tracking-wide"> 258 + {feed.format} 259 + </span> 260 + <span class="font-bold leading-snug">{feed.title || "Untitled 261 + feed"}</span> 262 + <span class="w-full truncate text-xs text-ink/50">{feed.url}</span> 263 + </button> 264 + </div> 265 + </.async_result> 266 + </div> 267 + """ 268 + end 269 + 270 + attr :publications, :any, required: true 271 + 272 + defp publication_step(assigns) do 273 + ~H""" 274 + <div class="rounded-2xl border-2 border-ink bg-paper p-6"> 275 + <div class="flex items-center gap-3"> 276 + <div class="grid size-11 flex-none -rotate-3 place-items-center 277 + rounded-xl border-2 border-ink bg-peach-bold 278 + shadow-[3px_3px_0px_0px_var(--color-ink)]"> 279 + <.icon name="hero-newspaper" class="size-6" /> 280 + </div> 281 + <div> 282 + <h2 class="font-display text-xl font-bold tracking-tight">Your 283 + publication</h2> 284 + <p class="text-sm text-ink/60">Reuse a publication you already have, 285 + or create a new one.</p> 286 + </div> 287 + </div> 288 + 289 + <.async_result :let={publications} assign={@publications}> 290 + <:loading> 291 + <div class="mt-6 flex items-center gap-2 text-ink/60"> 292 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> repo. 293 + </div> 294 + </:loading> 295 + 296 + <div :if={publications != []} class="mt-6 grid gap-3 sm:grid-cols-2"> 297 + <button 298 + :for={pub <- publications} 299 + phx-click="use_existing" 300 + phx-value-rkey={pub.rkey} 301 + class="flex cursor-pointer flex-col items-start gap-2 rounded-2xl 302 + border-2 border-ink bg-paper p-4 text-left transition-all 303 + shadow-[4px_4px_0px_0px_var(--color-peach-bold)] hover:-translate-y-0.5 304 + hover:shadow-[6px_6px_0px_0px_var(--color-peach-bold)] active:translate-y-0 305 + active:shadow-[2px_2px_0px_0px_var(--color-peach-bold)]" 306 + > 307 + <span class="inline-flex items-center rounded-full border-2 308 + border-ink bg-sky-light px-2.5 py-0.5 text-xs font-bold uppercase 309 + tracking-wide"> 310 + Existing 311 + </span> 312 + <span class="font-bold leading-snug">{pub.name || "Untitled 313 + publication"}</span> 314 + <span class="w-full truncate text-xs text-ink/50">{pub.rkey}</span> 315 + </button> 316 + </div> 317 + 318 + <button 319 + phx-click="use_new" 320 + class="mt-6 flex w-full cursor-pointer items-center gap-3 rounded-2xl 321 + border-2 border-dashed border-ink bg-sky-light p-4 text-left transition-all 322 + hover:-translate-y-0.5 active:translate-y-0" 323 + > 324 + <.icon name="hero-plus-circle" class="size-6 flex-none" /> 325 + <div> 326 + <div class="font-bold">Create a new publication</div> 327 + <div class="text-xs text-ink/55">We'll mint a fresh record for this 328 + site.</div> 329 + </div> 330 + </button> 331 + </.async_result> 332 + </div> 333 + """ 334 + end 335 + 336 + attr :verification, :any, required: true 337 + attr :at_uri, :string, required: true 338 + 339 + defp well_known_step(assigns) do 340 + ~H""" 341 + <div class="rounded-2xl border-2 border-ink bg-paper p-6"> 342 + <div class="flex items-center gap-3"> 343 + <div class="grid size-11 flex-none -rotate-3 place-items-center 344 + rounded-xl border-2 border-ink bg-sky-bold 345 + shadow-[3px_3px_0px_0px_var(--color-ink)]"> 346 + <.icon name="hero-shield-check" class="size-6" /> 347 + </div> 348 + <div> 349 + <h2 class="font-display text-xl font-bold tracking-tight">Verify your 350 + domain</h2> 351 + <p class="text-sm text-ink/60">Prove you control this site by hosting 352 + a small file.</p> 353 + </div> 354 + </div> 355 + 356 + <.async_result :let={_v} assign={@verification}> 357 + <:loading> 358 + <div class="mt-6 flex items-center gap-2 text-ink/60"> 359 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Checking your .well-known… 360 + </div> 361 + </:loading> 362 + <:failed :let={_reason}> 363 + <div class="mt-6 space-y-4"> 364 + <p class="text-sm text-ink/70">Host this file on your site, then 365 + check again:</p> 366 + <div class="rounded-xl border-2 border-ink bg-sky-light p-4"> 367 + <div class="text-xs font-bold uppercase tracking-wide 368 + text-ink/55">Path</div> 369 + <code class="mt-1 block break-all text-sm 370 + font-medium"> 371 + /.well-known/site.standard.publication 372 + </code> 373 + <div class="mt-3 text-xs font-bold uppercase tracking-wide 374 + text-ink/55">Contents</div> 375 + <code class="mt-1 block break-all text-sm 376 + font-medium">{@at_uri}</code> 377 + </div> 378 + <button 379 + phx-click="revalidate" 380 + class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl 381 + border-2 border-ink bg-paper px-5 py-3 text-sm font-bold transition-all 382 + hover:-translate-y-0.5 active:translate-y-0" 383 + > 384 + <.icon name="hero-arrow-path" class="size-5" /> Check again 385 + </button> 386 + </div> 387 + </:failed> 388 + 389 + <div class="mt-6 space-y-4"> 390 + <div class="flex items-center gap-2 font-bold text-green-600"> 391 + <.icon name="hero-check-circle" class="size-6" /> Verified, you 392 + control this domain. 393 + </div> 394 + <button 395 + phx-click="confirm_verified" 396 + class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl 397 + border-2 border-ink bg-ink px-6 py-3 text-sm font-bold text-paper transition-all 398 + hover:-translate-y-0.5 active:translate-y-0" 399 + > 400 + Finish setup <.icon name="hero-arrow-right" class="size-5" /> 401 + </button> 402 + </div> 403 + </.async_result> 404 + </div> 405 + """ 406 + end 407 + 408 + attr :icon, :string, required: true 409 + attr :title, :string, required: true 410 + attr :tint, :string, default: "sky" 411 + slot :badge 412 + slot :inner_block, required: true 413 + slot :actions 414 + 415 + defp info_card(assigns) do 416 + ~H""" 417 + <div class={[ 418 + "rounded-2xl border-2 border-ink p-6", 419 + @tint == "sky" && "bg-sky-light 420 + shadow-[6px_6px_0px_0px_var(--color-sky-bold)]", 421 + @tint == "peach" && "bg-peach-light 422 + shadow-[6px_6px_0px_0px_var(--color-peach-bold)]" 423 + ]}> 424 + <div class="flex items-start justify-between gap-3"> 425 + <div class="flex items-center gap-3"> 426 + <div class={[ 427 + "grid size-10 flex-none -rotate-3 place-items-center rounded-xl 428 + border-2 border-ink shadow-[2px_2px_0px_0px_var(--color-ink)]", 429 + @tint == "sky" && "bg-sky-bold", 430 + @tint == "peach" && "bg-peach-bold" 431 + ]}> 432 + <.icon name={@icon} class="size-5" /> 433 + </div> 434 + <h3 class="font-display text-lg font-bold 435 + tracking-tight">{@title}</h3> 436 + </div> 437 + <div :if={@badge != []}>{render_slot(@badge)}</div> 438 + </div> 439 + 440 + <div class="mt-4">{render_slot(@inner_block)}</div> 441 + 442 + <div :if={@actions != []} class="mt-4 flex items-center gap-2 border-t-2 443 + border-ink/20 pt-3"> 444 + {render_slot(@actions)} 445 + </div> 446 + </div> 447 + """ 448 + end 449 + 450 + attr :label, :string, required: true 451 + attr :value, :string, default: nil 452 + 453 + defp record_field(assigns) do 454 + ~H""" 455 + <div> 456 + <dt class="text-xs font-bold uppercase tracking-wide 457 + text-ink/45">{@label}</dt> 458 + <dd :if={@value} class="break-all font-medium">{@value}</dd> 459 + <dd :if={is_nil(@value)} class="text-sm italic text-ink/40">Not set 460 + yet</dd> 461 + </div> 462 + """ 463 + end 464 + 465 + attr :site, :map, required: true 466 + attr :record, :any, required: true 467 + attr :feed, :any, required: true 468 + 469 + defp site_cards(assigns) do 470 + ~H""" 471 + <div class="grid gap-5 sm:grid-cols-2"> 472 + <.info_card icon="hero-rss" title="Feed" tint="sky"> 473 + <:badge> 474 + <span 475 + :if={@feed.ok?} 476 + class="inline-flex items-center gap-1 rounded-full border-2 477 + border-ink bg-paper px-2.5 py-0.5 text-xs font-bold text-ink/60" 478 + > 479 + <.icon name="hero-clock" class="size-3.5" /> Checked just now 480 + </span> 481 + </:badge> 482 + 483 + <.async_result :let={feed} assign={@feed}> 484 + <:loading> 485 + <div class="flex items-center gap-2 text-ink/60"> 486 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading the feed… 487 + </div> 488 + </:loading> 489 + <:failed :let={_}> 490 + <p class="text-sm font-bold text-red-600">Couldn't read the 491 + feed.</p> 492 + </:failed> 493 + 494 + <div class="space-y-3"> 495 + <%= case List.first(feed.entries) do %> 496 + <% nil -> %> 497 + <p class="text-sm text-ink/60">No posts in this feed yet.</p> 498 + <% latest -> %> 499 + <div> 500 + <dt class="text-xs font-bold uppercase tracking-wide 501 + text-ink/45">Latest post</dt> 502 + <dd class="font-medium">{latest.title}</dd> 503 + <dd :if={latest.published_at} class="mt-0.5 text-xs 504 + text-ink/50"> 505 + {Calendar.strftime(latest.published_at, "%b %d, %Y")} 506 + </dd> 507 + </div> 508 + <% end %> 509 + 510 + <p class="text-sm font-medium text-ink/55">{length(feed.entries)} posts in the feed</p> 511 + </div> 512 + </.async_result> 513 + 514 + <a 515 + href={@site.feed_url} 516 + target="_blank" 517 + rel="noopener" 518 + class="mt-4 block break-all font-mono text-xs text-ink/40 519 + hover:text-ink/70" 520 + > 521 + {@site.feed_url} 522 + </a> 523 + </.info_card> 524 + 525 + <.info_card icon="hero-newspaper" title="Publication" tint="peach"> 526 + <:badge> 527 + <span class={[ 528 + "rounded-full border-2 border-ink px-2.5 py-0.5 text-xs font-bold", 529 + @site.published_at && "bg-sky-light", 530 + is_nil(@site.published_at) && "bg-peach-light" 531 + ]}> 532 + {if @site.published_at, do: "Published", else: "Not published yet"} 533 + </span> 534 + </:badge> 535 + 536 + <.async_result :let={record} assign={@record}> 537 + <:loading> 538 + <div class="flex items-center gap-2 text-ink/60"> 539 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading your site… 540 + </div> 541 + </:loading> 542 + <:failed :let={_}> 543 + <p class="text-sm font-bold text-red-600">Couldn't read your 544 + site.</p> 545 + </:failed> 546 + 547 + <dl class="space-y-3"> 548 + <.record_field label="Name" value={record["name"]} /> 549 + <.record_field label="URL" value={record["url"]} /> 550 + <.record_field label="Description" value={record["description"]} /> 551 + <.record_field label="Type" value={record["$type"]} /> 552 + <div> 553 + <dt class="text-xs font-bold uppercase tracking-wide 554 + text-ink/45">Show in discover</dt> 555 + <dd class="font-medium"> 556 + {if get_in(record, ["preferences", "showInDiscover"]), 557 + do: "Yes", 558 + else: "No"} 559 + </dd> 560 + </div> 561 + </dl> 562 + </.async_result> 563 + </.info_card> 564 + </div> 565 + """ 566 + end 567 + end
+100
lib/annot_at_web/controllers/live/site_new_live.ex
··· 1 + defmodule AnnotAtWeb.SiteNewLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + alias AnnotAt.Publishing 5 + alias AnnotAt.URL 6 + 7 + @impl Phoenix.LiveView 8 + def render(assigns) do 9 + ~H""" 10 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:overview}> 11 + <.link navigate={~p"/dashboard"} class="text-sm font-bold text-ink/50 12 + hover:text-ink"> 13 + ← Back 14 + </.link> 15 + 16 + <div class="mt-6 rounded-3xl border-2 border-ink bg-sky-light p-8 17 + 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 20 + rounded-2xl border-2 border-ink bg-peach-bold 21 + 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> 29 + </div> 30 + </div> 31 + 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 42 + 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 53 + shadow-[4px_4px_0px_0px_var(--color-peach-bold)] hover:-translate-y-0.5 54 + hover:shadow-[6px_6px_0px_0px_var(--color-peach-bold)] active:translate-y-0 55 + active:shadow-[2px_2px_0px_0px_var(--color-peach-bold)]", 56 + !@valid? && "cursor-not-allowed opacity-40" 57 + ]} 58 + > 59 + Check feed 60 + </button> 61 + </div> 62 + </.form> 63 + </div> 64 + </Layouts.dashboard> 65 + """ 66 + end 67 + 68 + @impl Phoenix.LiveView 69 + def mount(_params, _session, socket) do 70 + {:ok, 71 + assign(socket, 72 + page_title: "Add a site", 73 + form: to_form(%{"url" => ""}, as: :site), 74 + valid?: false 75 + )} 76 + end 77 + 78 + @impl Phoenix.LiveView 79 + def handle_event("validate", %{"site" => %{"url" => url} = params}, socket) do 80 + {:noreply, 81 + assign(socket, 82 + form: to_form(params, as: :site), 83 + valid?: URL.valid?(url) 84 + )} 85 + end 86 + 87 + def handle_event("submit", %{"site" => %{"url" => url}}, socket) do 88 + 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}")} 92 + 93 + {:error, changeset} -> 94 + {:noreply, assign(socket, form: to_form(changeset, as: :site))} 95 + end 96 + else 97 + {:noreply, socket} 98 + end 99 + end 100 + end
+2
lib/annot_at_web/router.ex
··· 35 35 live_session :authenticated, 36 36 on_mount: [{AnnotAtWeb.UserAuth, :require_authenticated}] do 37 37 live "/dashboard", DashboardLive 38 + live "/sites/new", SiteNewLive 39 + live "/sites/:id", SiteLive 38 40 end 39 41 end 40 42
+16
priv/repo/migrations/20260621104044_alter_sites_for_wizard.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.AlterSitesForWizard do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:sites) do 6 + modify :name, :text, null: true, from: {:text, null: false} 7 + modify :feed_url, :text, null: true, from: {:text, null: false} 8 + modify :verified_at, :utc_datetime, null: true, from: {:utc_datetime, null: false} 9 + modify :rkey, :text, null: true, from: {:text, null: false} 10 + add :published_at, :utc_datetime 11 + end 12 + 13 + drop unique_index(:sites, [:user_id, :rkey]) 14 + create unique_index(:sites, [:user_id, :url]) 15 + end 16 + end
+200 -94
test/annot_at/atproto/standard_site_test.exs
··· 3 3 use Mimic 4 4 5 5 alias AnnotAt.Accounts 6 + alias AnnotAt.Atproto.HTTP 6 7 alias AnnotAt.Atproto.OAuth.Client 7 8 alias AnnotAt.Atproto.StandardSite 8 9 alias AnnotAt.Atproto.StandardSite.Document 9 10 alias AnnotAt.Atproto.StandardSite.Publication 10 11 11 12 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 13 + @rkey "3mope7jyypk22" 12 14 13 - defp create_user do 14 - {:ok, user} = 15 - Accounts.upsert_user(%{did: @did, handle: "jola.dev", pds_host: "https://pds.example.com"}) 15 + describe "put_publication/2" do 16 + test "writes a publication record at rkey self" do 17 + user = create_user() 18 + 19 + expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body -> 20 + assert user.id == user_id 21 + assert @did == body.repo 22 + assert "site.standard.publication" == body.collection 23 + assert "self" == body.rkey 24 + assert "site.standard.publication" == body.record["$type"] 25 + assert "jola.dev" == body.record["name"] 26 + {:ok, %{"uri" => "at://x"}} 27 + end) 28 + 29 + pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} 30 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 31 + end 16 32 17 - user 18 - end 33 + test "returns :no_session when the user does not exist" do 34 + reject(&Client.procedure/3) 19 35 20 - test "put_publication/2 writes a publication record at rkey self" do 21 - user = create_user() 36 + assert {:error, :no_session} = 37 + StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) 38 + end 22 39 23 - expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body -> 24 - assert user.id == user_id 25 - assert @did == body.repo 26 - assert "site.standard.publication" == body.collection 27 - assert "self" == body.rkey 28 - assert "site.standard.publication" == body.record["$type"] 29 - assert "jola.dev" == body.record["name"] 30 - {:ok, %{"uri" => "at://x"}} 31 - end) 40 + test "uploads the icon and embeds the returned blob" do 41 + user = create_user() 42 + 43 + blob = %{ 44 + "$type" => "blob", 45 + "ref" => %{"$link" => "bafyicon"}, 46 + "mimeType" => "image/png", 47 + "size" => 3 48 + } 49 + 50 + expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" -> 51 + assert user.id == user_id 52 + {:ok, %{"blob" => blob}} 53 + end) 54 + 55 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 56 + assert blob == body.record["icon"] 57 + {:ok, %{"uri" => "at://x"}} 58 + end) 59 + 60 + pub = %Publication{ 61 + name: "jola.dev", 62 + url: "https://jola.dev", 63 + icon: {<<1, 2, 3>>, "image/png"} 64 + } 65 + 66 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 67 + end 68 + 69 + test "omits optional fields that are nil" do 70 + user = create_user() 71 + 72 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 73 + refute Map.has_key?(body.record, "description") 74 + refute Map.has_key?(body.record, "icon") 75 + {:ok, %{}} 76 + end) 32 77 33 - pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} 34 - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 78 + assert {:ok, %{}} = 79 + StandardSite.put_publication(user.id, %Publication{ 80 + name: "n", 81 + url: "https://n.example" 82 + }) 83 + end 35 84 end 36 85 37 - test "put_document/2 writes a document record with an rkey and rfc3339 timestamps" do 38 - user = create_user() 86 + describe "put_document/2" do 87 + test "writes a document record with an rkey and rfc3339 timestamps" do 88 + user = create_user() 89 + 90 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 91 + assert "site.standard.document" == body.collection 92 + assert "post-1" == body.rkey 93 + assert "Hello" == body.record["title"] 94 + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 95 + assert ["a", "b"] == body.record["tags"] 96 + {:ok, %{"uri" => "at://y"}} 97 + end) 39 98 40 - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 41 - assert "site.standard.document" == body.collection 42 - assert "post-1" == body.rkey 43 - assert "Hello" == body.record["title"] 44 - assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 45 - assert ["a", "b"] == body.record["tags"] 46 - {:ok, %{"uri" => "at://y"}} 47 - end) 99 + doc = %Document{ 100 + rkey: "post-1", 101 + site: StandardSite.publication_uri(@did, @rkey), 102 + title: "Hello", 103 + path: "/posts/1", 104 + published_at: ~U[2026-01-01 00:00:00Z], 105 + updated_at: ~U[2026-01-01 00:00:00Z], 106 + description: "desc", 107 + text_content: "body", 108 + tags: ["a", "b"] 109 + } 48 110 49 - doc = %Document{ 50 - rkey: "post-1", 51 - site: StandardSite.publication_uri(@did), 52 - title: "Hello", 53 - path: "/posts/1", 54 - published_at: ~U[2026-01-01 00:00:00Z], 55 - updated_at: ~U[2026-01-01 00:00:00Z], 56 - description: "desc", 57 - text_content: "body", 58 - tags: ["a", "b"] 59 - } 111 + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 112 + end 60 113 61 - assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 62 - end 114 + test "omits updatedAt and path when not set" do 115 + user = create_user() 63 116 64 - test "returns :no_session when the user does not exist" do 65 - reject(&Client.procedure/3) 117 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 118 + refute Map.has_key?(body.record, "updatedAt") 119 + refute Map.has_key?(body.record, "path") 120 + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 121 + {:ok, %{"uri" => "at://y"}} 122 + end) 66 123 67 - assert {:error, :no_session} = 68 - StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) 124 + doc = %Document{ 125 + rkey: "post-2", 126 + site: StandardSite.publication_uri(@did, @rkey), 127 + title: "Hello", 128 + published_at: ~U[2026-01-01 00:00:00Z] 129 + } 130 + 131 + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 132 + end 69 133 end 70 134 71 - test "put_publication/2 uploads the icon and embeds the returned blob" do 72 - user = create_user() 135 + describe "verify_ownership/2" do 136 + @url "https://example.com" 137 + @at_uri "at://#{@did}/site.standard.publication/#{@rkey}" 73 138 74 - blob = %{ 75 - "$type" => "blob", 76 - "ref" => %{"$link" => "bafyicon"}, 77 - "mimeType" => "image/png", 78 - "size" => 3 79 - } 139 + test "ok when the well-known matches the at-uri, ignoring surrounding 140 + whitespace" do 141 + expect(HTTP, :get_text, fn 142 + "https://example.com/.well-known/site.standard.publication" -> 143 + {:ok, " #{@at_uri}\n"} 144 + end) 80 145 81 - expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" -> 82 - assert user.id == user_id 83 - {:ok, %{"blob" => blob}} 84 - end) 146 + assert :ok = StandardSite.verify_ownership(@url, @at_uri) 147 + end 85 148 86 - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 87 - assert blob == body.record["icon"] 88 - {:ok, %{"uri" => "at://x"}} 89 - end) 149 + test "fetches the well-known at the domain root even when the url has a 150 + path" do 151 + expect(HTTP, :get_text, fn 152 + "https://example.com/.well-known/site.standard.publication" -> 153 + {:ok, @at_uri} 154 + end) 90 155 91 - pub = %Publication{ 92 - name: "jola.dev", 93 - url: "https://jola.dev", 94 - icon: {<<1, 2, 3>>, "image/png"} 95 - } 156 + assert :ok = 157 + StandardSite.verify_ownership( 158 + "https://example.com/blog", 159 + @at_uri 160 + ) 161 + end 162 + 163 + test "mismatch when the well-known holds a different uri" do 164 + expect(HTTP, :get_text, fn _ -> 165 + {:ok, "at://did:plc:someoneelse/site.standard.publication/x"} 166 + end) 167 + 168 + assert {:error, :mismatch} = StandardSite.verify_ownership(@url, @at_uri) 169 + end 170 + 171 + test "not_found when the file is missing" do 172 + expect(HTTP, :get_text, fn _ -> {:error, {:http_status, 404}} end) 173 + assert {:error, :not_found} = StandardSite.verify_ownership(@url, @at_uri) 174 + end 175 + end 176 + 177 + describe "list_publications/1" do 178 + test "returns existing publications with their rkeys" do 179 + user = create_user() 180 + 181 + expect(Client, :query, fn _id, "com.atproto.repo.listRecords", params -> 182 + assert "site.standard.publication" == params[:collection] 183 + 184 + {:ok, 185 + %{ 186 + "records" => [ 187 + %{ 188 + "uri" => "at://#{@did}/site.standard.publication/#{@rkey}", 189 + "value" => %{"name" => "jola.dev", "url" => "https://jola.dev"} 190 + } 191 + ] 192 + }} 193 + end) 96 194 97 - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 195 + assert {:ok, [pub]} = StandardSite.list_publications(user.id) 196 + assert "3mope7jyypk22" == pub.rkey 197 + assert "https://jola.dev" == pub.url 198 + assert "jola.dev" == pub.name 199 + end 98 200 end 99 201 100 - test "put_publication/2 omits optional fields that are nil" do 101 - user = create_user() 202 + describe "get_publication/2" do 203 + test "reads the record value" do 204 + user = create_user() 102 205 103 - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 104 - refute Map.has_key?(body.record, "description") 105 - refute Map.has_key?(body.record, "icon") 106 - {:ok, %{}} 107 - end) 206 + expect(Client, :query, fn _id, "com.atproto.repo.getRecord", params -> 207 + assert "abc123" == params[:rkey] 208 + {:ok, %{"value" => %{"name" => "jola.dev", "url" => "https://jola.dev"}}} 209 + end) 108 210 109 - assert {:ok, %{}} = 110 - StandardSite.put_publication(user.id, %Publication{ 111 - name: "n", 112 - url: "https://n.example" 113 - }) 211 + assert {:ok, %{"name" => "jola.dev"}} = StandardSite.get_publication(user.id, "abc123") 212 + end 114 213 end 115 214 116 - test "put_document/2 omits updatedAt and path when not set" do 117 - user = create_user() 215 + describe "draft_publication/2" do 216 + test "builds a publication document with discover default" do 217 + doc = 218 + StandardSite.draft_publication("https://jola.dev", %{ 219 + title: "jola.dev", 220 + description: "blog" 221 + }) 118 222 119 - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 120 - refute Map.has_key?(body.record, "updatedAt") 121 - refute Map.has_key?(body.record, "path") 122 - assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 123 - {:ok, %{"uri" => "at://y"}} 124 - end) 223 + assert "site.standard.publication" == doc["$type"] 224 + assert "jola.dev" == doc["name"] 225 + assert "https://jola.dev" == doc["url"] 226 + assert "blog" == doc["description"] 227 + assert %{"showInDiscover" => true} == doc["preferences"] 228 + end 229 + end 125 230 126 - doc = %Document{ 127 - rkey: "post-2", 128 - site: StandardSite.publication_uri(@did), 129 - title: "Hello", 130 - published_at: ~U[2026-01-01 00:00:00Z] 131 - } 231 + defp create_user do 232 + {:ok, user} = 233 + Accounts.upsert_user(%{ 234 + did: @did, 235 + handle: "jola.dev", 236 + pds_host: "https://pds.example.com" 237 + }) 132 238 133 - assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 239 + user 134 240 end 135 241 end
+17
test/annot_at/atproto/tid_test.exs
··· 1 + defmodule AnnotAt.Atproto.TIDTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.TID 5 + 6 + @tid_regex ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/ 7 + 8 + test "encode/1 of zero is the spec's zero-value TID" do 9 + assert "2222222222222" == TID.new(0) 10 + end 11 + 12 + test "now/0 produces a syntactically valid TID" do 13 + tid = TID.now() 14 + assert String.length(tid) == 13 15 + assert tid =~ @tid_regex 16 + end 17 + end
+16
test/annot_at/feeds_test.exs
··· 67 67 assert [] == Feeds.discover(html, "https://example.com") 68 68 end 69 69 end 70 + 71 + describe "Feeds.metadata1/" do 72 + test "extracts title and description" do 73 + html = ~s(<html><head><title>My Blog</title><meta name="description" 74 + content="Thoughts."></head></html>) 75 + 76 + assert %{title: "My Blog", description: "Thoughts."} = 77 + Feeds.metadata(html) 78 + end 79 + 80 + test "falls back to og tags, nils when absent" do 81 + html = ~s(<html><head><meta property="og:title" content="OG 82 + Title"></head></html>) 83 + assert %{title: "OG\n Title", description: nil} = Feeds.metadata(html) 84 + end 85 + end 70 86 end
+75 -32
test/annot_at/publishing_test.exs
··· 10 10 {:ok, user} = 11 11 Accounts.upsert_user(%{ 12 12 did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 13 - handle: "alice.test", 13 + handle: "jola.dev", 14 14 pds_host: "https://pds.example.com" 15 15 }) 16 16 17 17 %{scope: Scope.for_user(user)} 18 18 end 19 19 20 - test "create_site/3 persists a verified site owned by the scope", %{scope: scope} do 21 - assert {:ok, %Site{} = site} = Publishing.create_site(scope, "3mope7jyypk22", site_attrs()) 20 + test "create_site/2 creates a site with no rkey yet", %{scope: scope} do 21 + assert {:ok, %Site{} = site} = 22 + Publishing.create_site( 23 + scope, 24 + "https://example.com" 25 + ) 26 + 22 27 assert site.user_id == scope.user.id 23 - assert "3mope7jyypk22" == site.rkey 24 - assert %DateTime{} = site.verified_at 28 + refute site.rkey 29 + refute site.verified_at 30 + end 25 31 26 - assert [listed] = Publishing.list_sites(scope) 27 - assert listed.id == site.id 32 + test "use_new_publication/2 mints and stores an rkey", %{scope: scope} do 33 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 34 + assert {:ok, updated} = Publishing.use_new_publication(scope, site) 35 + assert updated.rkey =~ ~r/^[234567abcdefghij]/ 28 36 end 29 37 30 - test "a user can hold many sites for different websites", %{scope: scope} do 31 - {:ok, _} = Publishing.create_site(scope, "aaa", site_attrs(%{url: "https://one.com"})) 32 - {:ok, _} = Publishing.create_site(scope, "bbb", site_attrs(%{url: "https://two.com"})) 38 + test "use_existing_publication/3 stores the given rkey", %{scope: scope} do 39 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 40 + assert {:ok, updated} = Publishing.use_existing_publication(scope, site, "3mope7jyypk22") 41 + assert "3mope7jyypk22" == updated.rkey 42 + end 33 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 49 + end 50 + 51 + test "create_site/2 keeps distinct sites per url", %{scope: scope} do 52 + {:ok, _} = Publishing.create_site(scope, "https://one.com") 53 + {:ok, _} = Publishing.create_site(scope, "https://two.com") 34 54 assert 2 == length(Publishing.list_sites(scope)) 35 55 end 36 56 37 - test "create_site/3 requires name, url and feed_url", %{scope: scope} do 38 - assert {:error, changeset} = Publishing.create_site(scope, "rkey", %{}) 39 - assert %{name: _, url: _, feed_url: _} = errors_on(changeset) 57 + test "get_site!/2 returns the scope's site", %{scope: scope} do 58 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 59 + assert Publishing.get_site!(scope, site.id).id == site.id 60 + end 61 + 62 + test "update_site/3 fills editable fields", %{scope: scope} do 63 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 64 + 65 + assert {:ok, updated} = 66 + Publishing.update_site(scope, site, %{ 67 + name: "Blog", 68 + feed_url: "https://example.com/feed.xml" 69 + }) 70 + 71 + assert "Blog" == updated.name 72 + assert "https://example.com/feed.xml" == updated.feed_url 40 73 end 41 74 42 - test "rkey is unique per user", %{scope: scope} do 43 - {:ok, _} = Publishing.create_site(scope, "dup", site_attrs()) 44 - assert {:error, changeset} = Publishing.create_site(scope, "dup", site_attrs()) 45 - assert %{rkey: _} = errors_on(changeset) 75 + test "mark_verified/2 then mark_published/2 stamp the timestamps", %{scope: scope} do 76 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 77 + {:ok, verified} = Publishing.mark_verified(scope, site) 78 + assert %DateTime{} = verified.verified_at 79 + {:ok, published} = Publishing.mark_published(scope, verified) 80 + assert %DateTime{} = published.published_at 46 81 end 47 82 48 - test "get_site!/2 raises for a site the scope doesn't own", %{scope: scope} do 83 + test "scoped functions refuse a site the scope doesn't own", %{scope: scope} do 49 84 {:ok, other} = Accounts.upsert_user(%{did: "did:plc:otheruser000000000000000"}) 50 85 other_scope = Scope.for_user(other) 51 - {:ok, site} = Publishing.create_site(scope, "scoped", site_attrs()) 52 - 53 - assert Publishing.get_site!(scope, site.id).id == site.id 86 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 54 87 55 88 assert_raise Ecto.NoResultsError, fn -> 56 89 Publishing.get_site!( ··· 58 91 site.id 59 92 ) 60 93 end 61 - end 62 94 63 - test "update_site/3 refuses a site the scope doesn't own", %{scope: scope} do 64 - {:ok, other} = Accounts.upsert_user(%{did: "did:plc:otheruser000000000000000"}) 65 - other_scope = Scope.for_user(other) 66 - {:ok, site} = Publishing.create_site(scope, "owned", site_attrs()) 95 + assert_raise Ecto.NoResultsError, fn -> 96 + Publishing.update_site(other_scope, site, %{name: "x"}) 97 + end 98 + 99 + assert_raise Ecto.NoResultsError, fn -> 100 + Publishing.mark_verified(other_scope, site) 101 + end 67 102 68 103 assert_raise Ecto.NoResultsError, fn -> 69 - Publishing.update_site(other_scope, site, %{name: "x"}) 104 + Publishing.mark_published(other_scope, site) 70 105 end 71 106 end 72 107 73 - defp site_attrs(overrides \\ %{}) do 74 - Map.merge( 75 - %{name: "My Blog", url: "https://example.com", feed_url: "https://example.com/feed.xml"}, 76 - overrides 77 - ) 108 + test "use_new_publication/2 mints an rkey and leaves it unpublished", %{scope: scope} do 109 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 110 + assert {:ok, updated} = Publishing.use_new_publication(scope, site) 111 + assert updated.rkey =~ ~r/^[234567abcdefghij]/ 112 + refute updated.published_at 113 + end 114 + 115 + test "use_existing_publication/3 stores the rkey and marks it published", 116 + %{scope: scope} do 117 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 118 + assert {:ok, updated} = Publishing.use_existing_publication(scope, site, "3mope7jyypk22") 119 + assert "3mope7jyypk22" == updated.rkey 120 + assert %DateTime{} = updated.published_at 78 121 end 79 122 end
+14
test/annot_at/url_test.exs
··· 1 + defmodule AnnotAt.URLTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.URL 5 + 6 + describe "canonical/1" do 7 + test "defaults scheme, downcases host, strips trailing slash" do 8 + assert "https://example.com" == URL.canonical("example.com") 9 + assert "https://example.com" == URL.canonical("https://EXAMPLE.com/") 10 + 11 + assert "https://example.com/blog" == URL.canonical("https://example.com/blog") 12 + end 13 + end 14 + end
+1
test/test_helper.exs
··· 1 1 Mimic.copy(AnnotAt.Atproto.DNS) 2 + Mimic.copy(AnnotAt.Feeds.Client) 2 3 Mimic.copy(AnnotAt.Atproto.HTTP) 3 4 Mimic.copy(AnnotAt.Atproto.Identity) 4 5 Mimic.copy(AnnotAt.Atproto.Profile)