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 <link rel="alternate" type="application/atom+xml">
  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.
This commit is contained in:
Timothy DeHerrera
2026-01-24 22:01:59 -07:00
parent 675050fd56
commit 51b947256d
3 changed files with 114 additions and 0 deletions

93
src/feed.rs Normal file
View File

@@ -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#" <entry>
<title>{}</title>
<link href="{}" rel="alternate"/>
<id>{}</id>
<updated>{}</updated>
<summary>{}</summary>
</entry>
"#,
xml_escape(&post.frontmatter.title),
post_url,
post_url,
post_date,
summary,
));
}
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{}</title>
<link href="{}" rel="alternate"/>
<link href="{}/feed.xml" rel="self"/>
<id>{}/</id>
<updated>{}</updated>
<author>
<name>{}</name>
</author>
{}
</feed>
"#,
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("Hello & World"), "Hello &amp; World");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
}
}

View File

@@ -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"] {

View File

@@ -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 {