1use std::path::Path;
4
5use rss::{Channel, ChannelBuilder, Guid, Item, ItemBuilder};
6
7use crate::article::collect_articles;
8use crate::error::Result;
9use crate::preview::render_preview;
10
11pub struct FeedPage {
16 pub filename: String,
18 pub channel: Channel,
19}
20
21pub struct BuildResult {
27 pub pages: Vec<FeedPage>,
28}
29
30#[derive(Debug, Clone)]
36pub struct FeedOptions<'a> {
37 pub title: &'a str,
38 pub site_url: &'a str,
39 pub description: &'a str,
40 pub full_preview: bool,
41 pub max_items: usize,
42 pub paginated: bool,
43}
44
45fn article_link(base_url: &str, article_path: &str) -> String {
48 let html_path = article_path
49 .replace('\\', "/")
50 .replace(".md", ".html")
51 .replace("/README.html", "/index.html");
52 format!("{base_url}/{html_path}")
53}
54
55fn build_channel(title: &str, base_url: &str, description: &str, items: &[Item]) -> Channel {
57 ChannelBuilder::default()
58 .title(title)
59 .link(format!("{base_url}/"))
60 .description(description)
61 .items(items.to_vec())
62 .generator(Some(format!(
63 "mdbook-rss-feed {}",
64 env!("CARGO_PKG_VERSION")
65 )))
66 .build()
67}
68
69fn paginate(items: &[Item], opts: &FeedOptions<'_>, base_url: &str) -> Vec<FeedPage> {
71 let mut pages = Vec::new();
72
73 let should_paginate = opts.paginated && opts.max_items > 0 && items.len() > opts.max_items;
74 if !should_paginate {
75 let channel = build_channel(opts.title, base_url, opts.description, items);
76 pages.push(FeedPage {
77 filename: "rss.xml".to_string(),
78 channel,
79 });
80 return pages;
81 }
82
83 let total_pages = items.len().div_ceil(opts.max_items);
84 for page_idx in 0..total_pages {
85 let start = page_idx * opts.max_items;
86 let end = (start + opts.max_items).min(items.len());
87 let slice = &items[start..end];
88
89 let filename = if page_idx == 0 {
90 "rss.xml".to_string()
91 } else {
92 format!("rss{}.xml", page_idx + 1)
93 };
94
95 let channel = build_channel(opts.title, base_url, opts.description, slice);
96 pages.push(FeedPage { filename, channel });
97 }
98
99 pages
100}
101
102pub fn build_feed(src_dir: &Path, opts: &FeedOptions<'_>) -> Result<BuildResult> {
115 let articles = collect_articles(src_dir)?;
116 let base_url = opts.site_url.trim_end_matches('/');
117
118 let items: Vec<Item> = articles
119 .into_iter()
120 .map(|article| {
121 let link = article_link(base_url, &article.path);
122 let preview = render_preview(
123 &article.content,
124 article.fm.description.as_deref(),
125 opts.full_preview,
126 );
127
128 let mut item = ItemBuilder::default();
129 item.title(Some(article.fm.title.clone()));
130 item.link(Some(link.clone()));
131 item.description(Some(preview));
132 item.guid(Some(Guid {
133 value: link,
134 permalink: true,
135 }));
136 if let Some(date) = article.fm.date {
137 item.pub_date(Some(date.to_rfc2822()));
138 }
139 if let Some(author) = article.fm.author {
140 item.author(Some(author));
141 }
142 item.build()
143 })
144 .collect();
145
146 Ok(BuildResult {
147 pages: paginate(&items, opts, base_url),
148 })
149}