use chrono::{DateTime, Utc};
use sitemap_rs::image::Image;
use sitemap_rs::sitemap::Sitemap;
use sitemap_rs::sitemap_index::SitemapIndex;
use sitemap_rs::url::Url;
use sitemap_rs::url_builder::UrlBuilder;
use sitemap_rs::url_set::UrlSet;
use tracing::{debug, instrument};
pub const SITEMAP_INDEX_PATH: &str = "/sitemap.xml";
pub const MAX_URLS_PER_SITEMAP: usize = 45_000;
pub const MAX_BYTES_PER_SITEMAP: usize = 45 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum SitemapError {
#[error("invalid sitemap entry for `{location}`")]
Url {
location: String,
#[source]
source: sitemap_rs::url_error::UrlError,
},
#[error("invalid sitemap url set ({count} urls)")]
UrlSet {
count: usize,
#[source]
source: sitemap_rs::url_set_error::UrlSetError,
},
#[error("invalid sitemap index ({count} sitemaps)")]
Index {
count: usize,
#[source]
source: sitemap_rs::sitemap_index_error::SitemapIndexError,
},
#[error("failed to serialize the sitemap")]
Write {
#[source]
source: xml_builder::XMLError,
},
#[error("the serialized sitemap was not valid UTF-8")]
Encoding,
}
#[derive(Debug, Clone)]
pub struct SitemapUrl {
path: String,
last_modified: Option<DateTime<Utc>>,
images: Vec<String>,
}
impl SitemapUrl {
#[must_use]
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
last_modified: None,
images: Vec::new(),
}
}
#[must_use]
pub const fn with_last_modified(mut self, last_modified: DateTime<Utc>) -> Self {
self.last_modified = Some(last_modified);
self
}
#[must_use]
pub fn extend_images<I, S>(mut self, images: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.images.extend(images.into_iter().map(Into::into));
self
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub const fn last_modified(&self) -> Option<DateTime<Utc>> {
self.last_modified
}
#[must_use]
pub fn images(&self) -> &[String] {
&self.images
}
#[must_use]
pub fn map_images<F>(mut self, resolve: F) -> Self
where
F: Fn(&str) -> String,
{
self.images = self.images.iter().map(|image| resolve(image)).collect();
self
}
fn build(
&self,
base_url: &str,
fallback_last_modified: Option<DateTime<Utc>>,
) -> Result<Url, SitemapError> {
let location: String = absolute(base_url, &self.path);
let mut builder: UrlBuilder = Url::builder(location.clone());
if let Some(last_modified) = self.last_modified.or(fallback_last_modified) {
builder.last_modified(DateTime::from(last_modified));
}
if !self.images.is_empty() {
builder.images(
self.images
.iter()
.map(|image| Image::new(absolute(base_url, image)))
.collect(),
);
}
builder
.build()
.map_err(|source| SitemapError::Url { location, source })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitemapSet {
index: String,
chunks: Vec<String>,
}
impl SitemapSet {
#[must_use]
pub fn index(&self) -> &str {
&self.index
}
#[must_use]
pub fn chunks(&self) -> &[String] {
&self.chunks
}
#[must_use]
pub fn paths(&self) -> Vec<String> {
let mut paths: Vec<String> = vec![String::from(SITEMAP_INDEX_PATH)];
paths.extend((1..=self.chunks.len()).map(|n| format!("/sitemap-{n}.xml")));
paths
}
}
#[instrument(skip_all)]
pub fn build_sitemaps(
base_url: &str,
urls: &[SitemapUrl],
fallback_last_modified: Option<DateTime<Utc>>,
) -> Result<SitemapSet, SitemapError> {
let mut chunks: Vec<String> = Vec::new();
for batch in urls.chunks(MAX_URLS_PER_SITEMAP).chain(if urls.is_empty() {
Some([].as_slice())
} else {
None
}) {
chunks.extend(render_url_set(base_url, batch, fallback_last_modified)?);
}
let sitemaps: Vec<Sitemap> = (1..=chunks.len())
.map(|n| {
Sitemap::new(
absolute(base_url, &format!("/sitemap-{n}.xml")),
fallback_last_modified.map(DateTime::from),
)
})
.collect();
let count: usize = sitemaps.len();
let index: SitemapIndex =
SitemapIndex::new(sitemaps).map_err(|source| SitemapError::Index { count, source })?;
let mut buffer: Vec<u8> = Vec::new();
index
.write(&mut buffer)
.map_err(|source| SitemapError::Write { source })?;
let index: String = String::from_utf8(buffer).map_err(|_| SitemapError::Encoding)?;
debug!("built {} url(s) across {count} sitemap(s)", urls.len());
Ok(SitemapSet { index, chunks })
}
fn render_url_set(
base_url: &str,
batch: &[SitemapUrl],
fallback_last_modified: Option<DateTime<Utc>>,
) -> Result<Vec<String>, SitemapError> {
let built: Vec<Url> = batch
.iter()
.map(|url| url.build(base_url, fallback_last_modified))
.collect::<Result<Vec<Url>, SitemapError>>()?;
let count: usize = built.len();
let url_set: UrlSet =
UrlSet::new(built).map_err(|source| SitemapError::UrlSet { count, source })?;
let mut buffer: Vec<u8> = Vec::new();
url_set
.write(&mut buffer)
.map_err(|source| SitemapError::Write { source })?;
if buffer.len() <= MAX_BYTES_PER_SITEMAP || batch.len() < 2 {
return Ok(vec![
String::from_utf8(buffer).map_err(|_| SitemapError::Encoding)?,
]);
}
let (left, right) = batch.split_at(batch.len() / 2);
let mut rendered: Vec<String> = render_url_set(base_url, left, fallback_last_modified)?;
rendered.extend(render_url_set(base_url, right, fallback_last_modified)?);
Ok(rendered)
}
fn absolute(base_url: &str, path: &str) -> String {
if path.starts_with("http://") || path.starts_with("https://") {
return path.to_string();
}
let base: &str = base_url.trim_end_matches('/');
let path: &str = path.trim_start_matches('/');
if path.is_empty() {
format!("{base}/")
} else {
format!("{base}/{path}")
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use super::{SitemapSet, SitemapUrl, absolute, build_sitemaps};
#[test]
fn joining_never_doubles_or_drops_a_slash() {
let cases: [(&str, &str, &str); 5] = [
("https://a.com", "/blog", "https://a.com/blog"),
("https://a.com/", "/blog", "https://a.com/blog"),
("https://a.com", "blog", "https://a.com/blog"),
("https://a.com", "/", "https://a.com/"),
("https://a.com", "", "https://a.com/"),
];
for (base, path, expected) in cases {
let actual: String = absolute(base, path);
assert_eq!(expected, actual, "base {base:?} path {path:?}");
}
}
#[test]
fn an_absolute_image_url_is_left_alone() {
let expected: String = String::from("https://cdn.example.com/card.png");
let actual: String = absolute("https://a.com", "https://cdn.example.com/card.png");
assert_eq!(expected, actual);
}
#[test]
fn a_small_site_still_gets_an_index_so_the_shape_never_changes() {
let urls: Vec<SitemapUrl> = vec![SitemapUrl::new("/"), SitemapUrl::new("/blog")];
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, None).expect("builds");
assert!(set.index().contains("<sitemapindex"));
assert!(
set.index()
.contains("https://www.example.com/sitemap-1.xml")
);
let expected: usize = 1;
let actual: usize = set.chunks().len();
assert_eq!(expected, actual);
assert!(set.chunks()[0].contains("<loc>https://www.example.com/</loc>"));
assert!(set.chunks()[0].contains("<loc>https://www.example.com/blog</loc>"));
}
#[test]
fn a_site_with_no_pages_still_produces_a_well_formed_pair() {
let set: SitemapSet = build_sitemaps("https://www.example.com", &[], None).expect("builds");
let expected: usize = 1;
let actual: usize = set.chunks().len();
assert_eq!(expected, actual);
assert!(set.index().contains("<sitemapindex"));
}
#[test]
fn urls_beyond_the_limit_spill_into_another_url_set() {
let urls: Vec<SitemapUrl> = (0..super::MAX_URLS_PER_SITEMAP + 10)
.map(|n| SitemapUrl::new(format!("/page/{n}")))
.collect();
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, None).expect("builds");
let expected: usize = 2;
let actual: usize = set.chunks().len();
assert_eq!(expected, actual);
assert!(
set.index()
.contains("https://www.example.com/sitemap-2.xml")
);
}
#[test]
fn the_index_path_comes_first_so_it_survives_truncation() {
let urls: Vec<SitemapUrl> = (0..=super::MAX_URLS_PER_SITEMAP)
.map(|n| SitemapUrl::new(format!("/page/{n}")))
.collect();
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, None).expect("builds");
let expected: Vec<String> = vec![
String::from("/sitemap.xml"),
String::from("/sitemap-1.xml"),
String::from("/sitemap-2.xml"),
];
let actual: Vec<String> = set.paths();
assert_eq!(expected, actual);
}
#[test]
fn a_page_without_a_date_gets_no_lastmod_rather_than_a_false_one() {
let urls: Vec<SitemapUrl> = vec![SitemapUrl::new("/")];
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, None).expect("builds");
assert!(!set.chunks()[0].contains("<lastmod>"));
}
#[test]
fn a_page_date_wins_over_the_site_wide_fallback() {
let page_date = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
let site_date = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let urls: Vec<SitemapUrl> = vec![
SitemapUrl::new("/blog").with_last_modified(page_date),
SitemapUrl::new("/"),
];
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, Some(site_date)).expect("builds");
assert!(set.chunks()[0].contains("2026-01-02"));
assert!(set.chunks()[0].contains("2020-01-01"));
}
#[test]
fn images_are_rewritten_to_the_paths_they_are_actually_served_at() {
let url: SitemapUrl = SitemapUrl::new("/")
.extend_images(["/static/image/social/card.webp"])
.map_images(|image| image.replace("card.webp", "card.abc123.webp"));
let expected: Vec<String> = vec![String::from("/static/image/social/card.abc123.webp")];
let actual: Vec<String> = url.images().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn images_are_resolved_against_the_site_origin() {
let urls: Vec<SitemapUrl> =
vec![SitemapUrl::new("/").extend_images(["/static/image/social/card.webp"])];
let set: SitemapSet =
build_sitemaps("https://www.example.com", &urls, None).expect("builds");
assert!(set.chunks()[0].contains("https://www.example.com/static/image/social/card.webp"));
}
}