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)
+ }))
+ }
+}