typstify_generator/
rss.rs

1//! RSS feed generation.
2//!
3//! Generates RSS 2.0 feeds for site content.
4
5use std::io::Write;
6
7use chrono::Utc;
8use rss::{ChannelBuilder, GuidBuilder, Item, ItemBuilder};
9use thiserror::Error;
10use tracing::debug;
11use typstify_core::{Config, Page};
12
13/// RSS generation errors.
14#[derive(Debug, Error)]
15pub enum RssError {
16    /// RSS building error.
17    #[error("RSS build error: {0}")]
18    Build(String),
19
20    /// IO error.
21    #[error("IO error: {0}")]
22    Io(#[from] std::io::Error),
23}
24
25/// Result type for RSS operations.
26pub type Result<T> = std::result::Result<T, RssError>;
27
28/// RSS feed generator.
29#[derive(Debug)]
30pub struct RssGenerator {
31    config: Config,
32}
33
34impl RssGenerator {
35    /// Create a new RSS generator.
36    #[must_use]
37    pub fn new(config: Config) -> Self {
38        Self { config }
39    }
40
41    /// Generate RSS feed XML from pages.
42    pub fn generate(&self, pages: &[&Page]) -> Result<String> {
43        let limit = self.config.rss.limit;
44        let pages: Vec<_> = pages.iter().take(limit).collect();
45
46        debug!(count = pages.len(), limit, "generating RSS feed");
47
48        let items: Vec<Item> = pages
49            .iter()
50            .filter_map(|page| self.page_to_item(page))
51            .collect();
52
53        let channel = ChannelBuilder::default()
54            .title(&self.config.site.title)
55            .link(&self.config.site.base_url)
56            .description(
57                self.config
58                    .site
59                    .description
60                    .as_deref()
61                    .unwrap_or(&self.config.site.title),
62            )
63            .language(Some(self.config.site.default_language.clone()))
64            .last_build_date(Some(Utc::now().to_rfc2822()))
65            .items(items)
66            .build();
67
68        Ok(channel.to_string())
69    }
70
71    /// Generate RSS feed for a specific language.
72    pub fn generate_for_lang(&self, pages: &[&Page], lang: &str) -> Result<String> {
73        let filtered: Vec<_> = pages
74            .iter()
75            .filter(|p| p.lang.as_deref() == Some(lang) || p.lang.is_none())
76            .copied()
77            .collect();
78
79        self.generate(&filtered)
80    }
81
82    /// Convert a page to an RSS item.
83    fn page_to_item(&self, page: &Page) -> Option<Item> {
84        let url = format!("{}{}", self.config.site.base_url, page.url);
85
86        let guid = GuidBuilder::default().value(&url).permalink(true).build();
87
88        let mut builder = ItemBuilder::default();
89        builder.title(Some(page.title.clone()));
90        builder.link(Some(url.clone()));
91        builder.guid(Some(guid));
92
93        // Add publication date
94        if let Some(date) = page.date {
95            builder.pub_date(Some(date.to_rfc2822()));
96        }
97
98        // Add description/summary
99        if let Some(desc) = &page.description {
100            builder.description(Some(desc.clone()));
101        } else if let Some(summary) = &page.summary {
102            builder.description(Some(summary.clone()));
103        }
104
105        // Add author
106        if let Some(author) = &self.config.site.author {
107            builder.author(Some(author.clone()));
108        }
109
110        // Add categories (tags)
111        let categories: Vec<_> = page
112            .tags
113            .iter()
114            .map(|tag| rss::Category {
115                name: tag.clone(),
116                domain: None,
117            })
118            .collect();
119
120        if !categories.is_empty() {
121            builder.categories(categories);
122        }
123
124        Some(builder.build())
125    }
126
127    /// Write RSS feed to a writer.
128    pub fn write_to<W: Write>(&self, pages: &[&Page], writer: &mut W) -> Result<()> {
129        let xml = self.generate(pages)?;
130        writer.write_all(xml.as_bytes())?;
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::path::PathBuf;
138
139    use chrono::{DateTime, Utc};
140
141    use super::*;
142
143    fn test_config() -> Config {
144        Config {
145            site: typstify_core::config::SiteConfig {
146                title: "Test Blog".to_string(),
147                base_url: "https://example.com".to_string(),
148                default_language: "en".to_string(),
149                languages: vec!["en".to_string()],
150                description: Some("A test blog".to_string()),
151                author: Some("Test Author".to_string()),
152            },
153            build: typstify_core::config::BuildConfig::default(),
154            search: typstify_core::config::SearchConfig::default(),
155            rss: typstify_core::config::RssConfig {
156                enabled: true,
157                limit: 20,
158            },
159            taxonomies: typstify_core::config::TaxonomyConfig::default(),
160        }
161    }
162
163    fn test_page(title: &str, date: Option<DateTime<Utc>>) -> Page {
164        Page {
165            url: format!("/{}", title.to_lowercase().replace(' ', "-")),
166            title: title.to_string(),
167            description: Some(format!("Description for {}", title)),
168            date,
169            updated: None,
170            draft: false,
171            lang: None,
172            tags: vec!["rust".to_string(), "web".to_string()],
173            categories: vec![],
174            content: String::new(),
175            summary: None,
176            reading_time: None,
177            word_count: None,
178            toc: vec![],
179            custom_js: vec![],
180            custom_css: vec![],
181            aliases: vec![],
182            template: None,
183            weight: 0,
184            source_path: Some(PathBuf::from("test.md")),
185        }
186    }
187
188    #[test]
189    fn test_generate_rss() {
190        let generator = RssGenerator::new(test_config());
191        let page1 = test_page("First Post", Some(Utc::now()));
192        let page2 = test_page("Second Post", Some(Utc::now()));
193        let pages: Vec<&Page> = vec![&page1, &page2];
194
195        let xml = generator.generate(&pages).unwrap();
196
197        assert!(xml.contains("<title>Test Blog</title>"));
198        assert!(xml.contains("<link>https://example.com</link>"));
199        assert!(xml.contains("First Post"));
200        assert!(xml.contains("Second Post"));
201        assert!(xml.contains("<category>rust</category>"));
202    }
203
204    #[test]
205    fn test_rss_limit() {
206        let mut config = test_config();
207        config.rss.limit = 1;
208        let generator = RssGenerator::new(config);
209
210        let page1 = test_page("First Post", Some(Utc::now()));
211        let page2 = test_page("Second Post", Some(Utc::now()));
212        let pages: Vec<&Page> = vec![&page1, &page2];
213
214        let xml = generator.generate(&pages).unwrap();
215
216        assert!(xml.contains("First Post"));
217        assert!(!xml.contains("Second Post"));
218    }
219
220    #[test]
221    fn test_page_to_item() {
222        let generator = RssGenerator::new(test_config());
223        let page = test_page("Test Post", Some(Utc::now()));
224
225        let item = generator.page_to_item(&page).unwrap();
226
227        assert_eq!(item.title(), Some("Test Post"));
228        assert!(item.link().is_some_and(|l| l.contains("/test-post")));
229        assert!(item.pub_date().is_some());
230    }
231}