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    ///
73    /// Uses the language-specific title, description, and sets the appropriate
74    /// language code in the feed.
75    pub fn generate_for_lang(&self, pages: &[&Page], lang: &str) -> Result<String> {
76        let limit = self.config.rss.limit;
77        let pages: Vec<_> = pages.iter().take(limit).collect();
78
79        debug!(
80            count = pages.len(),
81            limit, lang, "generating language-specific RSS feed"
82        );
83
84        let items: Vec<Item> = pages
85            .iter()
86            .filter_map(|page| self.page_to_item(page))
87            .collect();
88
89        // Get language-specific title and description
90        let title = self.config.title_for_language(lang);
91        let description = self.config.description_for_language(lang).unwrap_or(title);
92
93        // Determine the link for this language feed
94        let link = if lang == self.config.site.default_language {
95            self.config.site.base_url.clone()
96        } else {
97            format!("{}/{}", self.config.site.base_url, lang)
98        };
99
100        let channel = ChannelBuilder::default()
101            .title(title)
102            .link(&link)
103            .description(description)
104            .language(Some(lang.to_string()))
105            .last_build_date(Some(Utc::now().to_rfc2822()))
106            .items(items)
107            .build();
108
109        Ok(channel.to_string())
110    }
111
112    /// Convert a page to an RSS item.
113    fn page_to_item(&self, page: &Page) -> Option<Item> {
114        let url = format!("{}{}", self.config.site.base_url, page.url);
115
116        let guid = GuidBuilder::default().value(&url).permalink(true).build();
117
118        let mut builder = ItemBuilder::default();
119        builder.title(Some(page.title.clone()));
120        builder.link(Some(url.clone()));
121        builder.guid(Some(guid));
122
123        // Add publication date
124        if let Some(date) = page.date {
125            builder.pub_date(Some(date.to_rfc2822()));
126        }
127
128        // Add description/summary
129        if let Some(desc) = &page.description {
130            builder.description(Some(desc.clone()));
131        } else if let Some(summary) = &page.summary {
132            builder.description(Some(summary.clone()));
133        }
134
135        // Add author
136        if let Some(author) = &self.config.site.author {
137            builder.author(Some(author.clone()));
138        }
139
140        // Add categories (tags)
141        let categories: Vec<_> = page
142            .tags
143            .iter()
144            .map(|tag| rss::Category {
145                name: tag.clone(),
146                domain: None,
147            })
148            .collect();
149
150        if !categories.is_empty() {
151            builder.categories(categories);
152        }
153
154        Some(builder.build())
155    }
156
157    /// Write RSS feed to a writer.
158    pub fn write_to<W: Write>(&self, pages: &[&Page], writer: &mut W) -> Result<()> {
159        let xml = self.generate(pages)?;
160        writer.write_all(xml.as_bytes())?;
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use std::{collections::HashMap, path::PathBuf};
168
169    use chrono::{DateTime, Utc};
170
171    use super::*;
172
173    fn test_config() -> Config {
174        Config {
175            site: typstify_core::config::SiteConfig {
176                title: "Test Blog".to_string(),
177                base_url: "https://example.com".to_string(),
178                default_language: "en".to_string(),
179                description: Some("A test blog".to_string()),
180                author: Some("Test Author".to_string()),
181            },
182            languages: HashMap::new(),
183            build: typstify_core::config::BuildConfig::default(),
184            search: typstify_core::config::SearchConfig::default(),
185            rss: typstify_core::config::RssConfig {
186                enabled: true,
187                limit: 20,
188            },
189            robots: typstify_core::config::RobotsConfig::default(),
190            taxonomies: typstify_core::config::TaxonomyConfig::default(),
191        }
192    }
193
194    fn test_page(title: &str, date: Option<DateTime<Utc>>) -> Page {
195        Page {
196            url: format!("/{}", title.to_lowercase().replace(' ', "-")),
197            title: title.to_string(),
198            description: Some(format!("Description for {}", title)),
199            date,
200            updated: None,
201            draft: false,
202            lang: "en".to_string(),
203            is_default_lang: true,
204            canonical_id: title.to_lowercase().replace(' ', "-"),
205            tags: vec!["rust".to_string(), "web".to_string()],
206            categories: vec![],
207            content: String::new(),
208            summary: None,
209            reading_time: None,
210            word_count: None,
211            toc: vec![],
212            custom_js: vec![],
213            custom_css: vec![],
214            aliases: vec![],
215            template: None,
216            weight: 0,
217            source_path: Some(PathBuf::from("test.md")),
218        }
219    }
220
221    #[test]
222    fn test_generate_rss() {
223        let generator = RssGenerator::new(test_config());
224        let page1 = test_page("First Post", Some(Utc::now()));
225        let page2 = test_page("Second Post", Some(Utc::now()));
226        let pages: Vec<&Page> = vec![&page1, &page2];
227
228        let xml = generator.generate(&pages).unwrap();
229
230        assert!(xml.contains("<title>Test Blog</title>"));
231        assert!(xml.contains("<link>https://example.com</link>"));
232        assert!(xml.contains("First Post"));
233        assert!(xml.contains("Second Post"));
234        assert!(xml.contains("<category>rust</category>"));
235    }
236
237    #[test]
238    fn test_rss_limit() {
239        let mut config = test_config();
240        config.rss.limit = 1;
241        let generator = RssGenerator::new(config);
242
243        let page1 = test_page("First Post", Some(Utc::now()));
244        let page2 = test_page("Second Post", Some(Utc::now()));
245        let pages: Vec<&Page> = vec![&page1, &page2];
246
247        let xml = generator.generate(&pages).unwrap();
248
249        assert!(xml.contains("First Post"));
250        assert!(!xml.contains("Second Post"));
251    }
252
253    #[test]
254    fn test_page_to_item() {
255        let generator = RssGenerator::new(test_config());
256        let page = test_page("Test Post", Some(Utc::now()));
257
258        let item = generator.page_to_item(&page).unwrap();
259
260        assert_eq!(item.title(), Some("Test Post"));
261        assert!(item.link().is_some_and(|l| l.contains("/test-post")));
262        assert!(item.pub_date().is_some());
263    }
264}