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.

Adds feed discovery, parsing, and some plumbing for sites

Feed discovery is limited to link rel=alternate, which is probably plenty? It could be extended with some "guessing", checking if `/rss.xml` returns a valid feed, but that can be a future thing.

Probably going to want to make it possible to enable/disable feed polling, which might live on site, but we'll figure that out when we get to it

Johanna Larsson (Jun 21, 2026, 10:05 AM +0100) 9dbb48e6 a4bc38ed

+283 -41
+37 -22
lib/annot_at/feeds.ex
··· 6 6 7 7 alias AnnotAt.Feeds.Feed 8 8 alias AnnotAt.Feeds.RSS 9 + alias AnnotAt.Feeds.Source 9 10 10 - @feed_types ~w(application/rss+xml application/atom+xml appliation/feed+json) 11 + @feed_formats %{ 12 + "application/rss+xml" => :rss, 13 + "application/atom+xml" => :atom, 14 + "application/feed+json" => :json 15 + } 16 + 17 + @feed_selector Enum.map_join(Map.keys(@feed_formats), ", ", fn type -> 18 + ~s(link[rel="alternate"][type="#{type}"]) 19 + end) 11 20 12 21 @doc """ 13 22 Parses a feed body into a `Feed`, detecting the format from body and ··· 25 34 end 26 35 27 36 @doc """ 28 - Finds a feed URL on a page. 37 + Finds every feed URL on a page. 29 38 30 - Looks for link alternate pointing to a feed and returns the first match. 39 + Looks for link alternate pointing to feeds and returns all matches. 31 40 """ 32 - @spec discover(binary(), String.t()) :: {:ok, String.t()} | :error 41 + @spec discover(binary(), String.t()) :: [Source.t()] 33 42 def discover(html, base_url) when is_binary(html) and is_binary(base_url) do 34 - selector = 35 - Enum.map_join(@feed_types, ", ", fn type -> 36 - ~s(link[rel="alternate"][type="#{type}"]) 37 - end) 43 + html 44 + |> LazyHTML.from_document() 45 + |> LazyHTML.query(@feed_selector) 46 + |> LazyHTML.attributes() 47 + |> Enum.map(&source_from_attributes(&1, base_url)) 48 + |> Enum.reject(&is_nil/1) 49 + |> Enum.uniq_by(& &1.url) 50 + end 51 + 52 + defp source_from_attributes(attributes, base_url) do 53 + attributes = Map.new(attributes) 38 54 39 - href = 40 - html 41 - |> LazyHTML.from_document() 42 - |> LazyHTML.query(selector) 43 - |> LazyHTML.attribute("href") 44 - |> List.first() 55 + case attributes do 56 + %{"href" => href, "type" => type} -> 57 + url = 58 + base_url 59 + |> URI.merge(href) 60 + |> URI.to_string() 45 61 46 - if href do 47 - url = 48 - base_url 49 - |> URI.merge(href) 50 - |> URI.to_string() 62 + %Source{ 63 + url: url, 64 + title: attributes["title"], 65 + format: Map.fetch!(@feed_formats, type) 66 + } 51 67 52 - {:ok, url} 53 - else 54 - :error 68 + _ -> 69 + nil 55 70 end 56 71 end 57 72
+16
lib/annot_at/feeds/source.ex
··· 1 + defmodule AnnotAt.Feeds.Source do 2 + @moduledoc """ 3 + A nice wrapper for links to feeds containing metadata about them. 4 + """ 5 + 6 + @type format :: :rss | :atom | :json 7 + 8 + @type t :: %__MODULE__{ 9 + url: String.t(), 10 + title: String.t() | nil, 11 + format: format() 12 + } 13 + 14 + @enforce_keys [:url, :format] 15 + defstruct [:url, :title, :format] 16 + end
+44
lib/annot_at/publishing.ex
··· 1 + defmodule AnnotAt.Publishing do 2 + @moduledoc """ 3 + Context for a user's sites. Each site mirrors a `site.standard.publication` 4 + record in the user's atproto rep. Each user can have many sites, one for 5 + each actual website they control. Only created after verification. 6 + """ 7 + 8 + import Ecto.Query, only: [from: 2] 9 + 10 + alias AnnotAt.Accounts.Scope 11 + alias AnnotAt.Accounts.User 12 + alias AnnotAt.Publishing.Site 13 + alias AnnotAt.Repo 14 + 15 + @spec list_sites(Scope.t()) :: [Site.t()] 16 + def list_sites(%Scope{user: %User{id: user_id}}) do 17 + Repo.all(from s in Site, where: s.user_id == ^user_id, order_by: [asc: s.inserted_at]) 18 + end 19 + 20 + def get_site!(%Scope{user: %User{id: user_id}}, id) do 21 + Repo.get_by!(Site, id: id, user_id: user_id) 22 + end 23 + 24 + def create_site(%Scope{user: %User{id: user_id}}, rkey, attrs) do 25 + %Site{ 26 + user_id: user_id, 27 + rkey: rkey, 28 + verified_at: DateTime.utc_now(:second) 29 + } 30 + |> Site.changeset(attrs) 31 + |> Repo.insert() 32 + end 33 + 34 + def update_site(%Scope{user: %User{id: user_id}}, %Site{} = site, attrs) do 35 + verify_user_ownership!(site, user_id) 36 + 37 + site 38 + |> Site.changeset(attrs) 39 + |> Repo.update() 40 + end 41 + 42 + defp verify_user_ownership!(%Site{user_id: user_id}, user_id), do: :ok 43 + defp verify_user_ownership!(%Site{}, _user_id), do: raise(Ecto.NoResultsError, queryable: Site) 44 + end
+48
lib/annot_at/publishing/site.ex
··· 1 + defmodule AnnotAt.Publishing.Site do 2 + use Ecto.Schema 3 + 4 + import Ecto.Changeset 5 + 6 + @type t :: %__MODULE__{ 7 + user_id: integer(), 8 + name: String.t(), 9 + url: String.t(), 10 + description: String.t() | nil, 11 + feed_url: String.t(), 12 + rkey: String.t(), 13 + verified_at: DateTime.t() 14 + } 15 + 16 + schema "sites" do 17 + # Display name of the publication 18 + field :name, :string 19 + # The website the publication represents 20 + field :url, :string 21 + # Tagline 22 + field :description, :string 23 + # The url of the feed 24 + field :feed_url, :string 25 + # rkey of the site.standard.publication record 26 + # deterministically generated from did and url 27 + field :rkey, :string 28 + # when the site was verified 29 + field :verified_at, :utc_datetime 30 + 31 + belongs_to :user, AnnotAt.Accounts.User 32 + 33 + timestamps(type: :utc_datetime) 34 + end 35 + 36 + def changeset(site, attrs) do 37 + site 38 + |> cast(attrs, [:name, :url, :description, :feed_url]) 39 + |> validate_required([:name, :url, :feed_url, :rkey]) 40 + |> validate_length(:name, max: 255) 41 + |> validate_length(:description, max: 1000) 42 + |> validate_length(:url, max: 2048) 43 + |> validate_length(:feed_url, max: 2048) 44 + |> validate_length(:rkey, max: 512) 45 + |> foreign_key_constraint(:user_id) 46 + |> unique_constraint(:rkey, name: :sites_user_id_rkey_index) 47 + end 48 + end
+19
priv/repo/migrations/20260620150821_create_sites.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.CreateSites do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:sites) do 6 + add :user_id, references(:users, on_delete: :delete_all), null: false 7 + add :name, :text, null: false 8 + add :url, :text, null: false 9 + add :description, :text 10 + add :feed_url, :text, null: false 11 + add :rkey, :text, null: false 12 + add :verified_at, :utc_datetime, null: false 13 + 14 + timestamps(type: :utc_datetime) 15 + end 16 + 17 + create unique_index(:sites, [:user_id, :rkey]) 18 + end 19 + end
+40 -19
test/annot_at/feeds_test.exs
··· 19 19 end 20 20 21 21 describe "Feeds.discover/2" do 22 - test "finds and resolves a relative feed url" do 23 - html = """ 24 - <html> 25 - <head> 26 - <link rel="alternate" type="application/rss+xml" href="/feed.xml"> 27 - </head> 28 - </html> 22 + test "returns all declared feeds, resolved and labeled" do 23 + html = ~s""" 24 + <html><head> 25 + <link rel="alternate" type="application/rss+xml" title="Main" 26 + href="/feed.xml"> 27 + <link rel="alternate" type="application/atom+xml" 28 + href="https://blog.example.com/atom"> 29 + <link rel="alternate" type="application/feed+json" title="JSON" 30 + href="/feed.json"> 31 + </head></html> 32 + """ 33 + 34 + assert [main, atom, json] = Feeds.discover(html, "https://blog.example.com") 35 + 36 + assert %Feeds.Source{url: "https://blog.example.com/feed.xml", title: "Main", format: :rss} = 37 + main 38 + 39 + assert %Feeds.Source{url: "https://blog.example.com/atom", title: nil, format: :atom} = atom 40 + assert %Feeds.Source{format: :json, title: "JSON"} = json 41 + end 42 + 43 + test "ignores non-feed alternates and dedupes by url" do 44 + html = ~s""" 45 + <html><head> 46 + <link rel="alternate" hreflang="fr" type="text/html" href="/fr"> 47 + <link rel="alternate" type="application/rss+xml" href="/feed.xml"> 48 + <link rel="alternate" type="application/rss+xml" href="/feed.xml"> 49 + </head></html> 29 50 """ 30 51 31 - assert {:ok, "https://blog.example.com/feed.xml"} = 52 + assert [%Feeds.Source{url: "https://example.com/feed.xml"}] = 53 + Feeds.discover(html, "https://example.com") 54 + end 55 + 56 + test "returns [] when no feed is declared" do 57 + assert [] == 32 58 Feeds.discover( 33 - html, 34 - "https://blog.example.com" 59 + "<html><head></head></html>", 60 + "https://example.com" 35 61 ) 36 62 end 37 63 38 - test "returns :error when there's no feed link" do 39 - html = """ 40 - <html> 41 - <head> 42 - </head> 43 - </html> 44 - """ 45 - 46 - assert :error = Feeds.discover(html, "https://blog.example.com") 64 + test "skips feed links without an href" do 65 + html = ~s(<html><head><link rel="alternate" 66 + type="application/rss+xml"></head></html>) 67 + assert [] == Feeds.discover(html, "https://example.com") 47 68 end 48 69 end 49 70 end
+79
test/annot_at/publishing_test.exs
··· 1 + defmodule AnnotAt.PublishingTest do 2 + use AnnotAt.DataCase, async: true 3 + 4 + alias AnnotAt.Accounts 5 + alias AnnotAt.Accounts.Scope 6 + alias AnnotAt.Publishing 7 + alias AnnotAt.Publishing.Site 8 + 9 + setup do 10 + {:ok, user} = 11 + Accounts.upsert_user(%{ 12 + did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 13 + handle: "alice.test", 14 + pds_host: "https://pds.example.com" 15 + }) 16 + 17 + %{scope: Scope.for_user(user)} 18 + end 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()) 22 + assert site.user_id == scope.user.id 23 + assert "3mope7jyypk22" == site.rkey 24 + assert %DateTime{} = site.verified_at 25 + 26 + assert [listed] = Publishing.list_sites(scope) 27 + assert listed.id == site.id 28 + end 29 + 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"})) 33 + 34 + assert 2 == length(Publishing.list_sites(scope)) 35 + end 36 + 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) 40 + end 41 + 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) 46 + end 47 + 48 + test "get_site!/2 raises for a site the scope doesn't own", %{scope: scope} do 49 + {:ok, other} = Accounts.upsert_user(%{did: "did:plc:otheruser000000000000000"}) 50 + 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 54 + 55 + assert_raise Ecto.NoResultsError, fn -> 56 + Publishing.get_site!( 57 + other_scope, 58 + site.id 59 + ) 60 + end 61 + end 62 + 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()) 67 + 68 + assert_raise Ecto.NoResultsError, fn -> 69 + Publishing.update_site(other_scope, site, %{name: "x"}) 70 + end 71 + end 72 + 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 + ) 78 + end 79 + end