Skip to main content

sova_meta/
sitemap.rs

1//! Sitemap generation from RouteTable + providers.
2
3use 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/// Cache key version — bump via crate version so deploy invalidates shared KvStore entries.
15#[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/// Locale alternates for `xhtml:link` in urlset entries.
132#[derive(Debug, Clone)]
133pub struct HreflangOpts {
134    pub default: String,
135    pub path_prefix: bool,
136    /// `(code, iso)` with `seo: true`.
137    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
194/// Glob-ish match: `/admin`, `/admin/*`, `/api*`.
195pub 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 &registry.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('&', "&amp;")
361        .replace('<', "&lt;")
362        .replace('>', "&gt;")
363        .replace('"', "&quot;")
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;