1use std::fmt::Write as _;
5
6use crate::core::escape::escape_xml;
7use crate::core::url_policy::is_http_url;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum ChangeFreq {
24 Always,
26 Hourly,
28 Daily,
30 Weekly,
32 Monthly,
34 Yearly,
36 Never,
38}
39
40impl ChangeFreq {
41 #[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#[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
95pub const MAX_URLS: usize = 50_000;
97
98pub const MAX_BYTES: usize = 50 * 1024 * 1024;
100
101#[derive(Debug, Clone, PartialEq, Default)]
113#[non_exhaustive]
114pub struct SitemapUrl {
115 pub loc: String,
117 pub lastmod: Option<String>,
119 pub changefreq: Option<ChangeFreq>,
121 pub priority: Option<f64>,
127}
128
129impl SitemapUrl {
130 pub fn new(loc: impl Into<String>) -> Self {
132 Self {
133 loc: loc.into(),
134 ..Self::default()
135 }
136 }
137
138 #[must_use]
140 pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
141 self.lastmod = Some(lastmod.into());
142 self
143 }
144
145 #[must_use]
147 pub fn changefreq(mut self, changefreq: ChangeFreq) -> Self {
148 self.changefreq = Some(changefreq);
149 self
150 }
151
152 #[must_use]
157 pub fn priority(mut self, priority: f64) -> Self {
158 self.priority = valid_priority(priority);
159 self
160 }
161}
162
163#[derive(Debug, Clone, Copy)]
165pub struct SitemapGenerator;
166
167impl SitemapGenerator {
168 #[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 pub fn chunks(urls: &[SitemapUrl]) -> impl Iterator<Item = &[SitemapUrl]> {
221 urls.chunks(MAX_URLS)
222 }
223
224 #[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
246fn valid_priority(priority: f64) -> Option<f64> {
248 priority.is_finite().then(|| priority.clamp(0.0, 1.0))
249}
250
251fn 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&y=2</loc>"));
278 }
279
280 #[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 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 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&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}