use std::io::Write;
use chrono::Utc;
use rss::{ChannelBuilder, GuidBuilder, Item, ItemBuilder};
use thiserror::Error;
use tracing::debug;
use typstify_core::{Config, Page};
#[derive(Debug, Error)]
pub enum RssError {
#[error("RSS build error: {0}")]
Build(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, RssError>;
#[derive(Debug)]
pub struct RssGenerator {
config: Config,
}
impl RssGenerator {
#[must_use]
pub fn new(config: Config) -> Self {
Self { config }
}
pub fn generate(&self, pages: &[&Page]) -> Result<String> {
let limit = self.config.rss.limit;
let pages: Vec<_> = pages.iter().take(limit).collect();
debug!(count = pages.len(), limit, "generating RSS feed");
let items: Vec<Item> = pages
.iter()
.filter_map(|page| self.page_to_item(page))
.collect();
let channel = ChannelBuilder::default()
.title(&self.config.site.title)
.link(&self.config.site.base_url)
.description(
self.config
.site
.description
.as_deref()
.unwrap_or(&self.config.site.title),
)
.language(Some(self.config.site.default_language.clone()))
.last_build_date(Some(Utc::now().to_rfc2822()))
.items(items)
.build();
Ok(channel.to_string())
}
pub fn generate_for_lang(&self, pages: &[&Page], lang: &str) -> Result<String> {
let limit = self.config.rss.limit;
let pages: Vec<_> = pages.iter().take(limit).collect();
debug!(
count = pages.len(),
limit, lang, "generating language-specific RSS feed"
);
let items: Vec<Item> = pages
.iter()
.filter_map(|page| self.page_to_item(page))
.collect();
let title = self.config.title_for_language(lang);
let description = self.config.description_for_language(lang).unwrap_or(title);
let link = if lang == self.config.site.default_language {
self.config.site.base_url.clone()
} else {
format!("{}/{}", self.config.site.base_url, lang)
};
let channel = ChannelBuilder::default()
.title(title)
.link(&link)
.description(description)
.language(Some(lang.to_string()))
.last_build_date(Some(Utc::now().to_rfc2822()))
.items(items)
.build();
Ok(channel.to_string())
}
fn page_to_item(&self, page: &Page) -> Option<Item> {
let url = format!("{}{}", self.config.site.base_url, page.url);
let guid = GuidBuilder::default().value(&url).permalink(true).build();
let mut builder = ItemBuilder::default();
builder.title(Some(page.title.clone()));
builder.link(Some(url.clone()));
builder.guid(Some(guid));
if let Some(date) = page.date {
builder.pub_date(Some(date.to_rfc2822()));
}
if let Some(desc) = &page.description {
builder.description(Some(desc.clone()));
} else if let Some(summary) = &page.summary {
builder.description(Some(summary.clone()));
}
if let Some(author) = &self.config.site.author {
builder.author(Some(author.clone()));
}
let categories: Vec<_> = page
.tags
.iter()
.map(|tag| rss::Category {
name: tag.clone(),
domain: None,
})
.collect();
if !categories.is_empty() {
builder.categories(categories);
}
Some(builder.build())
}
pub fn write_to<W: Write>(&self, pages: &[&Page], writer: &mut W) -> Result<()> {
let xml = self.generate(pages)?;
writer.write_all(xml.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, path::PathBuf};
use chrono::{DateTime, Utc};
use super::*;
fn test_config() -> Config {
Config {
site: typstify_core::config::SiteConfig {
title: "Test Blog".to_string(),
base_url: "https://example.com".to_string(),
default_language: "en".to_string(),
description: Some("A test blog".to_string()),
author: Some("Test Author".to_string()),
},
languages: HashMap::new(),
build: typstify_core::config::BuildConfig::default(),
search: typstify_core::config::SearchConfig::default(),
rss: typstify_core::config::RssConfig {
enabled: true,
limit: 20,
},
robots: typstify_core::config::RobotsConfig::default(),
taxonomies: typstify_core::config::TaxonomyConfig::default(),
}
}
fn test_page(title: &str, date: Option<DateTime<Utc>>) -> Page {
Page {
url: format!("/{}", title.to_lowercase().replace(' ', "-")),
title: title.to_string(),
description: Some(format!("Description for {}", title)),
date,
updated: None,
draft: false,
lang: "en".to_string(),
is_default_lang: true,
canonical_id: title.to_lowercase().replace(' ', "-"),
tags: vec!["rust".to_string(), "web".to_string()],
categories: vec![],
content: String::new(),
summary: None,
reading_time: None,
word_count: None,
toc: vec![],
custom_js: vec![],
custom_css: vec![],
aliases: vec![],
template: None,
weight: 0,
source_path: Some(PathBuf::from("test.md")),
}
}
#[test]
fn test_generate_rss() {
let generator = RssGenerator::new(test_config());
let page1 = test_page("First Post", Some(Utc::now()));
let page2 = test_page("Second Post", Some(Utc::now()));
let pages: Vec<&Page> = vec![&page1, &page2];
let xml = generator.generate(&pages).unwrap();
assert!(xml.contains("<title>Test Blog</title>"));
assert!(xml.contains("<link>https://example.com</link>"));
assert!(xml.contains("First Post"));
assert!(xml.contains("Second Post"));
assert!(xml.contains("<category>rust</category>"));
}
#[test]
fn test_rss_limit() {
let mut config = test_config();
config.rss.limit = 1;
let generator = RssGenerator::new(config);
let page1 = test_page("First Post", Some(Utc::now()));
let page2 = test_page("Second Post", Some(Utc::now()));
let pages: Vec<&Page> = vec![&page1, &page2];
let xml = generator.generate(&pages).unwrap();
assert!(xml.contains("First Post"));
assert!(!xml.contains("Second Post"));
}
#[test]
fn test_page_to_item() {
let generator = RssGenerator::new(test_config());
let page = test_page("Test Post", Some(Utc::now()));
let item = generator.page_to_item(&page).unwrap();
assert_eq!(item.title(), Some("Test Post"));
assert!(item.link().is_some_and(|l| l.contains("/test-post")));
assert!(item.pub_date().is_some());
}
}