1use crate::canonical::absolute_url;
4use crate::page::MetaPage;
5use bytes::Bytes;
6use chrono::{DateTime, Utc};
7use http::Method;
8use sova_core::extend::{BoxFuture, RouteEntry, RouteTable, StateMap};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12const MAX_URLS_PER_FILE: usize = 50_000;
13
14#[cfg(feature = "store")]
16pub const SITEMAP_KV_KEY: &str = concat!("sitemap:xml:v", env!("CARGO_PKG_VERSION"));
17
18#[derive(Debug, Clone, Copy)]
19pub enum ChangeFreq {
20 Always,
21 Hourly,
22 Daily,
23 Weekly,
24 Monthly,
25 Yearly,
26 Never,
27}
28
29impl ChangeFreq {
30 fn as_str(self) -> &'static str {
31 match self {
32 Self::Always => "always",
33 Self::Hourly => "hourly",
34 Self::Daily => "daily",
35 Self::Weekly => "weekly",
36 Self::Monthly => "monthly",
37 Self::Yearly => "yearly",
38 Self::Never => "never",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
44pub struct Entry {
45 pub path: String,
46 pub lastmod: Option<DateTime<Utc>>,
47 pub changefreq: Option<ChangeFreq>,
48 pub priority: Option<f32>,
49}
50
51impl Entry {
52 pub fn new(path: impl Into<String>) -> Self {
53 Self {
54 path: path.into(),
55 lastmod: None,
56 changefreq: None,
57 priority: None,
58 }
59 }
60
61 pub fn lastmod(mut self, t: DateTime<Utc>) -> Self {
62 self.lastmod = Some(t);
63 self
64 }
65
66 pub fn changefreq(mut self, c: ChangeFreq) -> Self {
67 self.changefreq = Some(c);
68 self
69 }
70
71 pub fn priority(mut self, p: f32) -> Self {
72 self.priority = Some(p);
73 self
74 }
75}
76
77pub struct SitemapCtx {
78 pub state: Arc<StateMap>,
79}
80
81type ProviderFn =
82 Arc<dyn Fn(SitemapCtx) -> BoxFuture<Result<Vec<Entry>, String>> + Send + Sync>;
83
84#[derive(Clone)]
85pub struct SitemapProvider {
86 pub pattern: String,
87 pub run: ProviderFn,
88}
89
90#[derive(Default)]
91pub struct SitemapRegistry {
92 pub providers: Vec<SitemapProvider>,
93}
94
95struct CacheSlot {
96 body: Bytes,
97 expires: Instant,
98}
99
100pub struct SitemapCache {
101 inner: std::sync::Mutex<Option<CacheSlot>>,
102 ttl: Duration,
103}
104
105impl SitemapCache {
106 pub fn new(ttl: Duration) -> Self {
107 Self {
108 inner: std::sync::Mutex::new(None),
109 ttl,
110 }
111 }
112
113 pub fn get(&self) -> Option<Bytes> {
114 let g = self.inner.lock().unwrap();
115 let slot = g.as_ref()?;
116 if Instant::now() < slot.expires {
117 Some(slot.body.clone())
118 } else {
119 None
120 }
121 }
122
123 pub fn set(&self, body: Bytes) {
124 *self.inner.lock().unwrap() = Some(CacheSlot {
125 body,
126 expires: Instant::now() + self.ttl,
127 });
128 }
129}
130
131#[derive(Debug, Clone)]
133pub struct HreflangOpts {
134 pub default: String,
135 pub path_prefix: bool,
136 pub locales: Vec<(String, String)>,
138}
139
140pub fn path_is_dynamic(path: &str) -> bool {
141 path.contains(':') || path.contains('*')
142}
143
144pub fn should_include_route(method: &Method, path: &str, meta: &sova_core::extend::MetaMap) -> bool {
145 if *method != Method::GET {
146 return false;
147 }
148 if path_is_dynamic(path) {
149 return false;
150 }
151 if let Some(page) = meta.get::<MetaPage>() {
152 if page.noindex {
153 return false;
154 }
155 }
156 #[cfg(feature = "openapi")]
157 {
158 if let Some(doc) = meta.get::<sova_openapi::Doc>() {
159 if !doc.is_skip() {
160 return false;
161 }
162 }
163 }
164 if path == "/sitemap.xml" || path.starts_with("/sitemap-") || path == "/robots.txt" {
165 return false;
166 }
167 true
168}
169
170pub async fn collect_entries(
171 state: &Arc<StateMap>,
172 registry: &SitemapRegistry,
173) -> Result<Vec<Entry>, String> {
174 collect_entries_with(state, registry, &CollectOpts::default()).await
175}
176
177#[derive(Debug, Clone)]
178pub struct CollectOpts {
179 pub from_routes: bool,
180 pub excludes: Vec<String>,
181 pub includes: Vec<Entry>,
182}
183
184impl Default for CollectOpts {
185 fn default() -> Self {
186 Self {
187 from_routes: true,
188 excludes: Vec::new(),
189 includes: Vec::new(),
190 }
191 }
192}
193
194pub fn path_excluded(path: &str, patterns: &[String]) -> bool {
196 patterns.iter().any(|p| match_exclude(path, p))
197}
198
199fn match_exclude(path: &str, pattern: &str) -> bool {
200 if let Some(prefix) = pattern.strip_suffix("/*") {
201 path == prefix || path.starts_with(&format!("{prefix}/"))
202 } else if let Some(prefix) = pattern.strip_suffix('*') {
203 path.starts_with(prefix)
204 } else {
205 path == pattern
206 }
207}
208
209pub async fn collect_entries_with(
210 state: &Arc<StateMap>,
211 registry: &SitemapRegistry,
212 opts: &CollectOpts,
213) -> Result<Vec<Entry>, String> {
214 let mut entries = Vec::new();
215 let mut seen = std::collections::HashSet::new();
216
217 if opts.from_routes {
218 if let Some(table) = state.get::<RouteTable>() {
219 for entry in &table.0 {
220 if let RouteEntry::Http {
221 method,
222 path,
223 meta,
224 } = entry
225 {
226 if !should_include_route(method, path.as_str(), meta) {
227 continue;
228 }
229 if path_excluded(path, &opts.excludes) {
230 continue;
231 }
232 if seen.insert(path.clone()) {
233 entries.push(Entry::new(path.clone()));
234 }
235 }
236 }
237 }
238 }
239
240 for e in &opts.includes {
241 if path_excluded(&e.path, &opts.excludes) {
242 continue;
243 }
244 if seen.insert(e.path.clone()) {
245 entries.push(e.clone());
246 }
247 }
248
249 for p in ®istry.providers {
250 let ctx = SitemapCtx {
251 state: Arc::clone(state),
252 };
253 let extra = (p.run)(ctx)
254 .await
255 .map_err(|e| format!("sitemap provider `{}`: {e}", p.pattern))?;
256 for e in extra {
257 if path_excluded(&e.path, &opts.excludes) {
258 continue;
259 }
260 if seen.insert(e.path.clone()) {
261 entries.push(e);
262 }
263 }
264 }
265 Ok(entries)
266}
267
268pub fn render_urlset(
269 public_url: &str,
270 entries: &[Entry],
271 hreflang: Option<&HreflangOpts>,
272) -> String {
273 let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
274 if hreflang.is_some() {
275 out.push_str(
276 "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" \
277 xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
278 );
279 } else {
280 out.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
281 }
282 for e in entries.iter().take(MAX_URLS_PER_FILE) {
283 let loc = absolute_url(public_url, &e.path);
284 out.push_str(" <url>\n");
285 out.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&loc)));
286 if let Some(t) = e.lastmod {
287 out.push_str(&format!(
288 " <lastmod>{}</lastmod>\n",
289 t.format("%Y-%m-%d")
290 ));
291 }
292 if let Some(c) = e.changefreq {
293 out.push_str(&format!(
294 " <changefreq>{}</changefreq>\n",
295 c.as_str()
296 ));
297 }
298 if let Some(p) = e.priority {
299 out.push_str(&format!(" <priority>{p:.1}</priority>\n"));
300 }
301 if let Some(h) = hreflang {
302 append_xhtml_links(&mut out, public_url, &e.path, h);
303 }
304 out.push_str(" </url>\n");
305 }
306 out.push_str("</urlset>\n");
307 out
308}
309
310fn append_xhtml_links(out: &mut String, public_url: &str, entry_path: &str, h: &HreflangOpts) {
311 #[cfg(feature = "i18n")]
312 {
313 use sova_i18n::{localized_url, strip_locale_prefix};
314 let codes: Vec<&str> = h.locales.iter().map(|(c, _)| c.as_str()).collect();
315 let bare = strip_locale_prefix(entry_path, &codes);
316 for (code, iso) in &h.locales {
317 let href = localized_url(public_url, &bare, code, &h.default, h.path_prefix);
318 out.push_str(&format!(
319 " <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\"/>\n",
320 xml_escape(iso),
321 xml_escape(&href)
322 ));
323 }
324 let href = localized_url(
325 public_url,
326 &bare,
327 &h.default,
328 &h.default,
329 h.path_prefix,
330 );
331 out.push_str(&format!(
332 " <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\"/>\n",
333 xml_escape(&href)
334 ));
335 }
336 #[cfg(not(feature = "i18n"))]
337 {
338 let _ = (out, public_url, entry_path, h);
339 }
340}
341
342pub fn render_sitemap_index(public_url: &str, count: usize) -> String {
343 let mut out = String::from(
344 r#"<?xml version="1.0" encoding="UTF-8"?>
345<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
346"#,
347 );
348 let files = count.div_ceil(MAX_URLS_PER_FILE).max(1);
349 for i in 1..=files {
350 let loc = absolute_url(public_url, &format!("/sitemap-{i}.xml"));
351 out.push_str(" <sitemap>\n");
352 out.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&loc)));
353 out.push_str(" </sitemap>\n");
354 }
355 out.push_str("</sitemapindex>\n");
356 out
357}
358
359fn xml_escape(s: &str) -> String {
360 s.replace('&', "&")
361 .replace('<', "<")
362 .replace('>', ">")
363 .replace('"', """)
364}
365
366pub async fn build_sitemap_body(
367 state: &Arc<StateMap>,
368 registry: &SitemapRegistry,
369 public_url: &str,
370 hreflang: Option<&HreflangOpts>,
371 opts: &CollectOpts,
372) -> Result<Bytes, String> {
373 let entries = collect_entries_with(state, registry, opts).await?;
374 let xml = if entries.len() > MAX_URLS_PER_FILE {
375 render_sitemap_index(public_url, entries.len())
376 } else {
377 render_urlset(public_url, &entries, hreflang)
378 };
379 Ok(Bytes::from(xml))
380}
381
382pub const MAX_URLS: usize = MAX_URLS_PER_FILE;