Skip to main content

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<'a> {
31    config: &'a Config,
32}
33
34impl<'a> RssGenerator<'a> {
35    /// Create a new RSS generator.
36    #[must_use]
37    pub fn new(config: &'a 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.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.base_url().to_owned()
96        } else {
97            format!("{}/{}", self.config.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.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::path::PathBuf;
168
169    use chrono::{DateTime, Utc};
170    use typstify_core::test_fixtures::test_config;
171
172    use super::*;
173
174    fn test_page(title: &str, date: Option<DateTime<Utc>>) -> Page {
175        Page {
176            url: format!("/{}", title.to_lowercase().replace(' ', "-")),
177            title: title.to_string(),
178            description: Some(format!("Description for {}", title)),
179            date,
180            updated: None,
181            draft: false,
182            lang: "en".to_string(),
183            is_default_lang: true,
184            canonical_id: title.to_lowercase().replace(' ', "-"),
185            tags: vec!["rust".to_string(), "web".to_string()],
186            categories: vec![],
187            content: String::new(),
188            summary: None,
189            reading_time: None,
190            word_count: None,
191            toc: vec![],
192            custom_js: vec![],
193            custom_css: vec![],
194            aliases: vec![],
195            template: None,
196            weight: 0,
197            source_path: Some(PathBuf::from("test.md")),
198        }
199    }
200
201    #[test]
202    fn test_generate_rss() {
203        let config = test_config();
204        let generator = RssGenerator::new(&config);
205        let page1 = test_page("First Post", Some(Utc::now()));
206        let page2 = test_page("Second Post", Some(Utc::now()));
207        let pages: Vec<&Page> = vec![&page1, &page2];
208
209        let xml = generator.generate(&pages).unwrap();
210
211        assert!(xml.contains("<title>Test Site</title>"));
212        assert!(xml.contains("<link>https://example.com</link>"));
213        assert!(xml.contains("First Post"));
214        assert!(xml.contains("Second Post"));
215        assert!(xml.contains("<category>rust</category>"));
216    }
217
218    #[test]
219    fn test_rss_limit() {
220        let mut config = test_config();
221        config.rss.limit = 1;
222        let generator = RssGenerator::new(&config);
223
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("First Post"));
231        assert!(!xml.contains("Second Post"));
232    }
233
234    #[test]
235    fn test_page_to_item() {
236        let config = test_config();
237        let generator = RssGenerator::new(&config);
238        let page = test_page("Test Post", Some(Utc::now()));
239
240        let item = generator.page_to_item(&page).unwrap();
241
242        assert_eq!(item.title(), Some("Test Post"));
243        assert!(item.link().is_some_and(|l| l.contains("/test-post")));
244        assert!(item.pub_date().is_some());
245    }
246}