Skip to main content

sova_meta/
sitemap_plugin.rs

1//! Plugin `Sitemap` — `/sitemap.xml` (+ pagination).
2
3use crate::defaults::MetaDefaults;
4use crate::sitemap::{
5    build_sitemap_body, collect_entries_with, render_urlset, CollectOpts, Entry, HreflangOpts,
6    SitemapCache, SitemapProvider, SitemapRegistry, MAX_URLS,
7};
8use crate::sitemap_config::SitemapConfig;
9#[cfg(feature = "store")]
10use crate::sitemap::SITEMAP_KV_KEY;
11use sova_core::extend::{BoxFuture, RouteTable};
12use sova_core::{App, Plugin, Request, Response};
13use std::sync::Arc;
14use std::time::Duration;
15
16#[cfg(feature = "store")]
17use sova_store::KvStore;
18
19/// Optional shared sitemap cache (feature `store`).
20#[cfg(feature = "store")]
21#[derive(Clone)]
22pub struct SitemapStore(pub Arc<dyn KvStore>);
23
24/// Sitemap plugin: auto routes + exclude/include + providers.
25pub struct Sitemap {
26    public_url: Option<String>,
27    path: String,
28    ttl: Duration,
29    from_routes: bool,
30    excludes: Vec<String>,
31    includes: Vec<Entry>,
32    registry: SitemapRegistry,
33    #[cfg(feature = "store")]
34    cache_store: Option<Arc<dyn KvStore>>,
35}
36
37impl Sitemap {
38    pub fn new() -> Self {
39        Self {
40            public_url: None,
41            path: "/sitemap.xml".into(),
42            ttl: Duration::from_secs(3600),
43            from_routes: true,
44            excludes: Vec::new(),
45            includes: Vec::new(),
46            registry: SitemapRegistry::default(),
47            #[cfg(feature = "store")]
48            cache_store: None,
49        }
50    }
51
52    pub fn public_url(mut self, u: impl Into<String>) -> Self {
53        self.public_url = Some(u.into());
54        self
55    }
56
57    pub fn path(mut self, p: impl Into<String>) -> Self {
58        self.path = p.into();
59        self
60    }
61
62    pub fn ttl(mut self, d: Duration) -> Self {
63        self.ttl = d;
64        self
65    }
66
67    pub fn from_routes(mut self, yes: bool) -> Self {
68        self.from_routes = yes;
69        self
70    }
71
72    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
73        self.excludes.push(pattern.into());
74        self
75    }
76
77    pub fn include(mut self, path: impl Into<String>) -> Self {
78        self.includes.push(Entry::new(path));
79        self
80    }
81
82    /// Shared KvStore for sitemap XML (L2 after in-process TTL cache).
83    #[cfg(feature = "store")]
84    pub fn cache_store(mut self, store: Arc<dyn KvStore>) -> Self {
85        self.cache_store = Some(store);
86        self
87    }
88
89    pub fn provider<F, Fut>(mut self, pattern: impl Into<String>, f: F) -> Self
90    where
91        F: Fn(crate::sitemap::SitemapCtx) -> Fut + Send + Sync + 'static,
92        Fut: std::future::Future<Output = Result<Vec<Entry>, String>> + Send + 'static,
93    {
94        let run = Arc::new(move |ctx| {
95            let fut = f(ctx);
96            Box::pin(fut) as BoxFuture<Result<Vec<Entry>, String>>
97        });
98        self.registry.providers.push(SitemapProvider {
99            pattern: pattern.into(),
100            run,
101        });
102        self
103    }
104}
105
106impl Default for Sitemap {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl Plugin for Sitemap {
113    fn id(&self) -> &'static str {
114        "sitemap"
115    }
116
117    fn meta(&self) -> sova_core::PluginMeta {
118        sova_core::PluginMeta::new("Sitemap")
119            .description("Generate /sitemap.xml from routes, includes, and providers")
120            .version(env!("CARGO_PKG_VERSION"))
121    }
122
123    fn install(mut self, app: &mut App) {
124        if let Some(doc) = app.config_doc() {
125            if let Some(section) = doc.section("sitemap") {
126                if let Some(v) = section.get("public_url").and_then(|v| v.as_str()) {
127                    self.public_url = Some(v.to_string());
128                }
129                if let Some(v) = section.get("path").and_then(|v| v.as_str()) {
130                    self.path = v.to_string();
131                }
132                if let Some(v) = section.get("ttl").and_then(|v| v.as_integer()) {
133                    if v > 0 {
134                        self.ttl = Duration::from_secs(v as u64);
135                    }
136                }
137                if let Some(arr) = section.get("exclude").and_then(|v| v.as_array()) {
138                    for item in arr {
139                        if let Some(s) = item.as_str() {
140                            self.excludes.push(s.to_string());
141                        }
142                    }
143                }
144                if let Some(arr) = section.get("include").and_then(|v| v.as_array()) {
145                    for item in arr {
146                        if let Some(s) = item.as_str() {
147                            self.includes.push(Entry::new(s));
148                        }
149                    }
150                }
151            }
152        }
153
154        if self.public_url.is_none() {
155            if let Some(d) = app.try_state::<MetaDefaults>() {
156                self.public_url = d.public_url.clone();
157            }
158        }
159
160        let opts = CollectOpts {
161            from_routes: self.from_routes,
162            excludes: self.excludes.clone(),
163            includes: self.includes.clone(),
164        };
165        let opts = Arc::new(opts);
166
167        let cfg = SitemapConfig {
168            public_url: self.public_url.clone(),
169            path: self.path.clone(),
170        };
171        app.state(cfg);
172
173        let registry = Arc::new(self.registry);
174        let cache = Arc::new(SitemapCache::new(self.ttl));
175        #[cfg(feature = "store")]
176        let ttl = self.ttl;
177        let public_url = self.public_url.clone();
178        let mount = self.path.clone();
179
180        #[cfg(feature = "store")]
181        let kv: Option<Arc<dyn KvStore>> = self.cache_store.take().or_else(|| {
182            app.try_state::<sova_store::AppStore>()
183                .map(|s| s.namespaced("meta"))
184        });
185        #[cfg(feature = "store")]
186        if let Some(ref store) = kv {
187            app.state(SitemapStore(Arc::clone(store)));
188        }
189
190        app.state(Arc::clone(&registry));
191        app.state(Arc::clone(&cache));
192        app.state(Arc::clone(&opts));
193
194        let paginated = sibling_sitemap_pattern(&mount);
195
196        let reg_r = Arc::clone(&registry);
197        let cache_r = Arc::clone(&cache);
198        let opts_r = Arc::clone(&opts);
199        let public_r = public_url.clone();
200        #[cfg(feature = "store")]
201        let kv_r = kv.clone();
202        app.get(mount.as_str(), move |req: Request| {
203            let registry = Arc::clone(&reg_r);
204            let cache = Arc::clone(&cache_r);
205            let opts = Arc::clone(&opts_r);
206            let public = public_r.clone();
207            #[cfg(feature = "store")]
208            let kv = kv_r.clone();
209            async move {
210                if let Some(cached) = cache.get() {
211                    return xml_response(String::from_utf8_lossy(&cached).into_owned());
212                }
213                #[cfg(feature = "store")]
214                if let Some(ref store) = kv {
215                    if let Some(cached) = store.get(SITEMAP_KV_KEY).await {
216                        cache.set(cached.clone());
217                        return xml_response(String::from_utf8_lossy(&cached).into_owned());
218                    }
219                }
220
221                sitemap_response(
222                    &req,
223                    &registry,
224                    public.as_deref(),
225                    &cache,
226                    &opts,
227                    #[cfg(feature = "store")]
228                    (ttl, kv.as_ref()),
229                )
230                .await
231            }
232        });
233
234        let reg_p = Arc::clone(&registry);
235        let opts_p = Arc::clone(&opts);
236        let public_p = public_url;
237        app.get(paginated.as_str(), move |req: Request| {
238            let registry = Arc::clone(&reg_p);
239            let opts = Arc::clone(&opts_p);
240            let public = public_p.clone();
241            async move {
242                let n: usize = req
243                    .params
244                    .get("n")
245                    .and_then(|s| s.parse().ok())
246                    .unwrap_or(1)
247                    .max(1);
248                let public = match public.or_else(|| {
249                    req.try_state::<MetaDefaults>()
250                        .and_then(|d| d.public_url.clone())
251                }) {
252                    Some(u) => u,
253                    None => return Response::text("public_url required").status(500),
254                };
255                let hreflang = hreflang_from_req(&req);
256                match collect_entries_with(&state_from_req(&req), &registry, &opts).await {
257                    Ok(entries) => {
258                        let start = (n - 1) * MAX_URLS;
259                        let slice: Vec<_> =
260                            entries.into_iter().skip(start).take(MAX_URLS).collect();
261                        xml_response(render_urlset(&public, &slice, hreflang.as_ref()))
262                    }
263                    Err(e) => Response::text(e).status(500),
264                }
265            }
266        });
267    }
268}
269
270fn sibling_sitemap_pattern(mount: &str) -> String {
271    // `/sitemap.xml` → `/sitemap-:n.xml`
272    if let Some(stem) = mount.strip_suffix(".xml") {
273        format!("{stem}-:n.xml")
274    } else {
275        format!("{mount}-:n.xml")
276    }
277}
278
279fn xml_response(body: String) -> Response {
280    Response::text(body).header("content-type", "application/xml; charset=utf-8")
281}
282
283async fn sitemap_response(
284    req: &Request,
285    registry: &SitemapRegistry,
286    public_url: Option<&str>,
287    cache: &SitemapCache,
288    opts: &CollectOpts,
289    #[cfg(feature = "store")] kv: (Duration, Option<&Arc<dyn KvStore>>),
290) -> Response {
291    let public = match public_url
292        .map(|s| s.to_string())
293        .or_else(|| {
294            req.try_state::<MetaDefaults>()
295                .and_then(|d| d.public_url.clone())
296        })
297        .or_else(|| {
298            req.try_state::<SitemapConfig>()
299                .and_then(|c| c.public_url.clone())
300        }) {
301        Some(u) => u,
302        None => return Response::text("public_url required").status(500),
303    };
304    let hreflang = hreflang_from_req(req);
305    let state = state_from_req(req);
306    match build_sitemap_body(&state, registry, &public, hreflang.as_ref(), opts).await {
307        Ok(bytes) => {
308            let xml = String::from_utf8_lossy(&bytes).into_owned();
309            cache.set(bytes.clone());
310            #[cfg(feature = "store")]
311            if let Some(store) = kv.1 {
312                store.set(SITEMAP_KV_KEY, bytes, Some(kv.0)).await;
313            }
314            xml_response(xml)
315        }
316        Err(e) => {
317            tracing::error!("sitemap: {e}");
318            Response::text("sitemap unavailable").status(500)
319        }
320    }
321}
322
323fn hreflang_from_req(req: &Request) -> Option<HreflangOpts> {
324    #[cfg(feature = "i18n")]
325    {
326        let state = req.try_state::<sova_i18n::I18nState>()?;
327        let cfg = crate::i18n_meta::sitemap_hreflang_from_state(&state)?;
328        Some(HreflangOpts {
329            default: cfg.default,
330            path_prefix: cfg.path_prefix,
331            locales: cfg.locales,
332        })
333    }
334    #[cfg(not(feature = "i18n"))]
335    {
336        let _ = req;
337        None
338    }
339}
340
341fn state_from_req(req: &Request) -> Arc<sova_core::extend::StateMap> {
342    let mut map = sova_core::extend::StateMap::new();
343    if let Some(table) = req.try_state::<RouteTable>() {
344        map.insert((*table).clone());
345    }
346    if let Some(d) = req.try_state::<MetaDefaults>() {
347        map.insert((*d).clone());
348    }
349    #[cfg(feature = "i18n")]
350    if let Some(i18n) = req.try_state::<sova_i18n::I18nState>() {
351        map.insert((*i18n).clone());
352    }
353    Arc::new(map)
354}