Skip to main content

winged_rust/
sitemap.rs

1//! XML sitemap generation.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/seo/SitemapGenerator.swift`.
4
5use std::fmt::Write as _;
6
7use crate::core::escape::escape_xml;
8
9/// One entry in a sitemap.
10///
11/// # Examples
12/// ```
13/// use winged_rust::sitemap::{SitemapGenerator, SitemapUrl};
14///
15/// let xml = SitemapGenerator::generate(&[
16///     SitemapUrl::new("https://example.com/").changefreq("weekly").priority(1.0),
17/// ]);
18/// assert!(xml.contains("<priority>1.0</priority>"));
19/// ```
20#[derive(Debug, Clone, PartialEq, Default)]
21pub struct SitemapUrl {
22    /// The absolute URL of the page.
23    pub loc: String,
24    /// The last modification date, typically `YYYY-MM-DD`.
25    pub lastmod: Option<String>,
26    /// How often the page changes: `always`, `hourly`, `daily`, `weekly`, `monthly`,
27    /// `yearly`, `never`.
28    pub changefreq: Option<String>,
29    /// Relative priority within the site, from `0.0` to `1.0`.
30    pub priority: Option<f64>,
31}
32
33impl SitemapUrl {
34    /// Creates an entry for the given URL.
35    pub fn new(loc: impl Into<String>) -> Self {
36        Self {
37            loc: loc.into(),
38            ..Self::default()
39        }
40    }
41
42    /// Sets the last modification date.
43    #[must_use]
44    pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
45        self.lastmod = Some(lastmod.into());
46        self
47    }
48
49    /// Sets the change frequency.
50    #[must_use]
51    pub fn changefreq(mut self, changefreq: impl Into<String>) -> Self {
52        self.changefreq = Some(changefreq.into());
53        self
54    }
55
56    /// Sets the priority.
57    #[must_use]
58    pub fn priority(mut self, priority: f64) -> Self {
59        self.priority = Some(priority);
60        self
61    }
62}
63
64/// Renders sitemaps and sitemap indexes.
65#[derive(Debug, Clone, Copy)]
66pub struct SitemapGenerator;
67
68impl SitemapGenerator {
69    /// Renders a `<urlset>` sitemap.
70    ///
71    /// An empty list still produces a well-formed, empty `<urlset>`.
72    #[must_use]
73    pub fn generate(urls: &[SitemapUrl]) -> String {
74        let mut xml = String::with_capacity(256 + urls.len() * 160);
75        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
76        xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
77
78        for url in urls {
79            xml.push_str("  <url>\n");
80            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(&url.loc));
81            if let Some(lastmod) = &url.lastmod {
82                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
83            }
84            if let Some(changefreq) = &url.changefreq {
85                let _ = writeln!(
86                    xml,
87                    "    <changefreq>{}</changefreq>",
88                    escape_xml(changefreq)
89                );
90            }
91            if let Some(priority) = url.priority {
92                let _ = writeln!(
93                    xml,
94                    "    <priority>{}</priority>",
95                    format_priority(priority)
96                );
97            }
98            xml.push_str("  </url>\n");
99        }
100
101        xml.push_str("</urlset>");
102        xml
103    }
104
105    /// Renders a `<sitemapindex>` pointing at several sitemaps.
106    #[must_use]
107    pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
108        let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
109        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
110        xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
111
112        for (loc, lastmod) in sitemaps {
113            xml.push_str("  <sitemap>\n");
114            let _ = writeln!(xml, "    <loc>{}</loc>", escape_xml(loc));
115            if let Some(lastmod) = lastmod {
116                let _ = writeln!(xml, "    <lastmod>{}</lastmod>", escape_xml(lastmod));
117            }
118            xml.push_str("  </sitemap>\n");
119        }
120
121        xml.push_str("</sitemapindex>");
122        xml
123    }
124}
125
126/// Formats a priority the way Swift's `Double` interpolation does.
127///
128/// Swift renders `1.0` as `"1.0"`; Rust's `{}` renders it as `"1"`. The golden fixture was
129/// produced by Swift, so a whole number keeps one decimal place here. Fractional values
130/// already agree between the two languages, both using the shortest round-tripping form.
131fn format_priority(priority: f64) -> String {
132    if priority.fract() == 0.0 && priority.is_finite() {
133        format!("{priority:.1}")
134    } else {
135        format!("{priority}")
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// Ports `SitemapGeneratorTests.testGenerateProducesValidURLSet`.
144    #[test]
145    fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
146        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
147        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
148        assert!(xml.ends_with("</urlset>"));
149    }
150
151    /// Ports `SitemapGeneratorTests.testGenerateEscapesAmpersandsInURLs`.
152    #[test]
153    fn a_loc_containing_an_ampersand_is_escaped() {
154        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
155        assert!(xml.contains("<loc>https://e.com/a?x=1&amp;y=2</loc>"));
156    }
157
158    /// Ports `SitemapGeneratorTests.testPriorityIsRenderedWithOneDecimal`. This is the subtle one: Swift
159    /// prints `1.0`, Rust's default `{}` prints `1`.
160    #[test]
161    fn whole_priorities_keep_one_decimal_place() {
162        assert_eq!(format_priority(1.0), "1.0");
163        assert_eq!(format_priority(0.0), "0.0");
164        assert_eq!(format_priority(0.7), "0.7");
165        assert_eq!(format_priority(0.25), "0.25");
166    }
167
168    /// Ports `SitemapGeneratorTests.testOptionalFieldsAreOmitted`.
169    #[test]
170    fn optional_fields_are_omitted_entirely() {
171        let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
172        assert!(!xml.contains("<lastmod>"));
173        assert!(!xml.contains("<changefreq>"));
174        assert!(!xml.contains("<priority>"));
175    }
176
177    /// Ports `SitemapGeneratorTests.testEmptyURLListStillProducesAWellFormedDocument`.
178    #[test]
179    fn an_empty_sitemap_is_still_well_formed() {
180        let xml = SitemapGenerator::generate(&[]);
181        assert!(xml.contains("<urlset"));
182        assert!(xml.ends_with("</urlset>"));
183    }
184
185    /// Ports `SitemapGeneratorTests.testGeneratesASitemapIndex`.
186    #[test]
187    fn an_index_lists_each_sitemap() {
188        let xml = SitemapGenerator::generate_index(&[
189            (
190                "https://e.com/sitemap-posts.xml".into(),
191                Some("2026-01-15".into()),
192            ),
193            ("https://e.com/sitemap-pages.xml".into(), None),
194        ]);
195        assert!(xml.contains("<sitemapindex"));
196        assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
197        assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
198        assert_eq!(xml.matches("<sitemap>").count(), 2);
199        assert_eq!(xml.matches("<lastmod>").count(), 1);
200
201        // The Swift case checks that a query string in an index entry is escaped too, not
202        // just in `<urlset>`.
203        let escaped = SitemapGenerator::generate_index(&[(
204            "https://e.com/sitemap-pages.xml?v=2&full=1".into(),
205            None,
206        )]);
207        assert!(escaped.contains("v=2&amp;full=1"));
208    }
209
210    /// Ports `SitemapGeneratorTests.testSitemapIndexOmitsAMissingLastmod`.
211    #[test]
212    fn an_index_entry_without_a_lastmod_emits_no_element() {
213        let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
214
215        assert!(!xml.contains("<lastmod>"));
216    }
217}