typstify_generator/
rss.rs1use 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#[derive(Debug, Error)]
15pub enum RssError {
16 #[error("RSS build error: {0}")]
18 Build(String),
19
20 #[error("IO error: {0}")]
22 Io(#[from] std::io::Error),
23}
24
25pub type Result<T> = std::result::Result<T, RssError>;
27
28#[derive(Debug)]
30pub struct RssGenerator {
31 config: Config,
32}
33
34impl RssGenerator {
35 #[must_use]
37 pub fn new(config: Config) -> Self {
38 Self { config }
39 }
40
41 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 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 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 if let Some(date) = page.date {
95 builder.pub_date(Some(date.to_rfc2822()));
96 }
97
98 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 if let Some(author) = &self.config.site.author {
107 builder.author(Some(author.clone()));
108 }
109
110 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 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}