Skip to main content

RSS & Sitemap Providers

Implement RssFeedProvider and SitemapProvider to emit RSS feeds and sitemap URL entries during the publish pipeline via feed_specs() and source_specs().

RssFeedProvider and SitemapProvider generate RSS feeds and sitemap entries during the publish pipeline. Both traits live in systemprompt-provider-contracts and are re-exported through systemprompt::extension::prelude.

RssFeedProvider

The Trait

#[async_trait]
pub trait RssFeedProvider: Send + Sync {
    fn provider_id(&self) -> &'static str;

    fn feed_specs(&self) -> Vec<RssFeedSpec>;

    async fn feed_metadata(&self, ctx: &RssFeedContext<'_>) -> ProviderResult<RssFeedMetadata>;

    async fn fetch_items(
        &self,
        ctx: &RssFeedContext<'_>,
        limit: i64,
    ) -> ProviderResult<Vec<RssFeedItem>>;

    fn priority(&self) -> u32 {
        100
    }
}

feed_specs() declares which feeds this provider emits. For each spec, the generator calls feed_metadata() for the channel header and fetch_items() for the entries.

RssFeedContext

pub struct RssFeedContext<'a> {
    pub base_url: &'a str,
    pub source_name: &'a str,
}

RssFeedSpec

pub struct RssFeedSpec {
    pub source_id: SourceId,
    pub max_items: i64,
    pub output_filename: String,
}

pub struct RssFeedMetadata {
    pub title: String,
    pub link: String,
    pub description: String,
    pub language: Option<String>,
}

pub struct RssFeedItem {
    pub title: String,
    pub link: String,
    pub description: String,
    pub pub_date: DateTime<Utc>,
    pub guid: String,
    pub author: Option<String>,
}

Implementation

use systemprompt::extension::prelude::*;
use systemprompt::identifiers::SourceId;
use systemprompt_provider_contracts::ProviderResult;

pub struct BlogRssProvider;

#[async_trait]
impl RssFeedProvider for BlogRssProvider {
    fn provider_id(&self) -> &'static str {
        "blog-rss"
    }

    fn feed_specs(&self) -> Vec<RssFeedSpec> {
        vec![RssFeedSpec {
            source_id: SourceId::new("blog"),
            max_items: 20,
            output_filename: "blog.xml".to_string(),
        }]
    }

    async fn feed_metadata(&self, ctx: &RssFeedContext<'_>) -> ProviderResult<RssFeedMetadata> {
        Ok(RssFeedMetadata {
            title: "Blog".to_string(),
            link: format!("{}/blog", ctx.base_url),
            description: "Latest posts".to_string(),
            language: Some("en".to_string()),
        })
    }

    async fn fetch_items(
        &self,
        ctx: &RssFeedContext<'_>,
        limit: i64,
    ) -> ProviderResult<Vec<RssFeedItem>> {
        // Load your latest content (via a repository method, never inline SQL
        // in business logic) and map each row to an RssFeedItem.
        let posts = load_latest_posts(limit).await?;

        Ok(posts
            .into_iter()
            .map(|p| RssFeedItem {
                title: p.title,
                link: format!("{}/blog/{}", ctx.base_url, p.slug),
                description: p.description,
                pub_date: p.published_at,
                guid: format!("{}/blog/{}", ctx.base_url, p.slug),
                author: None,
            })
            .collect())
    }
}

The core ships DefaultRssFeedProvider, which emits one feed per enabled content source from services/content/config.yaml. Implement your own provider only when you need feeds the config-driven default cannot express.


SitemapProvider

The Trait

#[async_trait]
pub trait SitemapProvider: Send + Sync {
    fn provider_id(&self) -> &'static str;

    fn source_specs(&self) -> Vec<SitemapSourceSpec> {
        vec![]
    }

    fn static_urls(&self, base_url: &str) -> Vec<SitemapUrlEntry> {
        vec![]
    }

    async fn resolve_placeholders(
        &self,
        ctx: &SitemapContext<'_>,
        content: &serde_json::Value,
        placeholders: &[PlaceholderMapping],
    ) -> ProviderResult<HashMap<String, String>>;

    fn priority(&self) -> u32 {
        100
    }
}

source_specs() declares URL patterns per content source; the generator iterates that source's content and calls resolve_placeholders() to fill each pattern. static_urls() adds fixed entries such as landing pages.

SitemapContext

pub struct SitemapContext<'a> {
    pub base_url: &'a str,
    pub source_name: &'a str,
}

SitemapSourceSpec

pub struct SitemapSourceSpec {
    pub source_id: SourceId,
    pub url_pattern: String,
    pub placeholders: Vec<PlaceholderMapping>,
    pub priority: f32,
    pub changefreq: String,
}

pub struct PlaceholderMapping {
    pub placeholder: String,
    pub field: String,
}

pub struct SitemapUrlEntry {
    pub loc: String,
    pub lastmod: String,
    pub changefreq: String,
    pub priority: f32,
    pub alternates: Vec<SitemapAlternate>,
}

Implementation

use std::collections::HashMap;
use systemprompt::extension::prelude::*;
use systemprompt::identifiers::SourceId;
use systemprompt_provider_contracts::ProviderResult;

pub struct ContentSitemapProvider;

#[async_trait]
impl SitemapProvider for ContentSitemapProvider {
    fn provider_id(&self) -> &'static str {
        "content-sitemap"
    }

    fn source_specs(&self) -> Vec<SitemapSourceSpec> {
        vec![SitemapSourceSpec {
            source_id: SourceId::new("blog"),
            url_pattern: "/blog/{slug}".to_string(),
            placeholders: vec![PlaceholderMapping {
                placeholder: "{slug}".to_string(),
                field: "slug".to_string(),
            }],
            priority: 0.7,
            changefreq: "weekly".to_string(),
        }]
    }

    async fn resolve_placeholders(
        &self,
        _ctx: &SitemapContext<'_>,
        content: &serde_json::Value,
        placeholders: &[PlaceholderMapping],
    ) -> ProviderResult<HashMap<String, String>> {
        let mut resolved = HashMap::new();

        for mapping in placeholders {
            if let Some(value) = content.get(&mapping.field) {
                let string_value = match value {
                    serde_json::Value::String(s) => s.clone(),
                    other => other.to_string().trim_matches('"').to_owned(),
                };
                resolved.insert(mapping.placeholder.clone(), string_value);
            }
        }

        Ok(resolved)
    }
}

The core ships DefaultSitemapProvider, driven entirely by the sitemap: block on each content source in services/content/config.yaml (including parent_route static entries). Prefer configuring that over writing a custom provider.

Registration

impl Extension for WebExtension {
    fn rss_feed_providers(&self) -> Vec<Arc<dyn RssFeedProvider>> {
        vec![Arc::new(BlogRssProvider)]
    }

    fn sitemap_providers(&self) -> Vec<Arc<dyn SitemapProvider>> {
        vec![Arc::new(ContentSitemapProvider)]
    }
}

Multiple Feeds

Return multiple specs from a single provider; the generator calls feed_metadata() and fetch_items() once per spec:

fn feed_specs(&self) -> Vec<RssFeedSpec> {
    vec![
        RssFeedSpec {
            source_id: SourceId::new("blog"),
            max_items: 20,
            output_filename: "blog.xml".to_string(),
        },
        RssFeedSpec {
            source_id: SourceId::new("documentation"),
            max_items: 20,
            output_filename: "documentation.xml".to_string(),
        },
    ]
}