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> {
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 let title = self.config.title_for_language(lang);
91 let description = self.config.description_for_language(lang).unwrap_or(title);
92
93 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 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 if let Some(date) = page.date {
125 builder.pub_date(Some(date.to_rfc2822()));
126 }
127
128 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 if let Some(author) = &self.config.site.author {
137 builder.author(Some(author.clone()));
138 }
139
140 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 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}