From f18392c899211b18091bd4ed9443a4f5e2f6d473 Mon Sep 17 00:00:00 2001 From: omicron Date: Wed, 26 Aug 2026 05:07:15 +0200 Subject: [PATCH] Add Atom feed generation --- omicron/ssg/output/__init__.py | 3 ++ omicron/ssg/output/feed.py | 83 ++++++++++++++++++++++++++++++++++ omicron/ssg/site.py | 7 ++- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 omicron/ssg/output/feed.py diff --git a/omicron/ssg/output/__init__.py b/omicron/ssg/output/__init__.py index 811de7b..2c3c6d6 100644 --- a/omicron/ssg/output/__init__.py +++ b/omicron/ssg/output/__init__.py @@ -8,6 +8,7 @@ from omicron.ssg.output.directory import Directory from omicron.ssg.output.symlink import Symlink from omicron.ssg.output.tags import Tags, discover_tags from omicron.ssg.output.sitemap import Sitemap +from omicron.ssg.output.feed import Feed, discover_feeds __all__ = [ "Output", @@ -25,4 +26,6 @@ __all__ = [ "Tags", "discover_tags", "Sitemap", + "Feed", + "discover_feeds", ] diff --git a/omicron/ssg/output/feed.py b/omicron/ssg/output/feed.py new file mode 100644 index 0000000..1a0b533 --- /dev/null +++ b/omicron/ssg/output/feed.py @@ -0,0 +1,83 @@ +import logging +from datetime import date +from pathlib import Path +from typing import TYPE_CHECKING, Literal +from xml.etree.ElementTree import Element, ElementTree, SubElement +from omicron.ssg.output.output import Output +from omicron.ssg.output.content import Content + +log = logging.getLogger(__name__) + +if TYPE_CHECKING: + from omicron.ssg.site import Site # for static checking with mypy +else: + Site = "omicron.ssg.site.Site" # for runtime checking with beartype + +ITEMS_PER_FEED = 20 + + +def atom_date(d: date) -> str: + return f"{d.isoformat()}T00:00:00Z" + + +class Feed(Output): + NAMESPACE = "http://www.w3.org/2005/Atom" + + def __init__( + self, + site: Site, + kind: Literal["root", "section"], + items: list[Content], + label: str | None, + ): + super().__init__(site, Feed.build_path(kind, label)) + self.kind = kind + self.label = label + self.items = sorted(items, key=lambda c: c.created, reverse=True)[ + :ITEMS_PER_FEED + ] + + @staticmethod + def build_path(kind: Literal["root", "section"], label: str | None) -> Path: + file = Path("atom.xml") + if kind == "root": + return file + assert label, "label can't be None if kind is not root" + return Path(label) / file + + def write(self) -> None: + feed_url = f"{self.site.origin}{self.url}" + title = f"{self.site.name} — {self.label}" if self.label else self.site.name + updated = max( + (c.updated or c.created for c in self.items), default=date.today() + ) + + feed = Element("feed", xmlns=Feed.NAMESPACE) + SubElement(feed, "title").text = title + SubElement(feed, "id").text = feed_url + SubElement(feed, "link", rel="self", href=feed_url) + SubElement(feed, "updated").text = atom_date(updated) + + for item in self.items: + entry = SubElement(feed, "entry") + item_url = f"{self.site.origin}{item.url}" + SubElement(entry, "title").text = item.title + SubElement(entry, "id").text = item_url + SubElement(entry, "link", href=item_url) + SubElement(entry, "published").text = atom_date(item.created) + SubElement(entry, "updated").text = atom_date(item.updated or item.created) + if item.summary: + summary = SubElement(entry, "summary", type="html") + summary.text = str(item.summary) + + dest = self.site.output_path / self.destination + dest.parent.mkdir(parents=True, exist_ok=True) + ElementTree(feed).write(dest, encoding="utf-8", xml_declaration=True) + log.debug("Wrote %s", dest) + + +def discover_feeds(site: Site) -> list[Feed]: + outputs = [Feed(site, "root", site.content, None)] + for section, items in site.by_section.items(): + outputs.append(Feed(site, "section", items, section)) + return outputs diff --git a/omicron/ssg/site.py b/omicron/ssg/site.py index d7d8da2..4bd04fa 100644 --- a/omicron/ssg/site.py +++ b/omicron/ssg/site.py @@ -16,8 +16,10 @@ from omicron.ssg.output import ( Tags, Memory, Sitemap, + Feed, discover_index, discover_tags, + discover_feeds, ) from omicron.ssg.markdown import highlight_style @@ -199,8 +201,11 @@ class Site: sitemap = Sitemap(self) self.add_by_path(sitemap) self.other_outputs.append(sitemap) + for feed in discover_feeds(self): + self.add_by_path(feed) + self.other_outputs.append(feed) else: - log.debug("Skipping sitemap, base_url has no domain name") + log.debug("Skipping sitemap and feeds, base_url has no domain name") log.info("Discovered %d content items", len(self.content)) def clean(self) -> None: