Skip to main content

systemprompt_generator/sitemap/
default_provider.rs

1//! Default `SitemapProvider` that drives sitemap generation from the
2//! crate-local `content.yaml` configuration.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use async_trait::async_trait;
8use chrono::Utc;
9use std::collections::HashMap;
10use systemprompt_models::{AppPaths, ContentConfigRaw};
11use systemprompt_provider_contracts::{
12    PlaceholderMapping, ProviderResult, SitemapContext, SitemapProvider, SitemapSourceSpec,
13    SitemapUrlEntry,
14};
15use tokio::fs;
16
17use crate::error::{GeneratorResult, PublishError};
18
19#[derive(Debug)]
20pub struct DefaultSitemapProvider {
21    content_config: ContentConfigRaw,
22}
23
24impl DefaultSitemapProvider {
25    pub async fn new(paths: &AppPaths) -> GeneratorResult<Self> {
26        let content_config = load_content_config(paths).await?;
27        Ok(Self { content_config })
28    }
29
30    #[must_use]
31    pub const fn from_config(content_config: ContentConfigRaw) -> Self {
32        Self { content_config }
33    }
34}
35
36pub(super) async fn load_content_config(paths: &AppPaths) -> GeneratorResult<ContentConfigRaw> {
37    let config_path = paths.system().content_config();
38
39    let yaml_content = fs::read_to_string(&config_path).await.map_err(|source| {
40        PublishError::ContentConfigRead {
41            path: config_path.to_path_buf(),
42            source,
43        }
44    })?;
45
46    serde_yaml::from_str(&yaml_content).map_err(|source| PublishError::ContentConfigParse {
47        path: config_path.to_path_buf(),
48        source,
49    })
50}
51
52#[async_trait]
53impl SitemapProvider for DefaultSitemapProvider {
54    fn provider_id(&self) -> &'static str {
55        "default-sitemap"
56    }
57
58    fn source_specs(&self) -> Vec<SitemapSourceSpec> {
59        self.content_config
60            .content_sources
61            .iter()
62            .filter(|(_, source)| source.enabled)
63            .filter_map(|(_, source)| {
64                source.sitemap.as_ref().and_then(|sitemap| {
65                    sitemap.enabled.then(|| SitemapSourceSpec {
66                        source_id: source.source_id.clone(),
67                        url_pattern: sitemap.url_pattern.clone(),
68                        placeholders: vec![PlaceholderMapping {
69                            placeholder: "{slug}".to_owned(),
70                            field: "slug".to_owned(),
71                        }],
72                        priority: sitemap.priority,
73                        changefreq: sitemap.changefreq.clone(),
74                    })
75                })
76            })
77            .collect()
78    }
79
80    fn static_urls(&self, base_url: &str) -> Vec<SitemapUrlEntry> {
81        let today = Utc::now().format("%Y-%m-%d").to_string();
82
83        self.content_config
84            .content_sources
85            .iter()
86            .filter(|(_, source)| source.enabled)
87            .filter_map(|(_, source)| {
88                source.sitemap.as_ref().and_then(|sitemap| {
89                    sitemap.parent_route.as_ref().and_then(|parent| {
90                        parent.enabled.then(|| SitemapUrlEntry {
91                            loc: format!("{}{}", base_url, parent.url),
92                            lastmod: today.clone(),
93                            changefreq: parent.changefreq.clone(),
94                            priority: parent.priority,
95                            alternates: Vec::new(),
96                        })
97                    })
98                })
99            })
100            .collect()
101    }
102
103    async fn resolve_placeholders(
104        &self,
105        _ctx: &SitemapContext<'_>,
106        content: &serde_json::Value,
107        placeholders: &[PlaceholderMapping],
108    ) -> ProviderResult<HashMap<String, String>> {
109        let mut resolved = HashMap::new();
110
111        for mapping in placeholders {
112            if let Some(value) = content.get(&mapping.field) {
113                let string_value = match value {
114                    serde_json::Value::String(s) => s.clone(),
115                    _ => value.to_string().trim_matches('"').to_owned(),
116                };
117                resolved.insert(mapping.placeholder.clone(), string_value);
118            }
119        }
120
121        Ok(resolved)
122    }
123}