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 RSS feed discovery and parsing

Includes a Saxy parser based on gluttony, some feed "discovery" logic, and some random fixes

Johanna Larsson (Jun 20, 2026, 3:45 PM +0100) a4bc38ed 5445083a

+421 -3
+1
.gitignore
··· 36 36 /assets/node_modules/ 37 37 38 38 .mix_tasks 39 + .DS_Store
+1 -1
config/config.exs
··· 55 55 # Configure Elixir's Logger 56 56 config :logger, :default_formatter, 57 57 format: "$time $metadata[$level] $message\n", 58 - metadata: [:request_id] 58 + metadata: :all 59 59 60 60 # Use Jason for JSON parsing in Phoenix 61 61 config :phoenix, :json_library, Jason
+9
lib/annot_at/accounts/user.ex
··· 3 3 4 4 import Ecto.Changeset 5 5 6 + @type t :: %__MODULE__{ 7 + did: String.t(), 8 + handle: String.t(), 9 + display_name: String.t(), 10 + avatar_url: String.t(), 11 + pds_host: String.t(), 12 + handle_verified_at: DateTime.t() 13 + } 14 + 6 15 schema "users" do 7 16 # Stable atproto identity from OAuth sub 8 17 field :did, :string
-1
lib/annot_at/atproto/profile.ex
··· 13 13 @spec fetch(String.t()) :: 14 14 {:ok, %{display_name: String.t() | nil, avatar: String.t() | nil}} 15 15 | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} 16 - 17 16 def fetch(actor) do 18 17 url = "#{@appview}/xrpc/app.bsky.actor.getProfile?#{URI.encode_query(actor: actor)}" 19 18
+72
lib/annot_at/feeds.ex
··· 1 + defmodule AnnotAt.Feeds do 2 + @moduledoc """ 3 + Feed handling entry point. Discovers feed URLs from a page HTML, 4 + detects format and parses. 5 + """ 6 + 7 + alias AnnotAt.Feeds.Feed 8 + alias AnnotAt.Feeds.RSS 9 + 10 + @feed_types ~w(application/rss+xml application/atom+xml appliation/feed+json) 11 + 12 + @doc """ 13 + Parses a feed body into a `Feed`, detecting the format from body and 14 + content type. 15 + """ 16 + @spec parse(binary(), String.t() | nil) :: 17 + {:ok, Feed.t()} | {:error, :invalid_feed | :unsupported_feed | :unrecognized_feed} 18 + def parse(body, content_type \\ nil) when is_binary(body) do 19 + case detect(body, content_type) do 20 + :rss -> RSS.parse(body) 21 + :atom -> {:error, :unsupported_feed} 22 + :json -> {:error, :unsupported_feed} 23 + :unknown -> {:error, :unrecognized_feed} 24 + end 25 + end 26 + 27 + @doc """ 28 + Finds a feed URL on a page. 29 + 30 + Looks for link alternate pointing to a feed and returns the first match. 31 + """ 32 + @spec discover(binary(), String.t()) :: {:ok, String.t()} | :error 33 + 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) 38 + 39 + href = 40 + html 41 + |> LazyHTML.from_document() 42 + |> LazyHTML.query(selector) 43 + |> LazyHTML.attribute("href") 44 + |> List.first() 45 + 46 + if href do 47 + url = 48 + base_url 49 + |> URI.merge(href) 50 + |> URI.to_string() 51 + 52 + {:ok, url} 53 + else 54 + :error 55 + end 56 + end 57 + 58 + defp detect(body, content_type) do 59 + head = 60 + body 61 + |> String.slice(0..1023) 62 + |> String.trim_leading() 63 + 64 + cond do 65 + String.starts_with?(head, "{") -> :json 66 + is_binary(content_type) and String.contains?(content_type, "json") -> :json 67 + String.contains?(head, "<rss") -> :rss 68 + String.contains?(head, "<feed") -> :atom 69 + true -> :unknown 70 + end 71 + end 72 + end
+19
lib/annot_at/feeds/entry.ex
··· 1 + defmodule AnnotAt.Feeds.Entry do 2 + @moduledoc """ 3 + A normalized feed entry, format-agnostic. 4 + 5 + Used as a normalized feed entry format, where entries come from 6 + different forms like RSS and atom. 7 + """ 8 + 9 + defstruct [:id, :url, :title, :published_at, :summary, :content] 10 + 11 + @type t :: %__MODULE__{ 12 + id: String.t() | nil, 13 + url: String.t() | nil, 14 + title: String.t() | nil, 15 + published_at: DateTime.t() | nil, 16 + summary: String.t() | nil, 17 + content: String.t() | nil 18 + } 19 + end
+18
lib/annot_at/feeds/feed.ex
··· 1 + defmodule AnnotAt.Feeds.Feed do 2 + @moduledoc """ 3 + Normalized feed metadata plus entries, format-agnostic. 4 + 5 + Note that the `url` field is the channel's canonical URL. 6 + """ 7 + 8 + alias AnnotAt.Feeds.Entry 9 + 10 + defstruct [:title, :description, :url, entries: []] 11 + 12 + @type t :: %__MODULE__{ 13 + title: String.t(), 14 + description: String.t() | nil, 15 + url: String.t() | nil, 16 + entries: [Entry.t()] 17 + } 18 + end
+146
lib/annot_at/feeds/rss.ex
··· 1 + defmodule AnnotAt.Feeds.RSS do 2 + @moduledoc """ 3 + Saxy SAX parser for RSS 2.0 feeds into `AnnotAt.Feeds.Feed`. 4 + 5 + Used Gluttony as a reference implementation. 6 + """ 7 + 8 + @behaviour Saxy.Handler 9 + 10 + alias AnnotAt.Feeds.Entry 11 + alias AnnotAt.Feeds.Feed 12 + 13 + require Logger 14 + 15 + @doc """ 16 + Returns `AnnotAt.Feeds.Feed` with entries. The list of entries can be empty. 17 + 18 + If the feed is invalid or not usable, it returns `{:error, :invalid_feed}`. 19 + """ 20 + @spec parse(binary()) :: {:ok, Feed.t()} | {:error, :invalid_feed} 21 + def parse(body) when is_binary(body) do 22 + case Saxy.parse_string(body, __MODULE__, initial_state()) do 23 + {:ok, state} -> 24 + feed = %{state.feed | entries: Enum.reverse(state.entries)} 25 + 26 + if is_binary(feed.title) do 27 + {:ok, feed} 28 + else 29 + {:error, :invalid_feed} 30 + end 31 + 32 + {:error, %Saxy.ParseError{} = saxy_error} -> 33 + Logger.warning("Feeds.RSS saxy error", 34 + error: inspect(saxy_error) 35 + ) 36 + 37 + {:error, :invalid_feed} 38 + end 39 + end 40 + 41 + defp initial_state do 42 + %{ 43 + feed: %Feed{}, 44 + entries: [], 45 + stack: [], 46 + current_text: [] 47 + } 48 + end 49 + 50 + def handle_event(:start_document, _data, state), do: {:ok, state} 51 + 52 + def handle_event(:start_element, {"item", _attrs}, state) do 53 + {:ok, 54 + %{ 55 + state 56 + | entries: [%Entry{} | state.entries], 57 + stack: ["item" | state.stack], 58 + current_text: [] 59 + }} 60 + end 61 + 62 + def handle_event(:start_element, {name, _attrs}, state) do 63 + {:ok, %{state | stack: [name | state.stack], current_text: []}} 64 + end 65 + 66 + def handle_event(:characters, chars, state) do 67 + {:ok, %{state | current_text: [chars | state.current_text]}} 68 + end 69 + 70 + def handle_event(:end_element, "item", state) do 71 + ["item" | rest_stack] = state.stack 72 + [entry | rest] = state.entries 73 + entry = finalize_entry(entry) 74 + {:ok, %{state | entries: [entry | rest], stack: rest_stack, current_text: []}} 75 + end 76 + 77 + def handle_event(:end_element, name, state) do 78 + text = text(state.current_text) 79 + [^name | parent_stack] = state.stack 80 + parent = List.first(parent_stack) 81 + 82 + state = %{state | stack: parent_stack, current_text: []} 83 + 84 + state = 85 + cond do 86 + parent == "item" -> 87 + [current | entries] = state.entries 88 + %{state | entries: [apply_entry_field(current, name, text) | entries]} 89 + 90 + parent == "channel" -> 91 + %{state | feed: apply_feed_field(state.feed, name, text)} 92 + 93 + true -> 94 + state 95 + end 96 + 97 + {:ok, state} 98 + end 99 + 100 + def handle_event(:end_document, _data, state), do: {:ok, state} 101 + 102 + defp apply_entry_field(entry, "title", text), do: %{entry | title: text} 103 + defp apply_entry_field(entry, "link", text), do: %{entry | url: text} 104 + defp apply_entry_field(entry, "guid", text), do: %{entry | id: text} 105 + defp apply_entry_field(entry, "description", text), do: %{entry | summary: text} 106 + defp apply_entry_field(entry, "content:encoded", text), do: %{entry | content: text} 107 + defp apply_entry_field(entry, "pubDate", text), do: %{entry | published_at: parse_date(text)} 108 + defp apply_entry_field(entry, _name, _text), do: entry 109 + 110 + defp apply_feed_field(feed, "title", text), do: %{feed | title: text} 111 + defp apply_feed_field(feed, "description", text), do: %{feed | description: text} 112 + defp apply_feed_field(feed, "link", text), do: %{feed | url: text} 113 + defp apply_feed_field(feed, _name, _text), do: feed 114 + 115 + defp finalize_entry(%Entry{id: nil, url: url} = entry) when is_binary(url) do 116 + %{entry | id: url} 117 + end 118 + 119 + defp finalize_entry(entry), do: entry 120 + 121 + defp text(parts) do 122 + result = 123 + parts 124 + |> Enum.reverse() 125 + |> IO.iodata_to_binary() 126 + |> String.trim() 127 + 128 + case result do 129 + "" -> nil 130 + trimmed -> trimmed 131 + end 132 + end 133 + 134 + defp parse_date(nil), do: nil 135 + 136 + defp parse_date(text) do 137 + case DateTimeParser.parse_datetime(text) do 138 + {:ok, datetime} -> 139 + datetime 140 + 141 + {:error, reason} -> 142 + Logger.debug("Feeds.RSS unparseable pubDate #{inspect(text)}: #{inspect(reason)}") 143 + nil 144 + end 145 + end 146 + end
-1
lib/annot_at_web/components/layouts/root.html.heex
··· 33 33 /> 34 34 <meta name="twitter:image" content={~p"/images/og_light.png"} /> 35 35 36 - <!-- Just a little fun with preloading --> 37 36 <link 38 37 rel="preload" 39 38 href={~p"/fonts/plus-jakarta-sans-latin-wght-normal.woff2"}
+79
test/annot_at/feeds/rss_test.exs
··· 1 + defmodule AnnotAt.Feeds.RSSTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Feeds.Entry 5 + alias AnnotAt.Feeds.Feed 6 + alias AnnotAt.Feeds.RSS 7 + 8 + @fixture "../../support/fixtures/feeds/rss_sample.xml" 9 + |> Path.expand(__DIR__) 10 + |> File.read!() 11 + 12 + test "parses channel-level feed metadata" do 13 + assert {:ok, %Feed{} = feed} = RSS.parse(@fixture) 14 + assert "Sample Blog" == feed.title 15 + assert "https://example.com" == feed.url 16 + assert "Thoughts about things." == feed.description 17 + end 18 + 19 + test "parses entries with all fields" do 20 + assert {:ok, %{entries: [first, _second]}} = RSS.parse(@fixture) 21 + 22 + assert %Entry{} = first 23 + assert "First Post" == first.title 24 + assert "https://example.com/posts/first" == first.url 25 + assert "abc" == first.id 26 + assert "A short summary of the first post." == first.summary 27 + assert "<p>The full <strong>content</strong> of the first post.</p>" == first.content 28 + assert %DateTime{} = first.published_at 29 + assert ~U[2024-10-02 13:00:00Z] == first.published_at 30 + end 31 + 32 + test "falls back to URL as ID when guid is missing" do 33 + assert {:ok, %{entries: [_first, second]}} = RSS.parse(@fixture) 34 + assert second.id == second.url 35 + end 36 + 37 + test "leaves published_at nil when pubDate is missing" do 38 + assert {:ok, %{entries: [_first, second]}} = RSS.parse(@fixture) 39 + refute second.published_at 40 + end 41 + 42 + test "leaves content nil when missing" do 43 + assert {:ok, %{entries: [_first, second]}} = RSS.parse(@fixture) 44 + refute second.content 45 + end 46 + 47 + test "returns an error on malformed" do 48 + assert {:error, :invalid_feed} = RSS.parse("<rss><channel><title>oops") 49 + end 50 + 51 + test "accepts a valid but empty blog" do 52 + body = """ 53 + <?xml version="1.0" encoding="UTF-8"?> 54 + <rss version="2.0"> 55 + <channel> 56 + <title>Brand New Blog</title> 57 + <link>https://example.com</link> 58 + <description>Nothing here yet.</description> 59 + </channel> 60 + </rss> 61 + """ 62 + 63 + assert {:ok, %Feed{title: "Brand New Blog", entries: []}} = RSS.parse(body) 64 + end 65 + 66 + test "rejects a feed with no channel title as invalid" do 67 + body = """ 68 + <?xml version="1.0" encoding="UTF-8"?> 69 + <rss version="2.0"> 70 + <channel> 71 + <link>https://example.com</link> 72 + <description>No title here.</description> 73 + </channel> 74 + </rss> 75 + """ 76 + 77 + assert {:error, :invalid_feed} = RSS.parse(body) 78 + end 79 + end
+49
test/annot_at/feeds_test.exs
··· 1 + defmodule AnnotAt.FeedsTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Feeds 5 + alias AnnotAt.Feeds.Feed 6 + 7 + @fixture "../support/fixtures/feeds/rss_sample.xml" 8 + |> Path.expand(__DIR__) 9 + |> File.read!() 10 + 11 + describe "Feeds.parse/2" do 12 + test "detects and dispatches RSS" do 13 + assert {:ok, %Feed{title: "Sample Blog"}} = Feeds.parse(@fixture, "application/rss+xml") 14 + end 15 + 16 + test "rejects an unrecognized body" do 17 + assert {:error, :unrecognized_feed} = Feeds.parse("not a feed at all", nil) 18 + end 19 + end 20 + 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> 29 + """ 30 + 31 + assert {:ok, "https://blog.example.com/feed.xml"} = 32 + Feeds.discover( 33 + html, 34 + "https://blog.example.com" 35 + ) 36 + end 37 + 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") 47 + end 48 + end 49 + end
+27
test/support/fixtures/feeds/rss_sample.xml
··· 1 + <?xml version="1.0" encoding="UTF-8" ?> 2 + <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 3 + <channel> 4 + <title>Sample Blog</title> 5 + <link>https://example.com</link> 6 + <description>Thoughts about things.</description> 7 + <image> 8 + <url>https://example.com/icon.png</url> 9 + <title>Sample Blog Logo</title> 10 + <link>https://example.com</link> 11 + </image> 12 + <item> 13 + <title>First Post</title> 14 + <link>https://example.com/posts/first</link> 15 + <guid>abc</guid> 16 + <description>A short summary of the first post.</description> 17 + <content:encoded 18 + ><![CDATA[<p>The full <strong>content</strong> of the first post.</p>]]></content:encoded> 19 + <pubDate>Wed, 02 Oct 2024 13:00:00 GMT</pubDate> 20 + </item> 21 + <item> 22 + <title>Second Post</title> 23 + <link>https://example.com/posts/second</link> 24 + <description>Another summary.</description> 25 + </item> 26 + </channel> 27 + </rss>