bim 0.1.0

A content-first static site generator for blogs, digital gardens, and knowledge bases
Documentation
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)?;

    // Get most recent post date for lastBuildDate
    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)
    )?;

    // Sort by date (newest first)
    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))?;
        }

        // Use first 500 chars of content as description
        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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

fn extract_description(html: &str, max_chars: usize) -> String {
    // Simple HTML tag stripping
    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),
            _ => {}
        }
    }

    // Clean up whitespace
    let text: String = text.split_whitespace().collect::<Vec<_>>().join(" ");

    // Count characters, not bytes (important for multi-byte characters like Arabic)
    let char_count = text.chars().count();
    if char_count <= max_chars {
        text
    } else {
        // Truncate at character boundary
        let truncated: String = text.chars().take(max_chars).collect();
        // Try to find a word boundary (space)
        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>"), "&lt;test&gt;");
        assert_eq!(xml_escape("a & b"), "a &amp; 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);
    }
}