Skip to main content

mdbook_rss_feed/
feed.rs

1//! Building RSS 2.0 feed pages from collected articles.
2
3use 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
11/// One generated RSS feed file.
12///
13/// `filename` is the relative file name written into `src/` (for example
14/// `rss.xml` or `rss2.xml`). `channel` is the corresponding RSS 2.0 channel.
15pub struct FeedPage {
16    /// e.g. "rss.xml", "rss2.xml"
17    pub filename: String,
18    pub channel: Channel,
19}
20
21/// Result of building feeds for a book.
22///
23/// In simple setups this will contain a single `rss.xml` page. When
24/// pagination is enabled it contains multiple [`FeedPage`]s (e.g. `rss.xml`,
25/// `rss2.xml`, `rss3.xml`, …) each with a slice of the overall item list.
26pub struct BuildResult {
27    pub pages: Vec<FeedPage>,
28}
29
30/// Options controlling how a feed is built.
31///
32/// Grouping these avoids a long positional-argument list at the call site
33/// (see `build_feed`) and makes it cheap to add new options later without
34/// breaking every caller.
35#[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
45/// Build the absolute `.html` link for an article, given its `src`-relative
46/// markdown path.
47fn 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
55/// Build a single [`Channel`] from a slice of items.
56fn 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
69/// Split `items` into one or more [`FeedPage`]s according to `opts`.
70fn 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
102/// Build one or more RSS 2.0 feeds for an mdBook.
103///
104/// This scans `src_dir` for chapters, extracts frontmatter, generates HTML
105/// previews, and returns a [`BuildResult`] containing one or more
106/// [`FeedPage`]s. The first page is always `rss.xml`; when `opts.paginated`
107/// is `true` and `opts.max_items > 0`, additional pages `rss2.xml`,
108/// `rss3.xml`, … are created.
109///
110/// # Errors
111/// Returns `Err` if:
112/// - `src_dir` can't be accessed or doesn't exist
113/// - reading or walking the directory tree fails
114pub 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}