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 pub alternates: Vec<(String, String)>,
133}
134
135impl SitemapUrl {
136 pub fn new(loc: impl Into<String>) -> Self {
138 Self {
139 loc: loc.into(),
140 ..Self::default()
141 }
142 }
143
144 #[must_use]
146 pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
147 self.lastmod = Some(lastmod.into());
148 self
149 }
150
151 #[must_use]
153 pub fn changefreq(mut self, changefreq: ChangeFreq) -> Self {
154 self.changefreq = Some(changefreq);
155 self
156 }
157
158 #[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 #[must_use]
188 pub fn priority(mut self, priority: f64) -> Self {
189 self.priority = valid_priority(priority);
190 self
191 }
192}
193
194#[derive(Debug, Clone, Copy)]
196pub struct SitemapGenerator;
197
198impl SitemapGenerator {
199 #[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 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 pub fn chunks(urls: &[SitemapUrl]) -> impl Iterator<Item = &[SitemapUrl]> {
274 urls.chunks(MAX_URLS)
275 }
276
277 #[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
299fn 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
312fn valid_priority(priority: f64) -> Option<f64> {
314 priority.is_finite().then(|| priority.clamp(0.0, 1.0))
315}
316
317fn 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&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&y=2</loc>"));
387 }
388
389 #[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 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 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&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}