Skip to main content

molt/
sitemap.rs

1//! XML sitemap generation.
2//!
3
4use std::fmt::Write as _;
5
6use crate::core::escape::escape_xml;
7use crate::core::url_policy::is_http_url;
8
9/// How often a page is expected to change, as the sitemap protocol spells it.
10///
11/// An enum rather than a string so `"weakly"` is a compile error instead of a sitemap that
12/// search engines quietly ignore. Use [`str::parse`] when the value arrives as text.
13///
14/// # Examples
15/// ```
16/// use molt::sitemap::ChangeFreq;
17/// assert_eq!(ChangeFreq::Weekly.as_str(), "weekly");
18/// assert_eq!("monthly".parse::<ChangeFreq>(), Ok(ChangeFreq::Monthly));
19/// assert!("weakly".parse::<ChangeFreq>().is_err());
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum ChangeFreq {
24    /// `always` — changes on every access.
25    Always,
26    /// `hourly`.
27    Hourly,
28    /// `daily`.
29    Daily,
30    /// `weekly`.
31    Weekly,
32    /// `monthly`.
33    Monthly,
34    /// `yearly`.
35    Yearly,
36    /// `never` — archived content.
37    Never,
38}
39
40impl ChangeFreq {
41    /// The value written between `<changefreq>` tags.
42    #[must_use]
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Always => "always",
46            Self::Hourly => "hourly",
47            Self::Daily => "daily",
48            Self::Weekly => "weekly",
49            Self::Monthly => "monthly",
50            Self::Yearly => "yearly",
51            Self::Never => "never",
52        }
53    }
54}
55
56impl std::fmt::Display for ChangeFreq {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(self.as_str())
59    }
60}
61
62/// The text was not one of the seven values the protocol defines.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ParseChangeFreqError(String);
65
66impl std::fmt::Display for ParseChangeFreqError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(
69            f,
70            "{:?} is not a change frequency (always, hourly, daily, weekly, monthly, yearly, never)",
71            self.0
72        )
73    }
74}
75
76impl std::error::Error for ParseChangeFreqError {}
77
78impl std::str::FromStr for ChangeFreq {
79    type Err = ParseChangeFreqError;
80
81    fn from_str(text: &str) -> Result<Self, Self::Err> {
82        Ok(match text {
83            "always" => Self::Always,
84            "hourly" => Self::Hourly,
85            "daily" => Self::Daily,
86            "weekly" => Self::Weekly,
87            "monthly" => Self::Monthly,
88            "yearly" => Self::Yearly,
89            "never" => Self::Never,
90            other => return Err(ParseChangeFreqError(other.to_owned())),
91        })
92    }
93}
94
95/// The most `<url>` entries the sitemap protocol allows in one file.
96pub const MAX_URLS: usize = 50_000;
97
98/// The largest an uncompressed sitemap file may be under the protocol: 50 MiB.
99pub const MAX_BYTES: usize = 50 * 1024 * 1024;
100
101/// One entry in a sitemap.
102///
103/// # Examples
104/// ```
105/// use molt::sitemap::{ChangeFreq, SitemapGenerator, SitemapUrl};
106///
107/// let xml = SitemapGenerator::generate(&[
108///     SitemapUrl::new("https://example.com/").changefreq(ChangeFreq::Weekly).priority(1.0),
109/// ]);
110/// assert!(xml.contains("<priority>1.0</priority>"));
111/// ```
112#[derive(Debug, Clone, PartialEq, Default)]
113#[non_exhaustive]
114pub struct SitemapUrl {
115    /// The absolute URL of the page.
116    pub loc: String,
117    /// The last modification date, typically `YYYY-MM-DD`.
118    pub lastmod: Option<String>,
119    /// How often the page changes.
120    pub changefreq: Option<ChangeFreq>,
121    /// Relative priority within the site, from `0.0` to `1.0`.
122    ///
123    /// Set through [`SitemapUrl::priority`], which clamps. A value written to this field
124    /// directly is checked again when the sitemap is rendered: out of range is clamped and
125    /// a non-finite one is left out, because `NaN` is not a number a sitemap can hold.
126    pub priority: Option<f64>,
127}
128
129impl SitemapUrl {
130    /// Creates an entry for the given URL.
131    pub fn new(loc: impl Into<String>) -> Self {
132        Self {
133            loc: loc.into(),
134            ..Self::default()
135        }
136    }
137
138    /// Sets the last modification date.
139    #[must_use]
140    pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
141        self.lastmod = Some(lastmod.into());
142        self
143    }
144
145    /// Sets the change frequency.
146    #[must_use]
147    pub fn changefreq(mut self, changefreq: ChangeFreq) -> Self {
148        self.changefreq = Some(changefreq);
149        self
150    }
151
152    /// Sets the priority, clamped to `0.0..=1.0`. A non-finite value clears it.
153    ///
154    /// Clamping rather than failing keeps the builder chainable; the protocol allows no
155    /// value outside the range, so there is nothing to preserve.
156    #[must_use]
157    pub fn priority(mut self, priority: f64) -> Self {
158        self.priority = valid_priority(priority);
159        self
160    }
161}
162
163/// Renders sitemaps and sitemap indexes.
164#[derive(Debug, Clone, Copy)]
165pub struct SitemapGenerator;
166
167impl SitemapGenerator {
168    /// Renders a `<urlset>` sitemap.
169    ///
170    /// An empty list still produces a well-formed, empty `<urlset>`. An entry whose `loc`
171    /// is not an absolute `http://` or `https://` URL is **left out**: the protocol requires
172    /// a full URL, and a crawler or tool that lists the entries would otherwise be handed
173    /// `javascript:…`.
174    ///
175    /// This does not enforce the protocol's limit of [`MAX_URLS`] URLs and [`MAX_BYTES`]
176    /// bytes per file; split a larger site with [`SitemapGenerator::chunks`] and list the
177    /// parts in a [`generate_index`](Self::generate_index).
178    #[must_use]
179    pub fn generate(urls: &[SitemapUrl]) -> String {
180        let mut xml = String::with_capacity(256 + urls.len() * 160);
181        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
182        xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
183
184        for url in urls.iter().filter(|url| is_http_url(&url.loc)) {
185            xml.push_str("  <url>\n");
186            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(&url.loc));
187            if let Some(lastmod) = &url.lastmod {
188                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
189            }
190            if let Some(changefreq) = url.changefreq {
191                let _ = writeln!(xml, "    <changefreq>{changefreq}</changefreq>");
192            }
193            if let Some(priority) = url.priority.and_then(valid_priority) {
194                let _ = writeln!(
195                    xml,
196                    "    <priority>{}</priority>",
197                    format_priority(priority)
198                );
199            }
200            xml.push_str("  </url>\n");
201        }
202
203        xml.push_str("</urlset>");
204        xml
205    }
206
207    /// Splits `urls` into slices that each fit in one sitemap file (at most [`MAX_URLS`]).
208    ///
209    /// The byte limit depends on how long the URLs are, so check
210    /// `generate(chunk).len() <= MAX_BYTES` and split further if a chunk is over.
211    ///
212    /// # Examples
213    /// ```
214    /// use molt::sitemap::{SitemapGenerator, SitemapUrl, MAX_URLS};
215    /// let urls: Vec<SitemapUrl> = (0..=MAX_URLS)
216    ///     .map(|i| SitemapUrl::new(format!("https://example.com/{i}")))
217    ///     .collect();
218    /// assert_eq!(SitemapGenerator::chunks(&urls).count(), 2);
219    /// ```
220    pub fn chunks(urls: &[SitemapUrl]) -> impl Iterator<Item = &[SitemapUrl]> {
221        urls.chunks(MAX_URLS)
222    }
223
224    /// Renders a `<sitemapindex>` pointing at several sitemaps. An entry whose location is
225    /// not an absolute `http://` or `https://` URL is left out, as in [`generate`](Self::generate).
226    #[must_use]
227    pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
228        let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
229        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
230        xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
231
232        for (loc, lastmod) in sitemaps.iter().filter(|(loc, _)| is_http_url(loc)) {
233            xml.push_str("  <sitemap>\n");
234            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(loc));
235            if let Some(lastmod) = lastmod {
236                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
237            }
238            xml.push_str("  </sitemap>\n");
239        }
240
241        xml.push_str("</sitemapindex>");
242        xml
243    }
244}
245
246/// A priority the protocol can hold: clamped into `0.0..=1.0`, and `None` for `NaN`/infinity.
247fn valid_priority(priority: f64) -> Option<f64> {
248    priority.is_finite().then(|| priority.clamp(0.0, 1.0))
249}
250
251/// Formats a priority with at least one decimal place.
252///
253/// Rust's `{}` renders `1.0` as `"1"`, but the golden fixture has `"1.0"`, so a whole number
254/// keeps one decimal place here. Fractional values use the shortest round-tripping form.
255fn format_priority(priority: f64) -> String {
256    if priority.fract() == 0.0 && priority.is_finite() {
257        format!("{priority:.1}")
258    } else {
259        format!("{priority}")
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
269        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
270        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
271        assert!(xml.ends_with("</urlset>"));
272    }
273
274    #[test]
275    fn a_loc_containing_an_ampersand_is_escaped() {
276        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
277        assert!(xml.contains("<loc>https://e.com/a?x=1&amp;y=2</loc>"));
278    }
279
280    /// This is the subtle one: `1.0` must print as `1.0`, and Rust's default `{}` prints `1`.
281    #[test]
282    fn whole_priorities_keep_one_decimal_place() {
283        assert_eq!(format_priority(1.0), "1.0");
284        assert_eq!(format_priority(0.0), "0.0");
285        assert_eq!(format_priority(0.7), "0.7");
286        assert_eq!(format_priority(0.25), "0.25");
287    }
288
289    #[test]
290    fn a_change_frequency_round_trips_through_text() {
291        for freq in [
292            ChangeFreq::Always,
293            ChangeFreq::Hourly,
294            ChangeFreq::Daily,
295            ChangeFreq::Weekly,
296            ChangeFreq::Monthly,
297            ChangeFreq::Yearly,
298            ChangeFreq::Never,
299        ] {
300            assert_eq!(freq.as_str().parse(), Ok(freq));
301            assert_eq!(freq.to_string(), freq.as_str());
302        }
303        let error = "Weekly".parse::<ChangeFreq>().expect_err("case-sensitive");
304        assert!(error.to_string().contains("\"Weekly\""));
305    }
306
307    #[test]
308    fn a_priority_outside_the_range_is_clamped_and_a_non_number_is_left_out() {
309        let render = |priority: f64| {
310            SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/").priority(priority)])
311        };
312        assert!(render(7.0).contains("<priority>1.0</priority>"));
313        assert!(render(-3.0).contains("<priority>0.0</priority>"));
314        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
315            assert!(!render(bad).contains("<priority>"), "{bad}");
316        }
317
318        // The field is public, so the renderer checks again.
319        let mut url = SitemapUrl::new("https://e.com/");
320        url.priority = Some(f64::NAN);
321        assert!(!SitemapGenerator::generate(&[url]).contains("NaN"));
322    }
323
324    #[test]
325    fn an_entry_whose_loc_is_not_http_is_left_out() {
326        let xml = SitemapGenerator::generate(&[
327            SitemapUrl::new("https://e.com/ok"),
328            SitemapUrl::new("javascript:alert(1)"),
329            SitemapUrl::new("/relative"),
330            SitemapUrl::new("HTTP://e.com/upper"),
331        ]);
332        assert_eq!(xml.matches("<url>").count(), 2, "{xml}");
333        assert!(!xml.contains("javascript"));
334        let index = SitemapGenerator::generate_index(&[
335            ("https://e.com/s.xml".into(), None),
336            ("javascript:x".into(), None),
337        ]);
338        assert_eq!(index.matches("<sitemap>").count(), 1);
339    }
340
341    #[test]
342    fn a_large_site_is_split_into_files_that_fit_the_protocol() {
343        let urls: Vec<SitemapUrl> = (0..=MAX_URLS * 2)
344            .map(|i| SitemapUrl::new(format!("https://e.com/{i}")))
345            .collect();
346        let chunks: Vec<&[SitemapUrl]> = SitemapGenerator::chunks(&urls).collect();
347        assert_eq!(chunks.len(), 3);
348        assert!(chunks.iter().all(|chunk| chunk.len() <= MAX_URLS));
349        assert_eq!(
350            chunks.iter().map(|chunk| chunk.len()).sum::<usize>(),
351            urls.len()
352        );
353        assert_eq!(MAX_BYTES, 52_428_800);
354    }
355
356    #[test]
357    fn optional_fields_are_omitted_entirely() {
358        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
359        assert!(!xml.contains("<lastmod>"));
360        assert!(!xml.contains("<changefreq>"));
361        assert!(!xml.contains("<priority>"));
362    }
363
364    #[test]
365    fn an_empty_sitemap_is_still_well_formed() {
366        let xml = SitemapGenerator::generate(&[]);
367        assert!(xml.contains("<urlset"));
368        assert!(xml.ends_with("</urlset>"));
369    }
370
371    #[test]
372    fn an_index_lists_each_sitemap() {
373        let xml = SitemapGenerator::generate_index(&[
374            (
375                "https://e.com/sitemap-posts.xml".into(),
376                Some("2026-01-15".into()),
377            ),
378            ("https://e.com/sitemap-pages.xml".into(), None),
379        ]);
380        assert!(xml.contains("<sitemapindex"));
381        assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
382        assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
383        assert_eq!(xml.matches("<sitemap>").count(), 2);
384        assert_eq!(xml.matches("<lastmod>").count(), 1);
385
386        // A query string in an index entry is escaped too, not just in `<urlset>`.
387        let escaped = SitemapGenerator::generate_index(&[(
388            "https://e.com/sitemap-pages.xml?v=2&full=1".into(),
389            None,
390        )]);
391        assert!(escaped.contains("v=2&amp;full=1"));
392    }
393
394    #[test]
395    fn an_index_entry_without_a_lastmod_emits_no_element() {
396        let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
397
398        assert!(!xml.contains("<lastmod>"));
399    }
400}