Skip to main content

rheo_html/
feed.rs

1//! Atom 1.0 feed model (RFC 4287).
2//!
3//! [`AtomFeed`]/[`AtomEntry`] are the rheo-side domain model; serialization to
4//! XML is delegated to the `atom_syndication` crate via [`AtomFeed::serialize`].
5//! The wiring that builds an [`AtomFeed`] from the compiled spine lives in the
6//! plugin's `compile` (Issue E).
7
8use atom_syndication as atom;
9use chrono::{DateTime, Utc};
10
11/// A single `<entry>` in the feed.
12pub struct AtomEntry {
13    pub id: String,
14    pub title: String,
15    pub updated: DateTime<Utc>,
16    /// Page body HTML, emitted as `<content type="html">` (escaped by the serializer).
17    pub content_html: String,
18    /// Target of the `rel="alternate"` link (the page URL).
19    pub alternate_href: String,
20}
21
22/// A complete Atom feed.
23pub struct AtomFeed {
24    pub id: String,
25    pub title: String,
26    pub updated: DateTime<Utc>,
27    /// Target of the `rel="self"` link (the feed URL).
28    pub self_href: String,
29    /// Feed-level author name, emitted as `<author><name>...</name></author>`.
30    pub author: String,
31    pub entries: Vec<AtomEntry>,
32}
33
34impl AtomFeed {
35    /// Render this feed to an RFC 4287 Atom XML string.
36    pub fn serialize(&self) -> String {
37        let mut author = atom::Person::default();
38        author.set_name(self.author.clone());
39
40        let entries = self
41            .entries
42            .iter()
43            .map(AtomEntry::to_atom)
44            .collect::<Vec<_>>();
45
46        let mut feed = atom::Feed::default();
47        feed.set_id(self.id.clone());
48        feed.set_title(self.title.clone());
49        feed.set_updated(self.updated.fixed_offset());
50        feed.set_authors(vec![author]);
51        feed.set_links(vec![link("self", &self.self_href)]);
52        feed.set_entries(entries);
53        feed.to_string()
54    }
55}
56
57impl AtomEntry {
58    fn to_atom(&self) -> atom::Entry {
59        let mut content = atom::Content::default();
60        content.set_value(self.content_html.clone());
61        content.set_content_type("html".to_string());
62
63        let mut entry = atom::Entry::default();
64        entry.set_id(self.id.clone());
65        entry.set_title(self.title.clone());
66        entry.set_updated(self.updated.fixed_offset());
67        entry.set_links(vec![link("alternate", &self.alternate_href)]);
68        entry.set_content(content);
69        entry
70    }
71}
72
73/// Build an Atom `<link rel="..." href="..."/>`.
74fn link(rel: &str, href: &str) -> atom::Link {
75    let mut l = atom::Link::default();
76    l.set_rel(rel);
77    l.set_href(href.to_string());
78    l
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn ts() -> DateTime<Utc> {
86        DateTime::parse_from_rfc3339("2025-01-15T00:00:00Z")
87            .unwrap()
88            .with_timezone(&Utc)
89    }
90
91    fn entry(id: &str, title: &str) -> AtomEntry {
92        AtomEntry {
93            id: id.to_string(),
94            title: title.to_string(),
95            updated: ts(),
96            content_html: "<p>Body</p>".to_string(),
97            alternate_href: format!("https://example.com/{id}.html"),
98        }
99    }
100
101    #[test]
102    fn test_serialize_has_namespace_and_feed_elements() {
103        let feed = AtomFeed {
104            id: "https://example.com/feed.xml".to_string(),
105            title: "My Blog".to_string(),
106            updated: ts(),
107            self_href: "https://example.com/feed.xml".to_string(),
108            author: "Ada Lovelace".to_string(),
109            entries: vec![entry("post", "First Post")],
110        };
111        let xml = feed.serialize();
112
113        assert!(xml.contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
114        assert!(xml.contains("<id>https://example.com/feed.xml</id>"));
115        assert!(xml.contains("<title>My Blog</title>"));
116        assert!(xml.contains("<name>Ada Lovelace</name>"));
117        assert!(xml.contains(r#"rel="self""#));
118        assert!(xml.contains(r#"href="https://example.com/feed.xml""#));
119        // Entry
120        assert!(xml.contains("<entry>"));
121        assert!(xml.contains("<title>First Post</title>"));
122        assert!(xml.contains(r#"rel="alternate""#));
123        assert!(xml.contains(r#"href="https://example.com/post.html""#));
124        // Content is type=html and HTML-escaped by the serializer
125        assert!(xml.contains(r#"type="html""#));
126        assert!(xml.contains("&lt;p&gt;Body&lt;/p&gt;"));
127    }
128
129    #[test]
130    fn test_serialize_multiple_entries() {
131        let feed = AtomFeed {
132            id: "id".to_string(),
133            title: "t".to_string(),
134            updated: ts(),
135            self_href: "self".to_string(),
136            author: "Rheo".to_string(),
137            entries: vec![entry("a", "A"), entry("b", "B")],
138        };
139        let xml = feed.serialize();
140        assert_eq!(xml.matches("<entry>").count(), 2);
141    }
142
143    #[test]
144    fn test_serialize_escapes_title() {
145        let feed = AtomFeed {
146            id: "id".to_string(),
147            title: r#"Tom & Jerry <3"#.to_string(),
148            updated: ts(),
149            self_href: "self".to_string(),
150            author: "Rheo".to_string(),
151            entries: vec![],
152        };
153        let xml = feed.serialize();
154        assert!(xml.contains("Tom &amp; Jerry &lt;3"));
155    }
156}