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    /// Other-language versions of this page, as `(hreflang, href)` pairs.
128    ///
129    /// Set through [`SitemapUrl::alternate`]. Written as `<xhtml:link rel="alternate">`
130    /// elements; a pair whose `href` is not an absolute `http(s)` URL or whose `hreflang` is
131    /// not a language tag (or `x-default`) is left out when the sitemap is rendered.
132    pub alternates: Vec<(String, String)>,
133}
134
135impl SitemapUrl {
136    /// Creates an entry for the given URL.
137    pub fn new(loc: impl Into<String>) -> Self {
138        Self {
139            loc: loc.into(),
140            ..Self::default()
141        }
142    }
143
144    /// Sets the last modification date.
145    #[must_use]
146    pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
147        self.lastmod = Some(lastmod.into());
148        self
149    }
150
151    /// Sets the change frequency.
152    #[must_use]
153    pub fn changefreq(mut self, changefreq: ChangeFreq) -> Self {
154        self.changefreq = Some(changefreq);
155        self
156    }
157
158    /// Adds an other-language version of this page: `hreflang` is a language tag such as
159    /// `pt-BR`, or `x-default`; `href` is its absolute URL.
160    ///
161    /// List every version on each entry, **including the page itself**, as search engines
162    /// expect. Entries are written in call order. The `xmlns:xhtml` namespace is declared only
163    /// when at least one alternate is written, so a sitemap without them is unchanged.
164    ///
165    /// # Examples
166    /// ```
167    /// use molt::sitemap::{SitemapGenerator, SitemapUrl};
168    ///
169    /// let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/en/")
170    ///     .alternate("en", "https://example.com/en/")
171    ///     .alternate("pt-BR", "https://example.com/pt/")]);
172    /// assert!(xml.contains(r#"xmlns:xhtml="http://www.w3.org/1999/xhtml""#));
173    /// assert!(xml.contains(
174    ///     r#"<xhtml:link rel="alternate" hreflang="pt-BR" href="https://example.com/pt/"/>"#
175    /// ));
176    /// ```
177    #[must_use]
178    pub fn alternate(mut self, hreflang: impl Into<String>, href: impl Into<String>) -> Self {
179        self.alternates.push((hreflang.into(), href.into()));
180        self
181    }
182
183    /// Sets the priority, clamped to `0.0..=1.0`. A non-finite value clears it.
184    ///
185    /// Clamping rather than failing keeps the builder chainable; the protocol allows no
186    /// value outside the range, so there is nothing to preserve.
187    #[must_use]
188    pub fn priority(mut self, priority: f64) -> Self {
189        self.priority = valid_priority(priority);
190        self
191    }
192}
193
194/// Renders sitemaps and sitemap indexes.
195#[derive(Debug, Clone, Copy)]
196pub struct SitemapGenerator;
197
198impl SitemapGenerator {
199    /// Renders a `<urlset>` sitemap.
200    ///
201    /// An empty list still produces a well-formed, empty `<urlset>`. An entry whose `loc`
202    /// is not an absolute `http://` or `https://` URL is **left out**: the protocol requires
203    /// a full URL, and a crawler or tool that lists the entries would otherwise be handed
204    /// `javascript:…`.
205    ///
206    /// This does not enforce the protocol's limit of [`MAX_URLS`] URLs and [`MAX_BYTES`]
207    /// bytes per file; split a larger site with [`SitemapGenerator::chunks`] and list the
208    /// parts in a [`generate_index`](Self::generate_index).
209    #[must_use]
210    pub fn generate(urls: &[SitemapUrl]) -> String {
211        let mut xml = String::with_capacity(256 + urls.len() * 160);
212        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
213        xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"");
214        // Declared only when something uses it, so the header of a sitemap without alternates
215        // stays what the golden fixture pins.
216        if urls
217            .iter()
218            .filter(|url| is_http_url(&url.loc))
219            .any(|url| url.alternates.iter().any(is_valid_alternate))
220        {
221            xml.push_str(" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"");
222        }
223        xml.push_str(">\n");
224
225        for url in urls.iter().filter(|url| is_http_url(&url.loc)) {
226            xml.push_str("  <url>\n");
227            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(&url.loc));
228            for (hreflang, href) in url
229                .alternates
230                .iter()
231                .filter(|pair| is_valid_alternate(pair))
232            {
233                let _ = writeln!(
234                    xml,
235                    "    <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\"/>",
236                    escape_xml(hreflang),
237                    escape_xml(href)
238                );
239            }
240            if let Some(lastmod) = &url.lastmod {
241                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
242            }
243            if let Some(changefreq) = url.changefreq {
244                let _ = writeln!(xml, "    <changefreq>{changefreq}</changefreq>");
245            }
246            if let Some(priority) = url.priority.and_then(valid_priority) {
247                let _ = writeln!(
248                    xml,
249                    "    <priority>{}</priority>",
250                    format_priority(priority)
251                );
252            }
253            xml.push_str("  </url>\n");
254        }
255
256        xml.push_str("</urlset>");
257        xml
258    }
259
260    /// Splits `urls` into slices that each fit in one sitemap file (at most [`MAX_URLS`]).
261    ///
262    /// The byte limit depends on how long the URLs are, so check
263    /// `generate(chunk).len() <= MAX_BYTES` and split further if a chunk is over.
264    ///
265    /// # Examples
266    /// ```
267    /// use molt::sitemap::{SitemapGenerator, SitemapUrl, MAX_URLS};
268    /// let urls: Vec<SitemapUrl> = (0..=MAX_URLS)
269    ///     .map(|i| SitemapUrl::new(format!("https://example.com/{i}")))
270    ///     .collect();
271    /// assert_eq!(SitemapGenerator::chunks(&urls).count(), 2);
272    /// ```
273    pub fn chunks(urls: &[SitemapUrl]) -> impl Iterator<Item = &[SitemapUrl]> {
274        urls.chunks(MAX_URLS)
275    }
276
277    /// Renders a `<sitemapindex>` pointing at several sitemaps. An entry whose location is
278    /// not an absolute `http://` or `https://` URL is left out, as in [`generate`](Self::generate).
279    #[must_use]
280    pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
281        let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
282        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
283        xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
284
285        for (loc, lastmod) in sitemaps.iter().filter(|(loc, _)| is_http_url(loc)) {
286            xml.push_str("  <sitemap>\n");
287            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(loc));
288            if let Some(lastmod) = lastmod {
289                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
290            }
291            xml.push_str("  </sitemap>\n");
292        }
293
294        xml.push_str("</sitemapindex>");
295        xml
296    }
297}
298
299/// Whether an `(hreflang, href)` pair can be written: an absolute `http(s)` URL, and a language
300/// tag. A tag is not parsed against BCP 47 here; it must be `x-default` or start with a letter
301/// and hold only ASCII letters, digits and `-`, at most 35 characters, which is every
302/// registered tag and nothing that could carry markup.
303fn is_valid_alternate((hreflang, href): &(String, String)) -> bool {
304    is_http_url(href)
305        && hreflang.len() <= 35
306        && hreflang.starts_with(|c: char| c.is_ascii_alphabetic())
307        && hreflang
308            .chars()
309            .all(|c| c.is_ascii_alphanumeric() || c == '-')
310}
311
312/// A priority the protocol can hold: clamped into `0.0..=1.0`, and `None` for `NaN`/infinity.
313fn valid_priority(priority: f64) -> Option<f64> {
314    priority.is_finite().then(|| priority.clamp(0.0, 1.0))
315}
316
317/// Formats a priority with at least one decimal place.
318///
319/// Rust's `{}` renders `1.0` as `"1"`, but the golden fixture has `"1.0"`, so a whole number
320/// keeps one decimal place here. Fractional values use the shortest round-tripping form.
321fn format_priority(priority: f64) -> String {
322    if priority.fract() == 0.0 && priority.is_finite() {
323        format!("{priority:.1}")
324    } else {
325        format!("{priority}")
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn alternates_declare_the_xhtml_namespace_and_list_every_version() {
335        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/en/")
336            .alternate("en", "https://e.com/en/")
337            .alternate("pt-BR", "https://e.com/pt/?a=1&b=2")
338            .alternate("x-default", "https://e.com/")]);
339        assert!(xml.contains(
340            "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" \
341             xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n"
342        ));
343        assert!(xml.contains(
344            "    <loc>https://e.com/en/</loc>\n\
345             \x20   <xhtml:link rel=\"alternate\" hreflang=\"en\" href=\"https://e.com/en/\"/>\n\
346             \x20   <xhtml:link rel=\"alternate\" hreflang=\"pt-BR\" href=\"https://e.com/pt/?a=1&amp;b=2\"/>\n\
347             \x20   <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"https://e.com/\"/>\n"
348        ));
349    }
350
351    #[test]
352    fn without_alternates_the_header_has_no_xhtml_namespace() {
353        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
354        assert!(!xml.contains("xhtml"), "{xml}");
355    }
356
357    #[test]
358    fn a_hostile_alternate_is_left_out_and_does_not_declare_the_namespace() {
359        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")
360            .alternate("en", "javascript:alert(1)")
361            .alternate("en\"/><x", "https://e.com/en/")
362            .alternate("", "https://e.com/")
363            .alternate("1en", "https://e.com/")
364            .alternate("e".repeat(36), "https://e.com/")]);
365        assert!(!xml.contains("xhtml"), "{xml}");
366    }
367
368    #[test]
369    fn an_alternate_on_an_entry_that_is_left_out_declares_nothing() {
370        let xml = SitemapGenerator::generate(&[
371            SitemapUrl::new("javascript:alert(1)").alternate("en", "https://e.com/en/")
372        ]);
373        assert!(!xml.contains("xhtml"), "{xml}");
374    }
375
376    #[test]
377    fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
378        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
379        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
380        assert!(xml.ends_with("</urlset>"));
381    }
382
383    #[test]
384    fn a_loc_containing_an_ampersand_is_escaped() {
385        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
386        assert!(xml.contains("<loc>https://e.com/a?x=1&amp;y=2</loc>"));
387    }
388
389    /// This is the subtle one: `1.0` must print as `1.0`, and Rust's default `{}` prints `1`.
390    #[test]
391    fn whole_priorities_keep_one_decimal_place() {
392        assert_eq!(format_priority(1.0), "1.0");
393        assert_eq!(format_priority(0.0), "0.0");
394        assert_eq!(format_priority(0.7), "0.7");
395        assert_eq!(format_priority(0.25), "0.25");
396    }
397
398    #[test]
399    fn a_change_frequency_round_trips_through_text() {
400        for freq in [
401            ChangeFreq::Always,
402            ChangeFreq::Hourly,
403            ChangeFreq::Daily,
404            ChangeFreq::Weekly,
405            ChangeFreq::Monthly,
406            ChangeFreq::Yearly,
407            ChangeFreq::Never,
408        ] {
409            assert_eq!(freq.as_str().parse(), Ok(freq));
410            assert_eq!(freq.to_string(), freq.as_str());
411        }
412        let error = "Weekly".parse::<ChangeFreq>().expect_err("case-sensitive");
413        assert!(error.to_string().contains("\"Weekly\""));
414    }
415
416    #[test]
417    fn a_priority_outside_the_range_is_clamped_and_a_non_number_is_left_out() {
418        let render = |priority: f64| {
419            SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/").priority(priority)])
420        };
421        assert!(render(7.0).contains("<priority>1.0</priority>"));
422        assert!(render(-3.0).contains("<priority>0.0</priority>"));
423        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
424            assert!(!render(bad).contains("<priority>"), "{bad}");
425        }
426
427        // The field is public, so the renderer checks again.
428        let mut url = SitemapUrl::new("https://e.com/");
429        url.priority = Some(f64::NAN);
430        assert!(!SitemapGenerator::generate(&[url]).contains("NaN"));
431    }
432
433    #[test]
434    fn an_entry_whose_loc_is_not_http_is_left_out() {
435        let xml = SitemapGenerator::generate(&[
436            SitemapUrl::new("https://e.com/ok"),
437            SitemapUrl::new("javascript:alert(1)"),
438            SitemapUrl::new("/relative"),
439            SitemapUrl::new("HTTP://e.com/upper"),
440        ]);
441        assert_eq!(xml.matches("<url>").count(), 2, "{xml}");
442        assert!(!xml.contains("javascript"));
443        let index = SitemapGenerator::generate_index(&[
444            ("https://e.com/s.xml".into(), None),
445            ("javascript:x".into(), None),
446        ]);
447        assert_eq!(index.matches("<sitemap>").count(), 1);
448    }
449
450    #[test]
451    fn a_large_site_is_split_into_files_that_fit_the_protocol() {
452        let urls: Vec<SitemapUrl> = (0..=MAX_URLS * 2)
453            .map(|i| SitemapUrl::new(format!("https://e.com/{i}")))
454            .collect();
455        let chunks: Vec<&[SitemapUrl]> = SitemapGenerator::chunks(&urls).collect();
456        assert_eq!(chunks.len(), 3);
457        assert!(chunks.iter().all(|chunk| chunk.len() <= MAX_URLS));
458        assert_eq!(
459            chunks.iter().map(|chunk| chunk.len()).sum::<usize>(),
460            urls.len()
461        );
462        assert_eq!(MAX_BYTES, 52_428_800);
463    }
464
465    #[test]
466    fn optional_fields_are_omitted_entirely() {
467        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
468        assert!(!xml.contains("<lastmod>"));
469        assert!(!xml.contains("<changefreq>"));
470        assert!(!xml.contains("<priority>"));
471    }
472
473    #[test]
474    fn an_empty_sitemap_is_still_well_formed() {
475        let xml = SitemapGenerator::generate(&[]);
476        assert!(xml.contains("<urlset"));
477        assert!(xml.ends_with("</urlset>"));
478    }
479
480    #[test]
481    fn an_index_lists_each_sitemap() {
482        let xml = SitemapGenerator::generate_index(&[
483            (
484                "https://e.com/sitemap-posts.xml".into(),
485                Some("2026-01-15".into()),
486            ),
487            ("https://e.com/sitemap-pages.xml".into(), None),
488        ]);
489        assert!(xml.contains("<sitemapindex"));
490        assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
491        assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
492        assert_eq!(xml.matches("<sitemap>").count(), 2);
493        assert_eq!(xml.matches("<lastmod>").count(), 1);
494
495        // A query string in an index entry is escaped too, not just in `<urlset>`.
496        let escaped = SitemapGenerator::generate_index(&[(
497            "https://e.com/sitemap-pages.xml?v=2&full=1".into(),
498            None,
499        )]);
500        assert!(escaped.contains("v=2&amp;full=1"));
501    }
502
503    #[test]
504    fn an_index_entry_without_a_lastmod_emits_no_element() {
505        let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
506
507        assert!(!xml.contains("<lastmod>"));
508    }
509}