From e0d50db632f7a2167b01d498dbb3241419556158 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 03:56:48 +0000 Subject: [PATCH] Add RSS 2.0 and Atom 1.0 feed support Introduces `maudit::feed` module with `RssFeed`, `RssItem`, `AtomFeed`, and `AtomEntry` builder types. Feeds are implemented as regular routes returning these types, which convert to `RenderResult::Raw` via `From` impls. Adds a `/feed.xml` route to the blog example to demonstrate usage. https://claude.ai/code/session_01Lvoa6K3XPhuijkc6DQYY5L --- crates/maudit/src/feed.rs | 753 +++++++++++++++++++++++++++++++ crates/maudit/src/lib.rs | 1 + examples/blog/src/main.rs | 5 +- examples/blog/src/routes/feed.rs | 34 ++ 4 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 crates/maudit/src/feed.rs create mode 100644 examples/blog/src/routes/feed.rs diff --git a/crates/maudit/src/feed.rs b/crates/maudit/src/feed.rs new file mode 100644 index 00000000..a49e4a27 --- /dev/null +++ b/crates/maudit/src/feed.rs @@ -0,0 +1,753 @@ +//! RSS 2.0 and Atom 1.0 feed generation. +//! +//! Feeds are implemented as regular routes that return a [`RssFeed`] or [`AtomFeed`] value. +//! This gives you full control over feed content and lets you have multiple feeds +//! (per-category, per-author, etc.) without any configuration overhead. +//! +//! ## Example +//! +//! ```rust +//! use maudit::route::prelude::*; +//! use maudit::feed::{RssFeed, RssItem}; +//! # use maudit::content::markdown_entry; +//! # +//! # #[markdown_entry] +//! # pub struct ArticleContent { +//! # pub title: String, +//! # pub description: String, +//! # } +//! +//! #[route("/feed.xml")] +//! pub struct Feed; +//! +//! impl Route for Feed { +//! fn render(&self, ctx: &mut PageContext) -> impl Into { +//! let articles = ctx.content.get_source::("articles"); +//! let base = ctx.base_url.as_deref().unwrap_or(""); +//! +//! RssFeed::new( +//! "My Blog", +//! ctx.canonical_url().unwrap_or_default(), +//! "Latest articles from my blog", +//! ) +//! .items(articles.entries.iter().map(|entry| { +//! let data = entry.data(ctx); +//! RssItem::new( +//! data.title.clone(), +//! format!("{}/articles/{}", base, entry.id), +//! ) +//! .description(&data.description) +//! })) +//! } +//! } +//! ``` + +use crate::route::RenderResult; + +// --------------------------------------------------------------------------- +// XML helpers +// --------------------------------------------------------------------------- + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Wraps content in a CDATA section, escaping any `]]>` sequences within. +fn cdata(s: &str) -> String { + format!("", s.replace("]]>", "]]]]>")) +} + +// --------------------------------------------------------------------------- +// RssItem +// --------------------------------------------------------------------------- + +/// A single item (entry) in an RSS 2.0 feed. +/// +/// ## Example +/// ```rust +/// use maudit::feed::RssItem; +/// +/// let item = RssItem::new("My Article", "https://example.com/articles/my-article") +/// .description("A short summary of the article.") +/// .pub_date(Some("2026-03-04T00:00:00Z")) +/// .content("

Full HTML content here.

"); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RssItem { + title: String, + /// Full URL to the page. + link: String, + /// Short excerpt or summary. Shown in feed readers that don't display full content. + description: Option, + /// Publication date. Accepts ISO 8601 (`2026-03-04T00:00:00Z`) or RFC 2822 formats. + pub_date: Option, + /// Author email address (RSS 2.0 convention: `name@example.com (Display Name)`). + author: Option, + /// Full HTML content, emitted as ``. + content: Option, + /// Unique identifier for the item. Defaults to [`link`](Self::link) if not set. + guid: Option, + categories: Vec, +} + +impl RssItem { + pub fn new(title: impl Into, link: impl Into) -> Self { + Self { + title: title.into(), + link: link.into(), + ..Default::default() + } + } + + /// Short excerpt or summary displayed in feed readers. + pub fn description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Publication date. Accepts ISO 8601 (`2026-03-04T00:00:00Z`) or RFC 2822 formats. + pub fn pub_date(mut self, date: Option<&str>) -> Self { + self.pub_date = date.map(str::to_owned); + self + } + + /// Author of the item (RSS 2.0: `email@example.com (Name)`). + pub fn author(mut self, author: impl Into) -> Self { + self.author = Some(author.into()); + self + } + + /// Full HTML content emitted as ``. + pub fn content(mut self, html: impl Into) -> Self { + self.content = Some(html.into()); + self + } + + /// Override the item GUID. Defaults to the item link. + pub fn guid(mut self, guid: impl Into) -> Self { + self.guid = Some(guid.into()); + self + } + + /// Add a category tag to the item. + pub fn category(mut self, cat: impl Into) -> Self { + self.categories.push(cat.into()); + self + } + + fn render_xml(&self) -> String { + let mut out = String::from(" \n"); + + out.push_str(&format!(" {}\n", xml_escape(&self.title))); + out.push_str(&format!(" {}\n", xml_escape(&self.link))); + + let guid = self.guid.as_deref().unwrap_or(&self.link); + out.push_str(&format!( + " {}\n", + xml_escape(guid) + )); + + if let Some(desc) = &self.description { + out.push_str(&format!( + " {}\n", + xml_escape(desc) + )); + } + + if let Some(date) = &self.pub_date { + out.push_str(&format!(" {}\n", xml_escape(date))); + } + + if let Some(author) = &self.author { + out.push_str(&format!( + " {}\n", + xml_escape(author) + )); + } + + for cat in &self.categories { + out.push_str(&format!( + " {}\n", + xml_escape(cat) + )); + } + + if let Some(html) = &self.content { + out.push_str(&format!( + " {}\n", + cdata(html) + )); + } + + out.push_str(" \n"); + out + } +} + +// --------------------------------------------------------------------------- +// RssFeed +// --------------------------------------------------------------------------- + +/// An RSS 2.0 feed. +/// +/// Implements [`Into`] so it can be returned directly from a route's `render` method. +/// +/// ## Example +/// ```rust +/// use maudit::route::prelude::*; +/// use maudit::feed::{RssFeed, RssItem}; +/// +/// #[route("/feed.xml")] +/// pub struct Feed; +/// +/// impl Route for Feed { +/// fn render(&self, ctx: &mut PageContext) -> impl Into { +/// RssFeed::new("My Blog", "https://example.com", "Latest posts") +/// .language("en") +/// } +/// } +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RssFeed { + title: String, + /// Canonical URL of the website or section this feed covers. + link: String, + description: String, + language: Option, + /// Time-to-live in minutes — how long clients may cache the feed. + ttl: Option, + items: Vec, +} + +impl RssFeed { + pub fn new( + title: impl Into, + link: impl Into, + description: impl Into, + ) -> Self { + Self { + title: title.into(), + link: link.into(), + description: description.into(), + ..Default::default() + } + } + + /// BCP 47 language tag for the feed (e.g. `"en"`, `"fr"`, `"pt-BR"`). + pub fn language(mut self, lang: impl Into) -> Self { + self.language = Some(lang.into()); + self + } + + /// How many minutes clients may cache the feed before re-fetching. + pub fn ttl(mut self, minutes: u32) -> Self { + self.ttl = Some(minutes); + self + } + + /// Add items to the feed from any iterator of [`RssItem`]. + pub fn items(mut self, items: impl IntoIterator) -> Self { + self.items.extend(items); + self + } + + fn render_xml(&self) -> String { + let mut out = String::from( + "\n\ + \n\ + \n", + ); + + out.push_str(&format!( + " {}\n", + xml_escape(&self.title) + )); + out.push_str(&format!( + " {}\n", + xml_escape(&self.link) + )); + out.push_str(&format!( + " {}\n", + xml_escape(&self.description) + )); + + if let Some(lang) = &self.language { + out.push_str(&format!(" {}\n", xml_escape(lang))); + } + + if let Some(ttl) = self.ttl { + out.push_str(&format!(" {ttl}\n")); + } + + for item in &self.items { + out.push_str(&item.render_xml()); + } + + out.push_str(" \n\n"); + out + } +} + +impl From for RenderResult { + fn from(feed: RssFeed) -> RenderResult { + RenderResult::Raw(feed.render_xml().into_bytes()) + } +} + +// --------------------------------------------------------------------------- +// AtomEntry +// --------------------------------------------------------------------------- + +/// A single entry in an Atom 1.0 feed. +/// +/// ## Example +/// ```rust +/// use maudit::feed::AtomEntry; +/// +/// let entry = AtomEntry::new("My Article", "https://example.com/articles/my-article") +/// .summary("A short summary.") +/// .updated("2026-03-04T00:00:00Z") +/// .content("

