1use 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#[cfg(feature = "store")]
21#[derive(Clone)]
22pub struct SitemapStore(pub Arc<dyn KvStore>);
23
24pub 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 #[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(®istry));
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(®istry);
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(®_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 ®istry,
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(®istry);
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(®_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), ®istry, &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 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}