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 category parsing too

Nothing much to say here, basically the same as the RSS parser except the name sits as an attribute on the category

Johanna Larsson (Jul 1, 2026, 7:45 AM +0100) 684a63e8 58fd1bbc

+31 -5
+28 -5
lib/annot_at/feeds/atom.ex
··· 48 48 {:ok, 49 49 %{ 50 50 state 51 - | entries: [%Entry{} | state.entries], 51 + | entries: [%Entry{categories: []} | state.entries], 52 52 stack: ["entry" | state.stack], 53 53 current_text: [] 54 54 }} ··· 57 57 def handle_event(:start_element, {"link", attrs}, state) do 58 58 state = apply_link(state, List.first(state.stack), attrs) 59 59 {:ok, %{state | stack: ["link" | state.stack], current_text: []}} 60 + end 61 + 62 + def handle_event(:start_element, {"category", attrs}, state) do 63 + state = apply_category(state, List.first(state.stack), attrs) 64 + {:ok, %{state | stack: ["category" | state.stack], current_text: []}} 60 65 end 61 66 62 67 def handle_event(:start_element, {name, _attrs}, state) do ··· 116 121 end 117 122 end 118 123 124 + defp apply_category(state, "entry", attrs) do 125 + case Map.new(attrs) do 126 + %{"term" => term} -> 127 + [current | entries] = state.entries 128 + %{state | entries: [%{current | categories: [term | current.categories]} | entries]} 129 + 130 + _ -> 131 + state 132 + end 133 + end 134 + 135 + defp apply_category(state, _parent, _attrs), do: state 136 + 119 137 defp apply_entry_field(entry, "title", text), do: %{entry | title: text} 120 138 defp apply_entry_field(entry, "id", text), do: %{entry | id: text} 121 139 defp apply_entry_field(entry, "summary", text), do: %{entry | summary: text} ··· 132 150 defp apply_feed_field(feed, "subtitle", text), do: %{feed | description: text} 133 151 defp apply_feed_field(feed, _name, _text), do: feed 134 152 135 - defp finalize_entry(%Entry{id: nil, url: url} = entry) when is_binary(url) do 136 - %{entry | id: url} 137 - end 153 + defp finalize_entry(%Entry{} = entry) do 154 + id = 155 + if is_nil(entry.id) and is_binary(entry.url) do 156 + entry.url 157 + else 158 + entry.id 159 + end 138 160 139 - defp finalize_entry(entry), do: entry 161 + %{entry | id: id, categories: Enum.reverse(entry.categories)} 162 + end 140 163 141 164 defp text(parts) do 142 165 result =
+1
test/annot_at/feeds/atom_test.exs
··· 27 27 assert "<p>The full <strong>content</strong>\n of the first post.</p>" == first.content 28 28 assert %DateTime{} = first.published_at 29 29 assert ~U[2024-10-02 13:00:00Z] == first.published_at 30 + assert ["category-1", "category-2"] = first.categories 30 31 end 31 32 32 33 test "falls back to URL as ID when id is missing" do
+2
test/support/fixtures/feeds/atom_sample.xml
··· 17 17 of the first post.&lt;/p&gt;</content> 18 18 <published>2024-10-02T13:00:00Z</published> 19 19 <updated>2024-10-03T09:00:00Z</updated> 20 + <category term="category-1" /> 21 + <category term="category-2" /> 20 22 </entry> 21 23 <entry> 22 24 <title>Second Post</title>