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 {