From 51b947256d83b4b9818542392480e8ce7ff968a6 Mon Sep 17 00:00:00 2001 From: Timothy DeHerrera Date: Sat, 24 Jan 2026 22:01:59 -0700 Subject: [PATCH] feat: add Atom feed generation - src/feed.rs: New module with generate_atom_feed() that produces Atom 1.0 XML from blog posts. Uses config.base_url for absolute entry URLs. Includes xml_escape() helper. - src/main.rs: Wire mod feed and call generate_feed() after blog index generation. Outputs to public/feed.xml. - src/templates.rs: Add autodiscovery link to page head using config.base_url. Feed includes title, author, updated timestamp, and entries with title, link, id, updated, and summary for each blog post. --- src/feed.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 +++++++++++ src/templates.rs | 1 + 3 files changed, 114 insertions(+) create mode 100644 src/feed.rs diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..b1afdc7 --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,93 @@ +//! Atom feed generation. + +use crate::config::SiteConfig; +use crate::content::Content; + +/// Generate an Atom 1.0 feed from blog posts. +pub fn generate_atom_feed(posts: &[Content], config: &SiteConfig) -> String { + let base_url = config.base_url.trim_end_matches('/'); + + // Use the most recent post date as feed updated time, or fallback + let updated = posts + .first() + .and_then(|p| p.frontmatter.date.as_ref()) + .map(|d| format!("{}T00:00:00Z", d)) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + + let mut entries = String::new(); + for post in posts { + let post_url = format!("{}/blog/{}.html", base_url, post.slug); + let post_date = post + .frontmatter + .date + .as_ref() + .map(|d| format!("{}T00:00:00Z", d)) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + + let summary = post + .frontmatter + .description + .as_ref() + .map(|s| xml_escape(s)) + .unwrap_or_default(); + + entries.push_str(&format!( + r#" + {} + + {} + {} + {} + +"#, + xml_escape(&post.frontmatter.title), + post_url, + post_url, + post_date, + summary, + )); + } + + format!( + r#" + + {} + + + {}/ + {} + + {} + +{} + +"#, + xml_escape(&config.title), + base_url, + base_url, + base_url, + updated, + xml_escape(&config.author), + entries, + ) +} + +/// Escape XML special characters. +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_xml_escape() { + assert_eq!(xml_escape("Hello & World"), "Hello & World"); + assert_eq!(xml_escape(""), "<tag>"); + } +} diff --git a/src/main.rs b/src/main.rs index d8072b9..90fb585 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod config; mod content; mod css; mod error; +mod feed; mod highlight; mod render; mod templates; @@ -45,6 +46,9 @@ fn run() -> Result<()> { posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date)); generate_blog_index(output_dir, &posts, &config)?; + // 2b. Generate Atom feed + generate_feed(output_dir, &posts, &config)?; + // 3. Process standalone pages (about, collab) process_pages(content_dir, output_dir, &config)?; @@ -122,6 +126,22 @@ fn generate_blog_index( Ok(()) } +/// Generate the Atom feed +fn generate_feed(output_dir: &Path, posts: &[Content], config: &config::SiteConfig) -> Result<()> { + let out_path = output_dir.join("feed.xml"); + eprintln!("generating: {}", out_path.display()); + + let feed_xml = feed::generate_atom_feed(posts, config); + + fs::write(&out_path, feed_xml).map_err(|e| Error::WriteFile { + path: out_path.clone(), + source: e, + })?; + + eprintln!(" → {}", out_path.display()); + Ok(()) +} + /// Process standalone pages in content/ (about.md, collab.md) fn process_pages(content_dir: &Path, output_dir: &Path, config: &config::SiteConfig) -> Result<()> { for name in ["about.md", "collab.md"] { diff --git a/src/templates.rs b/src/templates.rs index 9aa18b7..5030041 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -196,6 +196,7 @@ fn base_layout(title: &str, content: Markup, page_path: &str, config: &SiteConfi meta name="viewport" content="width=device-width, initial-scale=1"; title { (title) " | " (config.title) } link rel="canonical" href=(canonical_url); + link rel="alternate" type="application/atom+xml" title="Atom Feed" href=(format!("{}/feed.xml", config.base_url.trim_end_matches('/'))); link rel="stylesheet" href=(format!("{}/style.css", prefix)); } body {