use crate::collections::Collection;
use crate::content::Site;
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::io::Write;
use std::path::Path;
pub fn generate_rss(site: &Site, output_dir: &Path, base_url: &str) -> Result<()> {
let blog_posts: Vec<_> = site
.pages
.iter()
.filter(|p| p.collection == Collection::Blog && !p.is_index)
.collect();
if blog_posts.is_empty() {
tracing::info!("No blog posts found, skipping RSS generation");
return Ok(());
}
let rss_path = output_dir.join("feed.xml");
let mut file = std::fs::File::create(&rss_path)?;
let last_build_date = blog_posts
.iter()
.filter_map(|p| p.date)
.max()
.unwrap_or_else(Utc::now);
write!(file, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
write!(
file,
r#"<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">"#
)?;
write!(file, "<channel>")?;
write!(file, "<title>Blog</title>")?;
write!(
file,
"<link>{}</link>",
xml_escape(&format!("{}/blog/", base_url))
)?;
write!(file, "<description>Blog posts</description>")?;
write!(
file,
"<lastBuildDate>{}</lastBuildDate>",
format_rfc2822(last_build_date)
)?;
write!(
file,
r#"<atom:link href="{}/feed.xml" rel="self" type="application/rss+xml"/>"#,
xml_escape(base_url)
)?;
let mut sorted_posts = blog_posts;
sorted_posts.sort_by(|a, b| b.date.cmp(&a.date));
for post in sorted_posts.iter().take(20) {
write!(file, "<item>")?;
write!(file, "<title>{}</title>", xml_escape(&post.title))?;
write!(
file,
"<link>{}{}</link>",
xml_escape(base_url),
xml_escape(&post.url)
)?;
write!(
file,
"<guid isPermaLink=\"true\">{}{}</guid>",
xml_escape(base_url),
xml_escape(&post.url)
)?;
if let Some(date) = post.date {
write!(file, "<pubDate>{}</pubDate>", format_rfc2822(date))?;
}
let description = extract_description(&post.html_content, 500);
write!(
file,
"<description>{}</description>",
xml_escape(&description)
)?;
write!(file, "</item>")?;
}
write!(file, "</channel>")?;
write!(file, "</rss>")?;
tracing::info!("Generated RSS feed: {:?}", rss_path);
Ok(())
}
fn format_rfc2822(dt: DateTime<Utc>) -> String {
dt.format("%a, %d %b %Y %H:%M:%S +0000").to_string()
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn extract_description(html: &str, max_chars: usize) -> String {
let mut text = String::new();
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => text.push(ch),
_ => {}
}
}
let text: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
let char_count = text.chars().count();
if char_count <= max_chars {
text
} else {
let truncated: String = text.chars().take(max_chars).collect();
match truncated.rfind(' ') {
Some(idx) => format!("{}...", &truncated[..idx]),
None => format!("{}...", truncated),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("<test>"), "<test>");
assert_eq!(xml_escape("a & b"), "a & b");
}
#[test]
fn test_extract_description() {
let html = "<p>Hello <strong>world</strong>!</p>";
let desc = extract_description(html, 100);
assert_eq!(desc, "Hello world!");
}
#[test]
fn test_extract_description_truncate() {
let html = "<p>This is a long description that should be truncated</p>";
let desc = extract_description(html, 20);
assert!(desc.ends_with("..."));
assert!(desc.len() <= 25);
}
}