Full HTML content.

"); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AtomEntry { + title: String, + /// Full URL to the page (used as both link and id). + link: String, + summary: Option, + /// ISO 8601 timestamp. Required by the Atom spec — defaults to an empty string if not provided. + updated: Option, + /// ISO 8601 timestamp for initial publication. + published: Option, + author_name: Option, + author_email: Option, + /// Full HTML content. + content: Option, + /// Override the entry id. Defaults to [`link`](Self::link). + id: Option, + categories: Vec, +} + +impl AtomEntry { + pub fn new(title: impl Into, link: impl Into) -> Self { + Self { + title: title.into(), + link: link.into(), + ..Default::default() + } + } + + /// Short summary displayed in feed readers that don't show full content. + pub fn summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + /// ISO 8601 last-modified timestamp (e.g. `"2026-03-04T00:00:00Z"`). Required by Atom spec. + pub fn updated(mut self, date: impl Into) -> Self { + self.updated = Some(date.into()); + self + } + + /// ISO 8601 initial publication timestamp. + pub fn published(mut self, date: impl Into) -> Self { + self.published = Some(date.into()); + self + } + + /// Display name of the entry author. + pub fn author_name(mut self, name: impl Into) -> Self { + self.author_name = Some(name.into()); + self + } + + /// Email address of the entry author. + pub fn author_email(mut self, email: impl Into) -> Self { + self.author_email = Some(email.into()); + self + } + + /// Full HTML body of the entry. + pub fn content(mut self, html: impl Into) -> Self { + self.content = Some(html.into()); + self + } + + /// Override the entry ``. Defaults to the entry link. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Add a category term to the entry. + pub fn category(mut self, term: impl Into) -> Self { + self.categories.push(term.into()); + self + } + + fn render_xml(&self) -> String { + let mut out = String::from(" \n"); + + out.push_str(&format!( + " {}\n", + xml_escape(&self.title) + )); + out.push_str(&format!( + " \n", + xml_escape(&self.link) + )); + + let id = self.id.as_deref().unwrap_or(&self.link); + out.push_str(&format!(" {}\n", xml_escape(id))); + + let updated = self.updated.as_deref().unwrap_or(""); + out.push_str(&format!( + " {}\n", + xml_escape(updated) + )); + + if let Some(published) = &self.published { + out.push_str(&format!( + " {}\n", + xml_escape(published) + )); + } + + if self.author_name.is_some() || self.author_email.is_some() { + out.push_str(" \n"); + if let Some(name) = &self.author_name { + out.push_str(&format!(" {}\n", xml_escape(name))); + } + if let Some(email) = &self.author_email { + out.push_str(&format!(" {}\n", xml_escape(email))); + } + out.push_str(" \n"); + } + + if let Some(summary) = &self.summary { + out.push_str(&format!( + " {}\n", + xml_escape(summary) + )); + } + + for cat in &self.categories { + out.push_str(&format!( + " \n", + xml_escape(cat) + )); + } + + if let Some(html) = &self.content { + out.push_str(&format!( + " {}\n", + cdata(html) + )); + } + + out.push_str(" \n"); + out + } +} + +// --------------------------------------------------------------------------- +// AtomFeed +// --------------------------------------------------------------------------- + +/// An Atom 1.0 feed. +/// +/// Implements [`Into`] so it can be returned directly from a route's `render` method. +/// +/// ## Example +/// ```rust +/// use maudit::route::prelude::*; +/// use maudit::feed::{AtomFeed, AtomEntry}; +/// +/// #[route("/feed.atom")] +/// pub struct Feed; +/// +/// impl Route for Feed { +/// fn render(&self, ctx: &mut PageContext) -> impl Into { +/// AtomFeed::new("My Blog", "https://example.com") +/// .self_link("https://example.com/feed.atom") +/// .updated("2026-03-04T00:00:00Z") +/// } +/// } +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AtomFeed { + title: String, + /// Canonical URL of the website or section. + link: String, + /// URL of this feed document itself (the `rel="self"` link). + self_link: Option, + /// ISO 8601 timestamp of the most recent update across all entries. + updated: Option, + author_name: Option, + author_email: Option, + subtitle: Option, + /// Override the feed ``. Defaults to [`link`](Self::link). + id: Option, + entries: Vec, +} + +impl AtomFeed { + pub fn new(title: impl Into, link: impl Into) -> Self { + Self { + title: title.into(), + link: link.into(), + ..Default::default() + } + } + + /// URL of this feed document (used as ``). + pub fn self_link(mut self, url: impl Into) -> Self { + self.self_link = Some(url.into()); + self + } + + /// ISO 8601 timestamp of the most recent update in the feed. + pub fn updated(mut self, date: impl Into) -> Self { + self.updated = Some(date.into()); + self + } + + /// Display name of the feed-level author. + pub fn author_name(mut self, name: impl Into) -> Self { + self.author_name = Some(name.into()); + self + } + + /// Email of the feed-level author. + pub fn author_email(mut self, email: impl Into) -> Self { + self.author_email = Some(email.into()); + self + } + + /// Short tagline or subtitle for the feed. + pub fn subtitle(mut self, subtitle: impl Into) -> Self { + self.subtitle = Some(subtitle.into()); + self + } + + /// Override the feed ``. Defaults to the feed link. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Add entries to the feed from any iterator of [`AtomEntry`]. + pub fn entries(mut self, entries: impl IntoIterator) -> Self { + self.entries.extend(entries); + self + } + + fn render_xml(&self) -> String { + let mut out = String::from( + "\n\ + \n", + ); + + out.push_str(&format!( + " {}\n", + xml_escape(&self.title) + )); + out.push_str(&format!( + " \n", + xml_escape(&self.link) + )); + + if let Some(self_link) = &self.self_link { + out.push_str(&format!( + " \n", + xml_escape(self_link) + )); + } + + let id = self.id.as_deref().unwrap_or(&self.link); + out.push_str(&format!(" {}\n", xml_escape(id))); + + let updated = self.updated.as_deref().unwrap_or(""); + out.push_str(&format!(" {}\n", xml_escape(updated))); + + if let Some(subtitle) = &self.subtitle { + out.push_str(&format!( + " {}\n", + xml_escape(subtitle) + )); + } + + if self.author_name.is_some() || self.author_email.is_some() { + out.push_str(" \n"); + if let Some(name) = &self.author_name { + out.push_str(&format!(" {}\n", xml_escape(name))); + } + if let Some(email) = &self.author_email { + out.push_str(&format!(" {}\n", xml_escape(email))); + } + out.push_str(" \n"); + } + + for entry in &self.entries { + out.push_str(&entry.render_xml()); + } + + out.push_str("\n"); + out + } +} + +impl From for RenderResult { + fn from(feed: AtomFeed) -> RenderResult { + RenderResult::Raw(feed.render_xml().into_bytes()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_xml_escape() { + assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d"); + assert_eq!(xml_escape("\"quoted\""), ""quoted""); + assert_eq!(xml_escape("it's"), "it's"); + } + + #[test] + fn test_cdata_basic() { + assert_eq!(cdata("

