RSS & Sitemap Providers

Generate RSS feeds and sitemap entries for your content.

RssFeedProvider and SitemapProvider generate RSS feeds and sitemap entries during the publish pipeline.

RssFeedProvider

The Trait

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

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

    async fn provide_feeds(&self, ctx: &RssFeedContext<'_>) -> Result<Vec<RssFeedSpec>>;
}

RssFeedContext

impl<'a> RssFeedContext<'a> {
    pub fn web_config(&self) -> &FullWebConfig;
    pub fn db_pool<T>(&self) -> Option<&T>;
}

RssFeedSpec

pub struct RssFeedSpec {
    pub output_path: PathBuf,
    pub metadata: RssFeedMetadata,
    pub items: Vec<RssFeedItem>,
}

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

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

Implementation

use systemprompt::extension::prelude::*;
use anyhow::Result;
use std::path::PathBuf;

pub struct BlogRssProvider;

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

    async fn provide_feeds(&self, ctx: &RssFeedContext<'_>) -> Result<Vec<RssFeedSpec>> {
        let pool = ctx.db_pool::<Arc<PgPool>>()
            .ok_or_else(|| anyhow::anyhow!("Database not available"))?;

        let posts = sqlx::query!(
            r#"SELECT slug, title, description, published_at
               FROM markdown_content
               WHERE source_id = 'blog' AND public = true
               ORDER BY published_at DESC
               LIMIT 20"#
        )
        .fetch_all(&*pool)
        .await?;

        let base_url = &ctx.web_config().base_url;

        let items: Vec<RssFeedItem> = posts.iter().map(|p| RssFeedItem {
            title: p.title.clone(),
            link: format!("{}/blog/{}", base_url, p.slug),
            description: p.description.clone(),
            pub_date: p.published_at.map(|d| d.to_rfc2822()),
            guid: Some(format!("{}/blog/{}", base_url, p.slug)),
        }).collect();

        Ok(vec![RssFeedSpec {
            output_path: PathBuf::from("feed.xml"),
            metadata: RssFeedMetadata {
                title: "Blog".to_string(),
                description: "Latest posts".to_string(),
                link: format!("{}/blog", base_url),
                language: Some("en".to_string()),
            },
            items,
        }])
    }
}

SitemapProvider

The Trait

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

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

    async fn provide_sitemap_sources(
        &self,
        ctx: &SitemapContext<'_>,
    ) -> Result<Vec<SitemapSourceSpec>>;
}

SitemapContext

impl<'a> SitemapContext<'a> {
    pub fn web_config(&self) -> &FullWebConfig;
    pub fn db_pool<T>(&self) -> Option<&T>;
}

SitemapSourceSpec

pub struct SitemapSourceSpec {
    pub source_id: String,
    pub entries: Vec<SitemapUrlEntry>,
}

pub struct SitemapUrlEntry {
    pub loc: String,
    pub lastmod: Option<String>,
    pub changefreq: Option<String>,
    pub priority: Option<f32>,
}

Implementation

pub struct ContentSitemapProvider;

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

    async fn provide_sitemap_sources(
        &self,
        ctx: &SitemapContext<'_>,
    ) -> Result<Vec<SitemapSourceSpec>> {
        let pool = ctx.db_pool::<Arc<PgPool>>()
            .ok_or_else(|| anyhow::anyhow!("Database not available"))?;

        let content = sqlx::query!(
            r#"SELECT source_id, slug, updated_at
               FROM markdown_content
               WHERE public = true"#
        )
        .fetch_all(&*pool)
        .await?;

        let base_url = &ctx.web_config().base_url;

        let mut sources: HashMap<String, Vec<SitemapUrlEntry>> = HashMap::new();

        for item in content {
            let entry = SitemapUrlEntry {
                loc: format!("{}/{}", base_url, item.slug),
                lastmod: item.updated_at.map(|d| d.format("%Y-%m-%d").to_string()),
                changefreq: Some("weekly".to_string()),
                priority: Some(0.7),
            };

            sources
                .entry(item.source_id)
                .or_default()
                .push(entry);
        }

        Ok(sources.into_iter()
            .map(|(source_id, entries)| SitemapSourceSpec { source_id, entries })
            .collect())
    }
}

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 feeds from a single provider:

async fn provide_feeds(&self, ctx: &RssFeedContext<'_>) -> Result<Vec<RssFeedSpec>> {
    Ok(vec![
        RssFeedSpec {
            output_path: PathBuf::from("blog/feed.xml"),
            // ...
        },
        RssFeedSpec {
            output_path: PathBuf::from("docs/feed.xml"),
            // ...
        },
    ])
}