#![doc = include_str!("../../docs/content/sitemap.md")]
use std::{borrow::Cow, fmt, time::SystemTime};
use http::header::{CONTENT_TYPE, HeaderValue};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use topcoat_core::{base_url::base_url, context::Cx, error::Result};
use topcoat_view::{Formatter, HtmlContext};
use crate::{
Body,
response::{IntoResponse, Response},
};
#[derive(Clone, Debug, Default)]
#[must_use]
pub struct Sitemap {
urls: Vec<SitemapUrl>,
}
impl Sitemap {
pub fn new() -> Self {
Self::default()
}
pub fn url(mut self, url: impl Into<SitemapUrl>) -> Self {
self.urls.push(url.into());
self
}
pub fn urls<I>(mut self, urls: I) -> Self
where
I: IntoIterator,
I::Item: Into<SitemapUrl>,
{
self.urls.extend(urls.into_iter().map(Into::into));
self
}
fn serialize(&self, cx: &Cx) -> Result<String> {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
);
let mut f = Formatter::new(&mut xml);
for url in &self.urls {
let location = if is_absolute(&url.location) {
Cow::Borrowed(url.location.as_str())
} else {
Cow::Owned(base_url(cx).join(&url.location))
};
f.write_str("<url><loc>");
HtmlContext::Text.writer(&mut f).write_str(&location);
f.write_str("</loc>");
if let Some(last_modified) = url.last_modified {
let last_modified = OffsetDateTime::from(last_modified)
.format(&Rfc3339)
.map_err(|_| {
InvalidSitemapError::new(
"a last modified time is outside the representable range",
)
})?;
f.write_str("<lastmod>");
f.write_str(&last_modified);
f.write_str("</lastmod>");
}
if let Some(change_frequency) = url.change_frequency {
f.write_str("<changefreq>");
f.write_str(change_frequency.as_str());
f.write_str("</changefreq>");
}
if let Some(priority) = url.priority {
if !(0.0..=1.0).contains(&priority) {
return Err(
InvalidSitemapError::new("a priority must be between 0.0 and 1.0").into(),
);
}
f.write_str("<priority>");
f.write_str(&priority.to_string());
f.write_str("</priority>");
}
f.write_str("</url>\n");
}
f.write_str("</urlset>\n");
Ok(xml)
}
}
impl IntoResponse for Sitemap {
fn into_response(self, cx: &Cx) -> Result<Response> {
(
[(CONTENT_TYPE, HeaderValue::from_static("application/xml"))],
Body::from(self.serialize(cx)?),
)
.into_response(cx)
}
}
#[derive(Clone, Debug)]
#[must_use]
pub struct SitemapUrl {
location: String,
last_modified: Option<SystemTime>,
change_frequency: Option<ChangeFrequency>,
priority: Option<f32>,
}
impl SitemapUrl {
pub fn new(location: impl Into<String>) -> Self {
Self {
location: location.into(),
last_modified: None,
change_frequency: None,
priority: None,
}
}
pub fn last_modified(mut self, last_modified: impl Into<SystemTime>) -> Self {
self.last_modified = Some(last_modified.into());
self
}
pub fn change_frequency(mut self, change_frequency: ChangeFrequency) -> Self {
self.change_frequency = Some(change_frequency);
self
}
pub fn priority(mut self, priority: f32) -> Self {
self.priority = Some(priority);
self
}
}
impl From<&str> for SitemapUrl {
fn from(location: &str) -> Self {
Self::new(location)
}
}
impl From<String> for SitemapUrl {
fn from(location: String) -> Self {
Self::new(location)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChangeFrequency {
Always,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
Never,
}
impl ChangeFrequency {
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",
}
}
}
#[derive(Debug)]
pub struct InvalidSitemapError {
description: &'static str,
}
impl InvalidSitemapError {
fn new(description: &'static str) -> Self {
Self { description }
}
#[must_use]
pub fn description(&self) -> &str {
self.description
}
}
impl fmt::Display for InvalidSitemapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid sitemap: {}", self.description)
}
}
impl std::error::Error for InvalidSitemapError {}
fn is_absolute(location: &str) -> bool {
["http://", "https://"].iter().any(|scheme| {
location
.get(..scheme.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme))
})
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use topcoat_core::{base_url::BaseUrl, context::CxTestBuilder};
use super::*;
use crate::to_bytes;
fn cx() -> Cx {
CxTestBuilder::new()
.app_context(BaseUrl::new("https://example.com").expect("a valid base URL"))
.build()
}
#[test]
fn an_empty_sitemap_is_an_empty_urlset() {
assert_eq!(
Sitemap::new().serialize(&cx()).unwrap(),
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n\
</urlset>\n"
);
}
#[test]
fn every_field_is_serialized() {
let url = SitemapUrl::new("/posts/1")
.last_modified(SystemTime::UNIX_EPOCH + Duration::from_hours(24))
.change_frequency(ChangeFrequency::Weekly)
.priority(0.8);
let xml = Sitemap::new().url(url).serialize(&cx()).unwrap();
assert!(xml.contains(
"<url>\
<loc>https://example.com/posts/1</loc>\
<lastmod>1970-01-02T00:00:00Z</lastmod>\
<changefreq>weekly</changefreq>\
<priority>0.8</priority>\
</url>"
));
}
#[test]
fn relative_locations_resolve_against_the_base_url() {
let xml = Sitemap::new()
.url("/")
.url("about")
.serialize(&cx())
.unwrap();
assert!(xml.contains("<loc>https://example.com/</loc>"));
assert!(xml.contains("<loc>https://example.com/about</loc>"));
}
#[test]
fn absolute_locations_are_used_as_is() {
let xml = Sitemap::new()
.url("https://cdn.example.com/page")
.url("HTTP://example.com/UPPER")
.serialize(&cx())
.unwrap();
assert!(xml.contains("<loc>https://cdn.example.com/page</loc>"));
assert!(xml.contains("<loc>HTTP://example.com/UPPER</loc>"));
}
#[test]
fn urls_adds_every_entry_of_an_iterator() {
let xml = Sitemap::new().urls(["/a", "/b"]).serialize(&cx()).unwrap();
assert!(xml.contains("<loc>https://example.com/a</loc>"));
assert!(xml.contains("<loc>https://example.com/b</loc>"));
}
#[test]
fn reserved_characters_in_locations_are_escaped() {
let xml = Sitemap::new()
.url("/search?q=<a>&sort=\"new\"")
.serialize(&cx())
.unwrap();
assert!(xml.contains("<loc>https://example.com/search?q=<a>&sort=\"new\"</loc>"));
}
#[test]
fn a_priority_outside_the_range_is_an_error() {
for priority in [-0.1, 1.1] {
let error = Sitemap::new()
.url(SitemapUrl::new("/").priority(priority))
.serialize(&cx())
.unwrap_err();
assert!(error.downcast_ref::<InvalidSitemapError>().is_some());
}
}
#[test]
#[should_panic(expected = "attempted to access the base URL")]
fn a_relative_location_without_a_base_url_panics() {
let _ = Sitemap::new().url("/").serialize(&Cx::default());
}
#[tokio::test]
async fn into_response_sets_the_xml_content_type() {
let response = Sitemap::new()
.url("/")
.into_response(&cx())
.expect("response builds");
assert_eq!(
response
.headers()
.get(CONTENT_TYPE)
.map(HeaderValue::as_bytes),
Some(b"application/xml".as_slice())
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("reading the response body");
assert!(body.starts_with(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
}
}