Hello

"), "Hello

]]>"); + } + + #[test] + fn test_cdata_escapes_end_sequence() { + assert_eq!( + cdata("a]]>b"), + "b]]>" + ); + } + + #[test] + fn test_rss_feed_minimal() { + let feed = RssFeed::new("My Blog", "https://example.com", "A blog"); + let xml = feed.render_xml(); + assert!(xml.contains("My Blog")); + assert!(xml.contains("https://example.com")); + assert!(xml.contains("A blog")); + assert!(xml.contains("")); + assert!(xml.contains("")); + } + + #[test] + fn test_rss_feed_with_item() { + let feed = RssFeed::new("My Blog", "https://example.com", "A blog") + .language("en") + .ttl(60) + .items([ + RssItem::new("First Post", "https://example.com/posts/first") + .description("Summary here") + .pub_date(Some("2026-03-04T00:00:00Z")) + .author("author@example.com (Author)") + .content("

Full content

") + .category("rust"), + ]); + + let xml = feed.render_xml(); + assert!(xml.contains("en")); + assert!(xml.contains("60")); + assert!(xml.contains("First Post")); + assert!(xml.contains("https://example.com/posts/first")); + assert!(xml.contains("Summary here")); + assert!(xml.contains("2026-03-04T00:00:00Z")); + assert!(xml.contains("")); + assert!(xml.contains("rust")); + } + + #[test] + fn test_rss_item_guid_defaults_to_link() { + let item = RssItem::new("Post", "https://example.com/post"); + let xml = item.render_xml(); + assert!(xml.contains("https://example.com/post")); + } + + #[test] + fn test_rss_into_render_result() { + let feed = RssFeed::new("Blog", "https://example.com", "desc"); + let result: RenderResult = feed.into(); + match result { + RenderResult::Raw(bytes) => { + let s = String::from_utf8(bytes).unwrap(); + assert!(s.starts_with(" panic!("expected RenderResult::Raw"), + } + } + + #[test] + fn test_atom_feed_minimal() { + let feed = AtomFeed::new("My Blog", "https://example.com") + .updated("2026-03-04T00:00:00Z"); + let xml = feed.render_xml(); + assert!(xml.contains("xmlns=\"http://www.w3.org/2005/Atom\"")); + assert!(xml.contains("My Blog")); + assert!(xml.contains("2026-03-04T00:00:00Z")); + assert!(xml.contains("")); + } + + #[test] + fn test_atom_feed_with_entry() { + let feed = AtomFeed::new("My Blog", "https://example.com") + .self_link("https://example.com/feed.atom") + .updated("2026-03-04T00:00:00Z") + .author_name("Jane") + .author_email("jane@example.com") + .subtitle("A Rust blog") + .entries([ + AtomEntry::new("First Post", "https://example.com/posts/first") + .summary("Summary here") + .updated("2026-03-04T00:00:00Z") + .published("2026-03-01T00:00:00Z") + .author_name("Jane") + .content("

