use std::fmt::Write as _;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::Response;
use axum::routing::get;
#[cfg(feature = "maud")]
use maud::{Markup, html};
#[derive(Debug, Clone)]
pub struct SitemapEntry {
pub loc: String,
pub lastmod: Option<String>,
pub changefreq: Option<SitemapChangefreq>,
pub priority: Option<f32>,
}
impl SitemapEntry {
pub fn new(loc: impl Into<String>) -> Self {
Self {
loc: loc.into(),
lastmod: None,
changefreq: None,
priority: None,
}
}
#[must_use]
pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
self.lastmod = Some(lastmod.into());
self
}
#[must_use]
pub const fn changefreq(mut self, changefreq: SitemapChangefreq) -> Self {
self.changefreq = Some(changefreq);
self
}
#[must_use]
pub const fn priority(mut self, priority: f32) -> Self {
self.priority = Some(priority.clamp(0.0, 1.0));
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SitemapChangefreq {
Always,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
Never,
}
impl SitemapChangefreq {
#[must_use]
pub(crate) const 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",
}
}
}
pub trait SitemapSource: Send + Sync {
fn entries(&self) -> Pin<Box<dyn Future<Output = Vec<SitemapEntry>> + Send + '_>>;
}
#[doc(hidden)]
pub struct RegisteredSitemapSources(pub Vec<Arc<dyn SitemapSource>>);
#[doc(hidden)]
pub struct RegisteredSeoConfig(pub crate::config::SeoConfig);
#[must_use]
pub fn robots_txt(profile: &str, sitemap_url: Option<&str>, additional_rules: &[String]) -> String {
let mut txt = String::new();
let is_prod = matches!(profile, "prod" | "production");
if is_prod {
txt.push_str("User-agent: *\nAllow: /\n");
} else {
txt.push_str("User-agent: *\nDisallow: /\n");
}
for rule in additional_rules {
txt.push_str(rule);
txt.push('\n');
}
if let Some(url) = sitemap_url {
txt.push('\n');
txt.push_str("Sitemap: ");
txt.push_str(url);
txt.push('\n');
}
txt
}
#[must_use]
pub fn sitemap_xml(entries: &[SitemapEntry], _base_url: Option<&str>) -> String {
const CHUNK_SIZE: usize = 50_000;
if entries.len() > CHUNK_SIZE {
tracing::warn!(
count = entries.len(),
limit = CHUNK_SIZE,
"sitemap: entry count exceeds the {CHUNK_SIZE}-URL per-file limit; \
only the first {CHUNK_SIZE} entries will be served. \
Register a custom /sitemap.xml handler to serve a sitemap index for larger sites.",
);
return sitemap_urlset_xml(&entries[..CHUNK_SIZE]);
}
sitemap_urlset_xml(entries)
}
#[must_use]
pub(crate) fn sitemap_urlset_xml(entries: &[SitemapEntry]) -> String {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">",
);
for entry in entries {
xml.push_str("\n <url>");
xml.push_str("\n <loc>");
xml.push_str(&xml_escape(&entry.loc));
xml.push_str("</loc>");
if let Some(lastmod) = &entry.lastmod {
xml.push_str("\n <lastmod>");
xml.push_str(lastmod);
xml.push_str("</lastmod>");
}
if let Some(freq) = entry.changefreq {
xml.push_str("\n <changefreq>");
xml.push_str(freq.as_str());
xml.push_str("</changefreq>");
}
if let Some(prio) = entry.priority {
xml.push_str("\n <priority>");
write!(xml, "{prio:.1}").ok();
xml.push_str("</priority>");
}
xml.push_str("\n </url>");
}
xml.push_str("\n</urlset>");
xml
}
fn xml_escape(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(c),
}
}
escaped
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SeoMeta {
title: Option<String>,
description: Option<String>,
canonical: Option<String>,
og_title: Option<String>,
og_description: Option<String>,
og_image: Option<String>,
og_type: Option<String>,
og_url: Option<String>,
twitter_card: Option<String>,
twitter_title: Option<String>,
twitter_description: Option<String>,
twitter_image: Option<String>,
robots_directive: Option<String>,
hreflang_alternates: Vec<(String, String)>,
}
impl SeoMeta {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn canonical(mut self, url: impl Into<String>) -> Self {
self.canonical = Some(url.into());
self
}
#[must_use]
pub fn og_image(mut self, url: impl Into<String>) -> Self {
self.og_image = Some(url.into());
self
}
#[must_use]
pub fn og_type(mut self, og_type: impl Into<String>) -> Self {
self.og_type = Some(og_type.into());
self
}
#[must_use]
pub fn og_title(mut self, title: impl Into<String>) -> Self {
self.og_title = Some(title.into());
self
}
#[must_use]
pub fn og_description(mut self, desc: impl Into<String>) -> Self {
self.og_description = Some(desc.into());
self
}
#[must_use]
pub fn og_url(mut self, url: impl Into<String>) -> Self {
self.og_url = Some(url.into());
self
}
#[must_use]
pub fn twitter_card(mut self, card_type: impl Into<String>) -> Self {
self.twitter_card = Some(card_type.into());
self
}
#[must_use]
pub fn twitter_title(mut self, title: impl Into<String>) -> Self {
self.twitter_title = Some(title.into());
self
}
#[must_use]
pub fn twitter_description(mut self, desc: impl Into<String>) -> Self {
self.twitter_description = Some(desc.into());
self
}
#[must_use]
pub fn twitter_image(mut self, url: impl Into<String>) -> Self {
self.twitter_image = Some(url.into());
self
}
#[must_use]
pub fn robots(mut self, directive: impl Into<String>) -> Self {
self.robots_directive = Some(directive.into());
self
}
#[must_use]
pub fn hreflang_alternates(mut self, alternates: Vec<(String, String)>) -> Self {
self.hreflang_alternates = alternates;
self
}
#[cfg(feature = "maud")]
#[must_use]
pub fn render(&self) -> Markup {
let og_title = self.og_title.as_ref().or(self.title.as_ref());
let og_desc = self.og_description.as_ref().or(self.description.as_ref());
let twitter_title = self.twitter_title.as_ref().or(self.title.as_ref());
let twitter_desc = self
.twitter_description
.as_ref()
.or(self.description.as_ref());
let og_url = self.og_url.as_ref().or(self.canonical.as_ref());
let has_twitter = self.twitter_card.is_some();
html! {
@if let Some(title) = &self.title {
title { (title) }
}
@if let Some(desc) = &self.description {
meta name="description" content=(desc);
}
@if let Some(dir) = &self.robots_directive {
meta name="robots" content=(dir);
}
@if let Some(url) = &self.canonical {
link rel="canonical" href=(url);
}
@if let Some(t) = og_title {
meta property="og:title" content=(t);
}
@if let Some(d) = og_desc {
meta property="og:description" content=(d);
}
@if let Some(img) = &self.og_image {
meta property="og:image" content=(img);
}
@if let Some(ot) = &self.og_type {
meta property="og:type" content=(ot);
}
@if let Some(url) = og_url {
meta property="og:url" content=(url);
}
@if let Some(card) = &self.twitter_card {
meta name="twitter:card" content=(card);
}
@if has_twitter {
@if let Some(t) = twitter_title {
meta name="twitter:title" content=(t);
}
@if let Some(d) = twitter_desc {
meta name="twitter:description" content=(d);
}
}
@if let Some(img) = &self.twitter_image {
meta name="twitter:image" content=(img);
}
@for (lang, href) in &self.hreflang_alternates {
link rel="alternate" hreflang=(lang) href=(href);
}
}
}
}
#[must_use]
pub fn locale_alternates(
base_url: &str,
path: &str,
default_locale: &str,
supported_locales: &[String],
) -> Vec<(String, String)> {
let base_url = base_url.trim_end_matches('/');
let join = |locale: &str| -> String {
if path == "/" {
format!("{base_url}/{locale}")
} else {
format!("{base_url}/{locale}{path}")
}
};
let mut alternates: Vec<(String, String)> = supported_locales
.iter()
.map(|locale| (locale.clone(), join(locale)))
.collect();
alternates.push(("x-default".to_owned(), join(default_locale)));
alternates
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SeoRouteDefaults {
pub title: Option<&'static str>,
pub description: Option<&'static str>,
pub canonical: Option<&'static str>,
pub og_title: Option<&'static str>,
pub og_description: Option<&'static str>,
pub og_image: Option<&'static str>,
pub og_type: Option<&'static str>,
pub og_url: Option<&'static str>,
pub twitter_card: Option<&'static str>,
pub twitter_title: Option<&'static str>,
pub twitter_description: Option<&'static str>,
pub twitter_image: Option<&'static str>,
pub robots: Option<&'static str>,
}
impl SeoRouteDefaults {
pub const EMPTY: Self = Self {
title: None,
description: None,
canonical: None,
og_title: None,
og_description: None,
og_image: None,
og_type: None,
og_url: None,
twitter_card: None,
twitter_title: None,
twitter_description: None,
twitter_image: None,
robots: None,
};
#[must_use]
pub const fn with_title(mut self, value: &'static str) -> Self {
self.title = Some(value);
self
}
#[must_use]
pub const fn with_description(mut self, value: &'static str) -> Self {
self.description = Some(value);
self
}
#[must_use]
pub const fn with_canonical(mut self, value: &'static str) -> Self {
self.canonical = Some(value);
self
}
#[must_use]
pub const fn with_og_title(mut self, value: &'static str) -> Self {
self.og_title = Some(value);
self
}
#[must_use]
pub const fn with_og_description(mut self, value: &'static str) -> Self {
self.og_description = Some(value);
self
}
#[must_use]
pub const fn with_og_image(mut self, value: &'static str) -> Self {
self.og_image = Some(value);
self
}
#[must_use]
pub const fn with_og_type(mut self, value: &'static str) -> Self {
self.og_type = Some(value);
self
}
#[must_use]
pub const fn with_og_url(mut self, value: &'static str) -> Self {
self.og_url = Some(value);
self
}
#[must_use]
pub const fn with_twitter_card(mut self, value: &'static str) -> Self {
self.twitter_card = Some(value);
self
}
#[must_use]
pub const fn with_twitter_title(mut self, value: &'static str) -> Self {
self.twitter_title = Some(value);
self
}
#[must_use]
pub const fn with_twitter_description(mut self, value: &'static str) -> Self {
self.twitter_description = Some(value);
self
}
#[must_use]
pub const fn with_twitter_image(mut self, value: &'static str) -> Self {
self.twitter_image = Some(value);
self
}
#[must_use]
pub const fn with_robots(mut self, value: &'static str) -> Self {
self.robots = Some(value);
self
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.canonical.is_none()
&& self.og_title.is_none()
&& self.og_description.is_none()
&& self.og_image.is_none()
&& self.og_type.is_none()
&& self.og_url.is_none()
&& self.twitter_card.is_none()
&& self.twitter_title.is_none()
&& self.twitter_description.is_none()
&& self.twitter_image.is_none()
&& self.robots.is_none()
}
#[must_use]
pub fn to_meta(&self) -> SeoMeta {
let mut meta = SeoMeta::new();
if let Some(v) = self.title {
meta = meta.title(v);
}
if let Some(v) = self.description {
meta = meta.description(v);
}
if let Some(v) = self.canonical {
meta = meta.canonical(v);
}
if let Some(v) = self.og_title {
meta = meta.og_title(v);
}
if let Some(v) = self.og_description {
meta = meta.og_description(v);
}
if let Some(v) = self.og_image {
meta = meta.og_image(v);
}
if let Some(v) = self.og_type {
meta = meta.og_type(v);
}
if let Some(v) = self.og_url {
meta = meta.og_url(v);
}
if let Some(v) = self.twitter_card {
meta = meta.twitter_card(v);
}
if let Some(v) = self.twitter_title {
meta = meta.twitter_title(v);
}
if let Some(v) = self.twitter_description {
meta = meta.twitter_description(v);
}
if let Some(v) = self.twitter_image {
meta = meta.twitter_image(v);
}
if let Some(v) = self.robots {
meta = meta.robots(v);
}
meta
}
}
impl From<SeoRouteDefaults> for SeoMeta {
fn from(defaults: SeoRouteDefaults) -> Self {
defaults.to_meta()
}
}
impl<S> axum::extract::FromRequestParts<S> for SeoMeta
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(parts
.extensions
.get::<SeoRouteDefaults>()
.map_or_else(Self::new, SeoRouteDefaults::to_meta))
}
}
pub fn build_seo_router<S>(
profile: &str,
base_url: Option<&str>,
additional_rules: &[String],
) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
build_seo_router_with_entries(profile, base_url, additional_rules, &[])
}
pub fn build_seo_router_with_entries<S>(
profile: &str,
base_url: Option<&str>,
additional_rules: &[String],
entries: &[SitemapEntry],
) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let base_url = base_url.map(|u| u.trim_end_matches('/'));
let sitemap_url = base_url.map(|b| format!("{b}/sitemap.xml"));
let robots_body = robots_txt(profile, sitemap_url.as_deref(), additional_rules);
let sitemap_body = sitemap_xml(entries, base_url);
build_seo_router_from_bodies(robots_body, sitemap_body)
}
pub fn build_seo_router_from_bodies<S>(robots_body: String, sitemap_body: String) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::<S>::new()
.route(
"/robots.txt",
get(move || {
let body = robots_body.clone();
async move {
Response::builder()
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(body))
.unwrap()
}
}),
)
.route(
"/sitemap.xml",
get(move || {
let body = sitemap_body.clone();
async move {
Response::builder()
.header("Content-Type", "application/xml; charset=utf-8")
.body(Body::from(body))
.unwrap()
}
}),
)
}
pub(crate) const fn has_seo_config(seo_cfg: &crate::config::SeoConfig) -> bool {
seo_cfg.base_url.is_some()
|| !seo_cfg.robots.additional_rules.is_empty()
|| seo_cfg.robots.allow_all.is_some()
|| seo_cfg.robots.sitemap_url.is_some()
}
pub(crate) const fn effective_seo_profile(raw_profile: &str, allow_all: Option<bool>) -> &str {
match allow_all {
Some(true) => "prod",
Some(false) => "dev",
None => raw_profile,
}
}
pub(crate) fn robots_directive_is_noindex(directive: &str) -> bool {
directive
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case("noindex"))
}
#[must_use]
pub(crate) fn defaults_exclude_from_sitemap(defaults: SeoRouteDefaults) -> bool {
defaults.robots.is_some_and(robots_directive_is_noindex)
}
pub(crate) async fn assemble_seo_bodies(
profile: &str,
base_url: Option<&str>,
sitemap_url_override: Option<&str>,
additional_rules: &[String],
sources: &[Arc<dyn SitemapSource>],
static_paths: &[&str],
locale: Option<SitemapLocaleConfig<'_>>,
) -> (String, String) {
let base_url = base_url.map(|u| u.trim_end_matches('/'));
let mut sitemap_entries = Vec::new();
for source in sources {
let mut entries = source.entries().await;
sitemap_entries.append(&mut entries);
}
if let Some(bu) = base_url {
for path in static_paths {
if path.contains('{') {
continue;
}
match &locale {
Some(loc)
if !loc.supported_locales.is_empty()
&& !loc.exclude_exact.iter().any(|p| p == path)
&& !matches_locale_exclude_prefix(path, loc.exclude_prefixes) =>
{
for locale_code in loc.supported_locales {
let entry = if *path == "/" {
format!("{bu}/{locale_code}")
} else {
format!("{bu}/{locale_code}{path}")
};
sitemap_entries.push(SitemapEntry::new(entry));
}
}
_ => sitemap_entries.push(SitemapEntry::new(format!("{bu}{path}"))),
}
}
}
let derived_sitemap_url = base_url.map(|b| format!("{b}/sitemap.xml"));
let sitemap_url = sitemap_url_override.or(derived_sitemap_url.as_deref());
let robots_body = robots_txt(profile, sitemap_url, additional_rules);
let sitemap_body = sitemap_xml(&sitemap_entries, base_url);
(robots_body, sitemap_body)
}
pub(crate) struct SitemapLocaleConfig<'a> {
pub supported_locales: &'a [String],
pub exclude_prefixes: &'a [String],
pub exclude_exact: &'a [String],
}
fn matches_locale_exclude_prefix(path: &str, prefixes: &[String]) -> bool {
prefixes.iter().any(|raw| {
let prefix = raw.strip_suffix("/*").unwrap_or(raw.as_str());
let prefix = if prefix == "/" {
prefix
} else {
prefix.strip_suffix('/').unwrap_or(prefix)
};
!prefix.is_empty() && (path == prefix || path.starts_with(&format!("{prefix}/")))
})
}
pub async fn write_seo_files(
dist_dir: &Path,
profile: &str,
base_url: Option<&str>,
sitemap_url_override: Option<&str>,
additional_rules: &[String],
entries: &[SitemapEntry],
) -> Result<(), std::io::Error> {
let base_url = base_url.map(|u| u.trim_end_matches('/'));
let derived_sitemap_url = base_url.map(|b| format!("{b}/sitemap.xml"));
let sitemap_url = sitemap_url_override.or(derived_sitemap_url.as_deref());
let robots = robots_txt(profile, sitemap_url, additional_rules);
let sitemap = sitemap_xml(entries, base_url);
tokio::fs::write(dist_dir.join("robots.txt"), robots).await?;
tokio::fs::write(dist_dir.join("sitemap.xml"), sitemap).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sitemap_entry_builder() {
let e = SitemapEntry::new("https://example.com/")
.lastmod("2026-01-01")
.changefreq(SitemapChangefreq::Weekly)
.priority(0.9);
assert_eq!(e.loc, "https://example.com/");
assert_eq!(e.lastmod.as_deref(), Some("2026-01-01"));
assert_eq!(e.changefreq, Some(SitemapChangefreq::Weekly));
assert!((e.priority.unwrap() - 0.9).abs() < 0.001);
}
#[test]
fn sitemap_entry_priority_clamped() {
let hi = SitemapEntry::new("https://example.com/").priority(1.5);
let lo = SitemapEntry::new("https://example.com/").priority(-0.5);
assert!((hi.priority.unwrap() - 1.0).abs() < 0.001);
assert!((lo.priority.unwrap() - 0.0).abs() < 0.001);
}
#[test]
fn xml_escape_replaces_special_chars() {
assert_eq!(
xml_escape("a&b<c>d\"e'f"),
"a&b<c>d"e'f"
);
}
#[test]
fn robots_txt_staging_profile_disallows() {
let txt = robots_txt("staging", None, &[]);
assert!(txt.contains("Disallow: /"));
assert!(!txt.contains("Allow: /"));
}
#[test]
fn has_seo_config_false_when_empty() {
let cfg = crate::config::SeoConfig::default();
assert!(!has_seo_config(&cfg));
}
#[test]
fn has_seo_config_true_when_base_url_set() {
let cfg = crate::config::SeoConfig {
base_url: Some("https://example.com".to_string()),
..Default::default()
};
assert!(has_seo_config(&cfg));
}
#[test]
fn has_seo_config_true_when_allow_all_set() {
let cfg = crate::config::SeoConfig {
robots: crate::config::RobotsConfig {
allow_all: Some(true),
..Default::default()
},
..Default::default()
};
assert!(has_seo_config(&cfg));
}
#[test]
fn has_seo_config_true_when_sitemap_url_set() {
let cfg = crate::config::SeoConfig {
robots: crate::config::RobotsConfig {
sitemap_url: Some("https://example.com/sitemap.xml".to_string()),
..Default::default()
},
..Default::default()
};
assert!(has_seo_config(&cfg));
}
#[test]
fn has_seo_config_true_when_additional_rules_set() {
let cfg = crate::config::SeoConfig {
robots: crate::config::RobotsConfig {
additional_rules: vec!["Disallow: /admin".to_string()],
..Default::default()
},
..Default::default()
};
assert!(has_seo_config(&cfg));
}
#[test]
fn effective_seo_profile_respects_allow_all_true() {
assert_eq!(effective_seo_profile("dev", Some(true)), "prod");
}
#[test]
fn effective_seo_profile_respects_allow_all_false() {
assert_eq!(effective_seo_profile("prod", Some(false)), "dev");
}
#[test]
fn effective_seo_profile_falls_back_to_raw_when_none() {
assert_eq!(effective_seo_profile("staging", None), "staging");
}
struct SimpleSitemapSource {
entries: Vec<SitemapEntry>,
}
impl SitemapSource for SimpleSitemapSource {
fn entries(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<SitemapEntry>> + Send + '_>>
{
let entries = self.entries.clone();
Box::pin(async move { entries })
}
}
#[tokio::test]
async fn assemble_seo_bodies_empty() {
let (robots, sitemap) = assemble_seo_bodies("prod", None, None, &[], &[], &[], None).await;
assert!(robots.contains("Allow: /"));
assert!(sitemap.contains("<urlset"));
}
#[tokio::test]
async fn assemble_seo_bodies_collects_source_entries() {
let source = Arc::new(SimpleSitemapSource {
entries: vec![SitemapEntry::new("https://example.com/post/1")],
}) as Arc<dyn SitemapSource>;
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[source],
&[],
None,
)
.await;
assert!(
sitemap.contains("https://example.com/post/1"),
"should include source entry; got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_includes_static_paths() {
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/about", "/contact"],
None,
)
.await;
assert!(sitemap.contains("https://example.com/about"));
assert!(sitemap.contains("https://example.com/contact"));
}
#[tokio::test]
async fn assemble_seo_bodies_skips_dynamic_paths() {
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/posts/{slug}"],
None,
)
.await;
assert!(
!sitemap.contains("/posts/"),
"should skip paths with params; got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_uses_sitemap_url_override() {
let (robots, _) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
Some("https://cdn.example.com/sitemap.xml"),
&[],
&[],
&[],
None,
)
.await;
assert!(
robots.contains("Sitemap: https://cdn.example.com/sitemap.xml"),
"should use override url; got:\n{robots}"
);
}
#[test]
fn robots_directive_noindex_detection() {
assert!(robots_directive_is_noindex("noindex"));
assert!(robots_directive_is_noindex("noindex, nofollow"));
assert!(robots_directive_is_noindex("nofollow, noindex"));
assert!(robots_directive_is_noindex("NoIndex"));
assert!(robots_directive_is_noindex(" noindex "));
assert!(!robots_directive_is_noindex("noarchive"));
assert!(!robots_directive_is_noindex("index, follow"));
assert!(!robots_directive_is_noindex("max-snippet:-1"));
}
#[test]
fn defaults_exclude_from_sitemap_only_for_noindex() {
assert!(!defaults_exclude_from_sitemap(SeoRouteDefaults::EMPTY));
assert!(!defaults_exclude_from_sitemap(
SeoRouteDefaults::EMPTY.with_title("About")
));
assert!(!defaults_exclude_from_sitemap(
SeoRouteDefaults::EMPTY.with_robots("nofollow")
));
assert!(defaults_exclude_from_sitemap(
SeoRouteDefaults::EMPTY.with_robots("noindex, nofollow")
));
}
#[tokio::test]
async fn assemble_seo_bodies_does_not_filter_registered_source_entries() {
let source = Arc::new(SimpleSitemapSource {
entries: vec![SitemapEntry::new("https://example.com/posts/hello")],
}) as Arc<dyn SitemapSource>;
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[source],
&[],
None,
)
.await;
assert!(
sitemap.contains("https://example.com/posts/hello"),
"explicitly registered source entries must survive; got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_skips_parameterized_templates() {
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/posts/{slug}"],
None,
)
.await;
assert!(
!sitemap.contains("/posts/"),
"parameterized templates must never be advertised; got:\n{sitemap}"
);
}
#[test]
fn route_defaults_setters_are_const_and_chainable() {
const DEFAULTS: SeoRouteDefaults = SeoRouteDefaults::EMPTY
.with_title("About")
.with_og_type("website");
assert_eq!(DEFAULTS.title, Some("About"));
assert_eq!(DEFAULTS.og_type, Some("website"));
assert_eq!(DEFAULTS.description, None);
}
#[test]
fn route_defaults_empty_is_default() {
assert_eq!(SeoRouteDefaults::default(), SeoRouteDefaults::EMPTY);
assert!(SeoRouteDefaults::EMPTY.is_empty());
}
#[test]
fn route_defaults_is_empty_false_when_any_key_set() {
let defaults = SeoRouteDefaults {
og_type: Some("article"),
..SeoRouteDefaults::EMPTY
};
assert!(!defaults.is_empty());
}
#[test]
fn route_defaults_to_meta_populates_every_key() {
let defaults = SeoRouteDefaults {
title: Some("T"),
description: Some("D"),
canonical: Some("C"),
og_title: Some("OT"),
og_description: Some("OD"),
og_image: Some("OI"),
og_type: Some("OTY"),
og_url: Some("OU"),
twitter_card: Some("TC"),
twitter_title: Some("TT"),
twitter_description: Some("TD"),
twitter_image: Some("TI"),
robots: Some("noindex"),
};
let expected = SeoMeta::new()
.title("T")
.description("D")
.canonical("C")
.og_title("OT")
.og_description("OD")
.og_image("OI")
.og_type("OTY")
.og_url("OU")
.twitter_card("TC")
.twitter_title("TT")
.twitter_description("TD")
.twitter_image("TI")
.robots("noindex");
assert_eq!(defaults.to_meta(), expected);
}
#[test]
fn route_defaults_empty_to_meta_is_empty_builder() {
assert_eq!(SeoRouteDefaults::EMPTY.to_meta(), SeoMeta::new());
}
#[tokio::test]
async fn extractor_resolves_route_defaults_from_extension() {
use axum::extract::FromRequestParts;
let mut parts = axum::http::Request::builder()
.uri("/about")
.body(())
.unwrap()
.into_parts()
.0;
parts.extensions.insert(SeoRouteDefaults {
title: Some("About"),
..SeoRouteDefaults::EMPTY
});
let meta = SeoMeta::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(meta, SeoMeta::new().title("About"));
}
#[tokio::test]
async fn extractor_yields_empty_builder_without_extension() {
use axum::extract::FromRequestParts;
let mut parts = axum::http::Request::builder()
.uri("/bare")
.body(())
.unwrap()
.into_parts()
.0;
let meta = SeoMeta::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(meta, SeoMeta::new());
}
#[tokio::test]
async fn extractor_result_is_refinable_by_the_handler() {
use axum::extract::FromRequestParts;
let mut parts = axum::http::Request::builder()
.uri("/posts/hello")
.body(())
.unwrap()
.into_parts()
.0;
parts.extensions.insert(SeoRouteDefaults {
og_type: Some("article"),
title: Some("Attribute Title"),
..SeoRouteDefaults::EMPTY
});
let meta = SeoMeta::from_request_parts(&mut parts, &())
.await
.unwrap()
.title("Handler Title");
assert_eq!(
meta,
SeoMeta::new().og_type("article").title("Handler Title")
);
}
#[tokio::test]
async fn assemble_seo_bodies_trims_trailing_slash() {
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com/"),
None,
&[],
&[],
&["/about"],
None,
)
.await;
assert!(
sitemap.contains("https://example.com/about"),
"base_url trailing slash should be trimmed; got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_lists_each_localized_url_when_locale_prefix_enabled() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/about"],
Some(SitemapLocaleConfig {
supported_locales: &supported,
exclude_prefixes: &[],
exclude_exact: &[],
}),
)
.await;
assert!(
sitemap.contains("https://example.com/en/about"),
"should list the en-prefixed URL; got:\n{sitemap}"
);
assert!(
sitemap.contains("https://example.com/es/about"),
"should list the es-prefixed URL; got:\n{sitemap}"
);
assert!(
!sitemap.contains(">https://example.com/about<"),
"unprefixed URL should not also be listed; got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_root_static_path_has_no_trailing_slash_per_locale() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/"],
Some(SitemapLocaleConfig {
supported_locales: &supported,
exclude_prefixes: &[],
exclude_exact: &[],
}),
)
.await;
assert!(
sitemap.contains(">https://example.com/en<"),
"got:\n{sitemap}"
);
assert!(
!sitemap.contains(">https://example.com/en/<"),
"got:\n{sitemap}"
);
}
#[tokio::test]
async fn assemble_seo_bodies_leaves_excluded_prefixes_unlocalized_in_sitemap() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let exclude = vec!["/api".to_owned()];
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/about", "/api/status"],
Some(SitemapLocaleConfig {
supported_locales: &supported,
exclude_prefixes: &exclude,
exclude_exact: &[],
}),
)
.await;
assert!(sitemap.contains("https://example.com/en/about"));
assert!(
sitemap.contains("https://example.com/api/status"),
"excluded prefix should list its unprefixed URL; got:\n{sitemap}"
);
assert!(!sitemap.contains("https://example.com/en/api/status"));
}
#[tokio::test]
async fn assemble_seo_bodies_root_exclude_prefix_excludes_exactly_the_root() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let exclude = vec!["/".to_owned()];
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/", "/about"],
Some(SitemapLocaleConfig {
supported_locales: &supported,
exclude_prefixes: &exclude,
exclude_exact: &[],
}),
)
.await;
assert!(
sitemap.contains(">https://example.com/<"),
"excluded root should list its unprefixed URL; got:\n{sitemap}"
);
assert!(!sitemap.contains(">https://example.com/en<"));
assert!(sitemap.contains("https://example.com/en/about"));
}
#[tokio::test]
async fn assemble_seo_bodies_does_not_exclude_path_sharing_a_string_prefix() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let exclude = vec!["/api".to_owned()];
let (_, sitemap) = assemble_seo_bodies(
"prod",
Some("https://example.com"),
None,
&[],
&[],
&["/apikeys"],
Some(SitemapLocaleConfig {
supported_locales: &supported,
exclude_prefixes: &exclude,
exclude_exact: &[],
}),
)
.await;
assert!(
sitemap.contains("https://example.com/en/apikeys"),
"/apikeys must be localized, not swept in with /api; got:\n{sitemap}"
);
assert!(sitemap.contains("https://example.com/es/apikeys"));
}
#[test]
fn locale_alternates_includes_every_supported_locale_and_x_default() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let alternates = locale_alternates("https://example.com", "/posts", "en", &supported);
assert_eq!(
alternates,
vec![
("en".to_owned(), "https://example.com/en/posts".to_owned()),
("es".to_owned(), "https://example.com/es/posts".to_owned()),
(
"x-default".to_owned(),
"https://example.com/en/posts".to_owned()
),
]
);
}
#[test]
fn locale_alternates_trims_base_url_trailing_slash() {
let supported = vec!["en".to_owned()];
let alternates = locale_alternates("https://example.com/", "/about", "en", &supported);
assert_eq!(
alternates,
vec![
("en".to_owned(), "https://example.com/en/about".to_owned()),
(
"x-default".to_owned(),
"https://example.com/en/about".to_owned()
),
]
);
}
#[test]
fn locale_alternates_root_path_has_no_trailing_slash() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let alternates = locale_alternates("https://example.com", "/", "en", &supported);
assert_eq!(
alternates,
vec![
("en".to_owned(), "https://example.com/en".to_owned()),
("es".to_owned(), "https://example.com/es".to_owned()),
("x-default".to_owned(), "https://example.com/en".to_owned()),
]
);
}
#[cfg(feature = "maud")]
#[test]
fn seo_meta_renders_hreflang_alternate_links() {
let meta = SeoMeta::new().hreflang_alternates(locale_alternates(
"https://example.com",
"/posts",
"en",
&["en".to_owned(), "es".to_owned()],
));
let rendered = meta.render().into_string();
assert!(rendered.contains(
r#"<link rel="alternate" hreflang="en" href="https://example.com/en/posts">"#
));
assert!(rendered.contains(
r#"<link rel="alternate" hreflang="es" href="https://example.com/es/posts">"#
));
assert!(rendered.contains(
r#"<link rel="alternate" hreflang="x-default" href="https://example.com/en/posts">"#
));
}
#[cfg(feature = "maud")]
#[test]
fn seo_meta_without_alternates_renders_no_hreflang_links() {
let meta = SeoMeta::new().title("Home");
assert!(!meta.render().into_string().contains("hreflang"));
}
}