use std::fmt::Write as _;
use crate::core::escape::escape_xml;
use crate::core::url_policy::is_http_url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ChangeFreq {
Always,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
Never,
}
impl ChangeFreq {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Always => "always",
Self::Hourly => "hourly",
Self::Daily => "daily",
Self::Weekly => "weekly",
Self::Monthly => "monthly",
Self::Yearly => "yearly",
Self::Never => "never",
}
}
}
impl std::fmt::Display for ChangeFreq {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseChangeFreqError(String);
impl std::fmt::Display for ParseChangeFreqError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} is not a change frequency (always, hourly, daily, weekly, monthly, yearly, never)",
self.0
)
}
}
impl std::error::Error for ParseChangeFreqError {}
impl std::str::FromStr for ChangeFreq {
type Err = ParseChangeFreqError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Ok(match text {
"always" => Self::Always,
"hourly" => Self::Hourly,
"daily" => Self::Daily,
"weekly" => Self::Weekly,
"monthly" => Self::Monthly,
"yearly" => Self::Yearly,
"never" => Self::Never,
other => return Err(ParseChangeFreqError(other.to_owned())),
})
}
}
pub const MAX_URLS: usize = 50_000;
pub const MAX_BYTES: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct SitemapUrl {
pub loc: String,
pub lastmod: Option<String>,
pub changefreq: Option<ChangeFreq>,
pub priority: Option<f64>,
}
impl SitemapUrl {
pub fn new(loc: impl Into<String>) -> Self {
Self {
loc: loc.into(),
..Self::default()
}
}
#[must_use]
pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
self.lastmod = Some(lastmod.into());
self
}
#[must_use]
pub fn changefreq(mut self, changefreq: ChangeFreq) -> Self {
self.changefreq = Some(changefreq);
self
}
#[must_use]
pub fn priority(mut self, priority: f64) -> Self {
self.priority = valid_priority(priority);
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct SitemapGenerator;
impl SitemapGenerator {
#[must_use]
pub fn generate(urls: &[SitemapUrl]) -> String {
let mut xml = String::with_capacity(256 + urls.len() * 160);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for url in urls.iter().filter(|url| is_http_url(&url.loc)) {
xml.push_str(" <url>\n");
let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(&url.loc));
if let Some(lastmod) = &url.lastmod {
let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
}
if let Some(changefreq) = url.changefreq {
let _ = writeln!(xml, " <changefreq>{changefreq}</changefreq>");
}
if let Some(priority) = url.priority.and_then(valid_priority) {
let _ = writeln!(
xml,
" <priority>{}</priority>",
format_priority(priority)
);
}
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>");
xml
}
pub fn chunks(urls: &[SitemapUrl]) -> impl Iterator<Item = &[SitemapUrl]> {
urls.chunks(MAX_URLS)
}
#[must_use]
pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (loc, lastmod) in sitemaps.iter().filter(|(loc, _)| is_http_url(loc)) {
xml.push_str(" <sitemap>\n");
let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(loc));
if let Some(lastmod) = lastmod {
let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
}
xml.push_str(" </sitemap>\n");
}
xml.push_str("</sitemapindex>");
xml
}
}
fn valid_priority(priority: f64) -> Option<f64> {
priority.is_finite().then(|| priority.clamp(0.0, 1.0))
}
fn format_priority(priority: f64) -> String {
if priority.fract() == 0.0 && priority.is_finite() {
format!("{priority:.1}")
} else {
format!("{priority}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
assert!(xml.ends_with("</urlset>"));
}
#[test]
fn a_loc_containing_an_ampersand_is_escaped() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
assert!(xml.contains("<loc>https://e.com/a?x=1&y=2</loc>"));
}
#[test]
fn whole_priorities_keep_one_decimal_place() {
assert_eq!(format_priority(1.0), "1.0");
assert_eq!(format_priority(0.0), "0.0");
assert_eq!(format_priority(0.7), "0.7");
assert_eq!(format_priority(0.25), "0.25");
}
#[test]
fn a_change_frequency_round_trips_through_text() {
for freq in [
ChangeFreq::Always,
ChangeFreq::Hourly,
ChangeFreq::Daily,
ChangeFreq::Weekly,
ChangeFreq::Monthly,
ChangeFreq::Yearly,
ChangeFreq::Never,
] {
assert_eq!(freq.as_str().parse(), Ok(freq));
assert_eq!(freq.to_string(), freq.as_str());
}
let error = "Weekly".parse::<ChangeFreq>().expect_err("case-sensitive");
assert!(error.to_string().contains("\"Weekly\""));
}
#[test]
fn a_priority_outside_the_range_is_clamped_and_a_non_number_is_left_out() {
let render = |priority: f64| {
SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/").priority(priority)])
};
assert!(render(7.0).contains("<priority>1.0</priority>"));
assert!(render(-3.0).contains("<priority>0.0</priority>"));
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(!render(bad).contains("<priority>"), "{bad}");
}
let mut url = SitemapUrl::new("https://e.com/");
url.priority = Some(f64::NAN);
assert!(!SitemapGenerator::generate(&[url]).contains("NaN"));
}
#[test]
fn an_entry_whose_loc_is_not_http_is_left_out() {
let xml = SitemapGenerator::generate(&[
SitemapUrl::new("https://e.com/ok"),
SitemapUrl::new("javascript:alert(1)"),
SitemapUrl::new("/relative"),
SitemapUrl::new("HTTP://e.com/upper"),
]);
assert_eq!(xml.matches("<url>").count(), 2, "{xml}");
assert!(!xml.contains("javascript"));
let index = SitemapGenerator::generate_index(&[
("https://e.com/s.xml".into(), None),
("javascript:x".into(), None),
]);
assert_eq!(index.matches("<sitemap>").count(), 1);
}
#[test]
fn a_large_site_is_split_into_files_that_fit_the_protocol() {
let urls: Vec<SitemapUrl> = (0..=MAX_URLS * 2)
.map(|i| SitemapUrl::new(format!("https://e.com/{i}")))
.collect();
let chunks: Vec<&[SitemapUrl]> = SitemapGenerator::chunks(&urls).collect();
assert_eq!(chunks.len(), 3);
assert!(chunks.iter().all(|chunk| chunk.len() <= MAX_URLS));
assert_eq!(
chunks.iter().map(|chunk| chunk.len()).sum::<usize>(),
urls.len()
);
assert_eq!(MAX_BYTES, 52_428_800);
}
#[test]
fn optional_fields_are_omitted_entirely() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
assert!(!xml.contains("<lastmod>"));
assert!(!xml.contains("<changefreq>"));
assert!(!xml.contains("<priority>"));
}
#[test]
fn an_empty_sitemap_is_still_well_formed() {
let xml = SitemapGenerator::generate(&[]);
assert!(xml.contains("<urlset"));
assert!(xml.ends_with("</urlset>"));
}
#[test]
fn an_index_lists_each_sitemap() {
let xml = SitemapGenerator::generate_index(&[
(
"https://e.com/sitemap-posts.xml".into(),
Some("2026-01-15".into()),
),
("https://e.com/sitemap-pages.xml".into(), None),
]);
assert!(xml.contains("<sitemapindex"));
assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
assert_eq!(xml.matches("<sitemap>").count(), 2);
assert_eq!(xml.matches("<lastmod>").count(), 1);
let escaped = SitemapGenerator::generate_index(&[(
"https://e.com/sitemap-pages.xml?v=2&full=1".into(),
None,
)]);
assert!(escaped.contains("v=2&full=1"));
}
#[test]
fn an_index_entry_without_a_lastmod_emits_no_element() {
let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
assert!(!xml.contains("<lastmod>"));
}
}