Full content

") + .category("rust"), + ]); + + let xml = feed.render_xml(); + assert!(xml.contains("")); + assert!(xml.contains("A Rust blog")); + assert!(xml.contains("Jane")); + assert!(xml.contains("")); + assert!(xml.contains("2026-03-01T00:00:00Z")); + assert!(xml.contains("")); + assert!(xml.contains("")); + } + + #[test] + fn test_atom_entry_id_defaults_to_link() { + let entry = AtomEntry::new("Post", "https://example.com/post").updated("2026-01-01"); + let xml = entry.render_xml(); + assert!(xml.contains("https://example.com/post")); + } + + #[test] + fn test_atom_into_render_result() { + let feed = AtomFeed::new("Blog", "https://example.com"); + let result: RenderResult = feed.into(); + match result { + RenderResult::Raw(bytes) => { + let s = String::from_utf8(bytes).unwrap(); + assert!(s.contains("Atom")); + } + _ => panic!("expected RenderResult::Raw"), + } + } +} diff --git a/crates/maudit/src/lib.rs b/crates/maudit/src/lib.rs index ba25df66..9d24a9d3 100644 --- a/crates/maudit/src/lib.rs +++ b/crates/maudit/src/lib.rs @@ -9,6 +9,7 @@ pub mod assets; pub mod content; pub mod errors; +pub mod feed; pub mod route; pub mod routing; pub mod sitemap; diff --git a/examples/blog/src/main.rs b/examples/blog/src/main.rs index 8ba69894..8fef73b2 100644 --- a/examples/blog/src/main.rs +++ b/examples/blog/src/main.rs @@ -7,14 +7,17 @@ use maudit::{ mod routes { mod article; + mod feed; mod index; pub use article::Article; + pub use article::ArticleParams; + pub use feed::Feed; pub use index::Index; } fn main() -> Result> { coronate( - routes![routes::Index, routes::Article], + routes![routes::Index, routes::Article, routes::Feed], content_sources![ "articles" => glob_markdown::("content/articles/*.md") ], diff --git a/examples/blog/src/routes/feed.rs b/examples/blog/src/routes/feed.rs new file mode 100644 index 00000000..641764fb --- /dev/null +++ b/examples/blog/src/routes/feed.rs @@ -0,0 +1,34 @@ +use maudit::feed::{RssFeed, RssItem}; +use maudit::route::prelude::*; + +use crate::{ + content::ArticleContent, + routes::article::ArticleParams, + routes::Article, +}; + +#[route("/feed.xml")] +pub struct Feed; + +impl Route for Feed { + fn render(&self, ctx: &mut PageContext) -> impl Into { + let articles = ctx.content.get_source::("articles"); + let base = ctx.base_url.as_deref().unwrap_or(""); + + RssFeed::new( + "Maudit Example Blog", + ctx.canonical_url() + .unwrap_or_else(|| base.to_string()), + "A sample blog built with Maudit.", + ) + .language("en") + .items(articles.entries.iter().map(|entry| { + let data = entry.data(ctx); + RssItem::new( + data.title.clone(), + format!("{}{}", base, Article.url(ArticleParams { article: entry.id.clone() })), + ) + .description(&data.description) + })) + } +}