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.

Add Atom parsing

Follows the existing pattern from the RSS parser, just slight changes to match the Atom format. Tested on a single blog, looks like it works!

Johanna Larsson (Jun 29, 2026, 10:14 PM +0100) 4cf83135 7704ae6e

+281 -4
+2 -1
lib/annot_at/feeds.ex
··· 4 4 detects format and parses. 5 5 """ 6 6 7 + alias AnnotAt.Feeds.Atom 7 8 alias AnnotAt.Feeds.Feed 8 9 alias AnnotAt.Feeds.RSS 9 10 alias AnnotAt.Feeds.Source ··· 27 28 def parse(body, content_type \\ nil) when is_binary(body) do 28 29 case detect(body, content_type) do 29 30 :rss -> RSS.parse(body) 30 - :atom -> {:error, :unsupported_feed} 31 + :atom -> Atom.parse(body) 31 32 :json -> {:error, :unsupported_feed} 32 33 :unknown -> {:error, :unrecognized_feed} 33 34 end
+166
lib/annot_at/feeds/atom.ex
··· 1 + defmodule AnnotAt.Feeds.Atom do 2 + @moduledoc """ 3 + Saxy parser for Atom 1.0 feeds. 4 + """ 5 + 6 + @behaviour Saxy.Handler 7 + 8 + alias AnnotAt.Feeds.Entry 9 + alias AnnotAt.Feeds.Feed 10 + 11 + require Logger 12 + 13 + @doc """ 14 + Returns `AnnotAt.Feeds.Feed` with entries. The list of entries can be empty. 15 + 16 + If the feed is invalid or not usable, it returns `{:error, :invalid_feed}`. 17 + """ 18 + @spec parse(binary()) :: {:ok, Feed.t()} | {:error, :invalid_feed} 19 + def parse(body) when is_binary(body) do 20 + case Saxy.parse_string(body, __MODULE__, initial_state()) do 21 + {:ok, state} -> 22 + feed = %{state.feed | entries: Enum.reverse(state.entries)} 23 + 24 + if is_binary(feed.title) do 25 + {:ok, feed} 26 + else 27 + {:error, :invalid_feed} 28 + end 29 + 30 + {:error, %Saxy.ParseError{} = saxy_error} -> 31 + Logger.warning("Feeds.Atom saxy error", error: inspect(saxy_error)) 32 + {:error, :invalid_feed} 33 + end 34 + end 35 + 36 + defp initial_state do 37 + %{ 38 + feed: %Feed{}, 39 + entries: [], 40 + stack: [], 41 + current_text: [] 42 + } 43 + end 44 + 45 + def handle_event(:start_document, _data, state), do: {:ok, state} 46 + 47 + def handle_event(:start_element, {"entry", _attrs}, state) do 48 + {:ok, 49 + %{ 50 + state 51 + | entries: [%Entry{} | state.entries], 52 + stack: ["entry" | state.stack], 53 + current_text: [] 54 + }} 55 + end 56 + 57 + def handle_event(:start_element, {"link", attrs}, state) do 58 + state = apply_link(state, List.first(state.stack), attrs) 59 + {:ok, %{state | stack: ["link" | state.stack], current_text: []}} 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, "entry", state) do 71 + ["entry" | 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 == "entry" -> 87 + [current | entries] = state.entries 88 + %{state | entries: [apply_entry_field(current, name, text) | entries]} 89 + 90 + parent == "feed" -> 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_link(state, parent, attrs) do 103 + attrs = Map.new(attrs) 104 + rel = Map.get(attrs, "rel", "alternate") 105 + 106 + case {parent, rel, attrs} do 107 + {"entry", "alternate", %{"href" => href}} -> 108 + [current | entries] = state.entries 109 + %{state | entries: [%{current | url: href} | entries]} 110 + 111 + {"feed", "alternate", %{"href" => href}} -> 112 + %{state | feed: %{state.feed | url: href}} 113 + 114 + _ -> 115 + state 116 + end 117 + end 118 + 119 + defp apply_entry_field(entry, "title", text), do: %{entry | title: text} 120 + defp apply_entry_field(entry, "id", text), do: %{entry | id: text} 121 + defp apply_entry_field(entry, "summary", text), do: %{entry | summary: text} 122 + defp apply_entry_field(entry, "content", text), do: %{entry | content: text} 123 + defp apply_entry_field(entry, "published", text), do: %{entry | published_at: parse_date(text)} 124 + 125 + defp apply_entry_field(entry, "updated", text) do 126 + %{entry | published_at: entry.published_at || parse_date(text)} 127 + end 128 + 129 + defp apply_entry_field(entry, _name, _text), do: entry 130 + 131 + defp apply_feed_field(feed, "title", text), do: %{feed | title: text} 132 + defp apply_feed_field(feed, "subtitle", text), do: %{feed | description: text} 133 + defp apply_feed_field(feed, _name, _text), do: feed 134 + 135 + defp finalize_entry(%Entry{id: nil, url: url} = entry) when is_binary(url) do 136 + %{entry | id: url} 137 + end 138 + 139 + defp finalize_entry(entry), do: entry 140 + 141 + defp text(parts) do 142 + result = 143 + parts 144 + |> Enum.reverse() 145 + |> IO.iodata_to_binary() 146 + |> String.trim() 147 + 148 + case result do 149 + "" -> nil 150 + trimmed -> trimmed 151 + end 152 + end 153 + 154 + defp parse_date(nil), do: nil 155 + 156 + defp parse_date(text) do 157 + case DateTimeParser.parse_datetime(text) do 158 + {:ok, datetime} -> 159 + datetime 160 + 161 + {:error, reason} -> 162 + Logger.debug("Feeds.Atom: unparseable date - #{text}", reason: inspect(reason)) 163 + nil 164 + end 165 + end 166 + end
+1 -1
lib/annot_at/feeds/rss.ex
··· 139 139 datetime 140 140 141 141 {:error, reason} -> 142 - Logger.debug("Feeds.RSS unparseable pubDate #{inspect(text)}: #{inspect(reason)}") 142 + Logger.debug("Feeds.RSS: unparseable pubDate - #{text}", reason: inspect(reason)) 143 143 nil 144 144 end 145 145 end
+84
test/annot_at/feeds/atom_test.exs
··· 1 + defmodule AnnotAt.Feeds.AtomTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Feeds.Atom 5 + alias AnnotAt.Feeds.Entry 6 + alias AnnotAt.Feeds.Feed 7 + 8 + @fixture "../../support/fixtures/feeds/atom_sample.xml" 9 + |> Path.expand(__DIR__) 10 + |> File.read!() 11 + 12 + test "parses feed-level metadata" do 13 + assert {:ok, %Feed{} = feed} = Atom.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]}} = Atom.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>\n 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 id is missing" do 33 + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) 34 + assert second.id == second.url 35 + end 36 + 37 + test "prefers published over updated for published_at" do 38 + assert {:ok, %{entries: [first, _second]}} = Atom.parse(@fixture) 39 + assert ~U[2024-10-02 13:00:00Z] == first.published_at 40 + end 41 + 42 + test "leaves published_at nil when published and updated are missing" do 43 + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) 44 + refute second.published_at 45 + end 46 + 47 + test "leaves content nil when missing" do 48 + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) 49 + refute second.content 50 + end 51 + 52 + test "uses the alternate link, not self" do 53 + assert {:ok, %Feed{url: "https://example.com"}} = Atom.parse(@fixture) 54 + end 55 + 56 + test "returns an error on malformed" do 57 + assert {:error, :invalid_feed} = Atom.parse("<feed><title>oops") 58 + end 59 + 60 + test "accepts a valid but empty feed" do 61 + body = """ 62 + <?xml version="1.0" encoding="utf-8"?> 63 + <feed xmlns="http://www.w3.org/2005/Atom"> 64 + <title>Brand New Blog</title> 65 + <link rel="alternate" href="https://example.com"/> 66 + <subtitle>Nothing here yet.</subtitle> 67 + </feed> 68 + """ 69 + 70 + assert {:ok, %Feed{title: "Brand New Blog", entries: []}} = Atom.parse(body) 71 + end 72 + 73 + test "rejects a feed with no title as invalid" do 74 + body = """ 75 + <?xml version="1.0" encoding="utf-8"?> 76 + <feed xmlns="http://www.w3.org/2005/Atom"> 77 + <link rel="alternate" href="https://example.com"/> 78 + <subtitle>No title here.</subtitle> 79 + </feed> 80 + """ 81 + 82 + assert {:error, :invalid_feed} = Atom.parse(body) 83 + end 84 + end
+2 -2
test/annot_at_web/live/posts_live_test.exs
··· 69 69 |> init_test_session(%{user_id: user.id}) 70 70 |> live(~p"/sites/#{site.id}/posts") 71 71 72 - assert render_async(lv) =~ title 72 + assert render_async(lv, 2000) =~ title 73 73 74 74 lv 75 75 |> element("button[phx-value-guid='#{id}']") ··· 79 79 |> element("#publish-modal-confirm") 80 80 |> render_click() 81 81 82 - assert render_async(lv) =~ "Published" 82 + assert render_async(lv, 2000) =~ "Published" 83 83 assert [%{guid: ^id}] = Publishing.list_posts(site) 84 84 end 85 85
+26
test/support/fixtures/feeds/atom_sample.xml
··· 1 + <?xml version="1.0" encoding="utf-8" ?> 2 + <feed xmlns="http://www.w3.org/2005/Atom"> 3 + <title>Sample Blog</title> 4 + <subtitle>Thoughts about things.</subtitle> 5 + <link rel="self" href="https://example.com/atom.xml" /> 6 + <link rel="alternate" href="https://example.com" /> 7 + <id>https://example.com/</id> 8 + <updated>2024-10-03T09:00:00Z</updated> 9 + <entry> 10 + <title>First Post</title> 11 + <link rel="alternate" href="https://example.com/posts/first" /> 12 + <id>abc</id> 13 + <summary>A short summary of the first post.</summary> 14 + <content 15 + type="html" 16 + >&lt;p&gt;The full &lt;strong&gt;content&lt;/strong&gt; 17 + of the first post.&lt;/p&gt;</content> 18 + <published>2024-10-02T13:00:00Z</published> 19 + <updated>2024-10-03T09:00:00Z</updated> 20 + </entry> 21 + <entry> 22 + <title>Second Post</title> 23 + <link href="https://example.com/posts/second" /> 24 + <summary>Another summary.</summary> 25 + </entry> 26 + </feed>