Skip to main content

Page Data Providers

Implement PageDataProvider::provide_page_data() to supply TITLE, DESCRIPTION, DATE, and every other Handlebars template variable the generator core does not.

PageDataProvider is the primary mechanism for providing template variables to Handlebars templates. The generator core only provides minimal data (CONTENT, TOC_HTML, SLUG, locale). Your extensions must provide everything else through PageDataProvider implementations.

When It Runs

PageDataProvider runs during content rendering, after content is loaded but before template rendering:

Database Query
     ↓
ContentDataProvider::enrich_content()
     ↓
═══════════════════════════════════════
PageDataProvider::provide_page_data()  ← You are here
═══════════════════════════════════════
     ↓
ComponentRenderer::render()
     ↓
TemplateDataExtender::extend()
     ↓
Handlebars template rendering

The PageDataProvider Trait

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

    fn applies_to_pages(&self) -> Vec<String> {
        vec![]
    }

    async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value>;

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

ProviderResult<Value> is Result<Value, ProviderError> from the provider contracts. Convert domain errors at the boundary with ProviderError::Internal(e.to_string()).

Methods

Method Purpose
provider_id() Unique identifier for logging and debugging
applies_to_pages() Content types this provider runs for (default: empty = all)
provide_page_data() Returns JSON data to merge into template variables
priority() Run order; lower runs first (default 100)

PageContext

The PageContext provides access to content and configuration. The page type, web config, and locale are public fields; the content config and database pool are type-erased and retrieved by downcast:

pub struct PageContext<'a> {
    pub page_type: &'a str,
    pub web_config: &'a WebConfig,
    pub locale: &'a LocaleCode,
    // private: content_config, db_pool, content_item, all_items
}

impl<'a> PageContext<'a> {
    pub fn content_item(&self) -> Option<&Value>;
    pub fn all_items(&self) -> Option<&[Value]>;
    pub fn content_config<T: 'static>(&self) -> Option<&T>;
    pub fn db_pool<T: 'static>(&self) -> Option<&T>;
}

Content Access

async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
    let item = ctx
        .content_item()
        .ok_or_else(|| ProviderError::Internal("No content item".to_string()))?;

    let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
    let description = item.get("description").and_then(|v| v.as_str()).unwrap_or("");

    Ok(json!({
        "TITLE": title,
        "DESCRIPTION": description,
    }))
}

Database Access

During prerendering the pool is provided as Arc<Database>. Downcast it, then fetch through your repository module (queries live in repositories, not in the provider):

use std::sync::Arc;
use systemprompt::database::Database;

async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
    let Some(db) = ctx.db_pool::<Arc<Database>>() else {
        return Ok(json!({ "related_posts": [] }));
    };
    let Some(pool) = db.pool() else {
        return Ok(json!({ "related_posts": [] }));
    };

    let related = crate::repositories::blog::list_blog_posts(&pool)
        .await
        .map_err(|e| ProviderError::Internal(e.to_string()))?;

    Ok(json!({ "related_posts": related }))
}

Basic Implementation

use systemprompt::extension::prelude::{PageContext, PageDataProvider};
use systemprompt::traits::{ProviderError, ProviderResult};
use async_trait::async_trait;
use serde_json::{json, Value};

pub struct ContentPageDataProvider;

#[async_trait]
impl PageDataProvider for ContentPageDataProvider {
    fn provider_id(&self) -> &'static str {
        "content-page-data"
    }

    fn applies_to_pages(&self) -> Vec<String> {
        vec![]
    }

    async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
        let item = ctx
            .content_item()
            .ok_or_else(|| ProviderError::Internal("No content item".to_string()))?;

        let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
        let description = item.get("description")
            .or_else(|| item.get("excerpt"))
            .and_then(|v| v.as_str())
            .unwrap_or("");

        let published = item.get("published_at")
            .or_else(|| item.get("date"))
            .and_then(|v| v.as_str());

        let formatted_date = published
            .and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
            .map(|dt| dt.format("%B %d, %Y").to_string())
            .unwrap_or_default();

        let iso_date = published
            .and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
            .map(|dt| dt.format("%Y-%m-%d").to_string())
            .unwrap_or_default();

        Ok(json!({
            "TITLE": title,
            "DESCRIPTION": description,
            "DATE": formatted_date,
            "DATE_ISO": iso_date,
            "AUTHOR": item.get("author").and_then(|v| v.as_str()).unwrap_or(""),
            "KEYWORDS": item.get("keywords").or_else(|| item.get("tags")),
        }))
    }
}

Targeting Specific Content Types

Use applies_to_pages() to run only for specific content types:

fn applies_to_pages(&self) -> Vec<String> {
    vec!["blog".to_string(), "docs".to_string()]
}

Return an empty vector (the trait default) to run for ALL content types:

fn applies_to_pages(&self) -> Vec<String> {
    vec![]
}

List Page Providers

For list pages (like blog index), the content type is {source}-list:

pub struct DocsListPageDataProvider;

#[async_trait]
impl PageDataProvider for DocsListPageDataProvider {
    fn provider_id(&self) -> &'static str {
        "docs-list-page-data"
    }

    fn applies_to_pages(&self) -> Vec<String> {
        vec!["documentation-list".to_string()]
    }

    async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
        let index_item = ctx.content_item();

        let (title, description) = match index_item {
            Some(item) => (
                item.get("title").and_then(|v| v.as_str()).unwrap_or("Documentation"),
                item.get("description").and_then(|v| v.as_str()).unwrap_or(""),
            ),
            None => ("Documentation", "Browse all documentation"),
        };

        Ok(json!({
            "TITLE": title,
            "DESCRIPTION": description,
        }))
    }
}

Multiple Providers

Multiple providers can contribute to the same page. The registry sorts providers by priority() (lower runs first, default 100) and merges their data in that order:

impl Extension for WebExtension {
    fn page_data_providers(&self) -> Vec<Arc<dyn PageDataProvider>> {
        vec![
            Arc::new(ContentPageDataProvider),
            Arc::new(BrandingPageDataProvider),
            Arc::new(DocsListPageDataProvider),
        ]
    }
}

Data Merging

When multiple providers return data, values are merged recursively:

{
    "TITLE": "From Provider 1",
    "meta": { "author": "Alice" }
}

{
    "DESCRIPTION": "From Provider 2",
    "meta": { "editor": "Bob" }
}

{
    "TITLE": "From Provider 1",
    "DESCRIPTION": "From Provider 2",
    "meta": { "author": "Alice", "editor": "Bob" }
}

Later providers can override earlier values, so priority matters.

Error Handling

Page data providers are load-bearing: if a provider returns an error, the generator fails that page render (unlike template data extenders, whose failures are logged as warnings). Return an error only when the page genuinely cannot render without your data:

async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
    let item = ctx
        .content_item()
        .ok_or_else(|| ProviderError::Internal("Content item required".to_string()))?;

    if item.get("title").is_none() {
        return Err(ProviderError::Internal("Missing required field: title".to_string()));
    }

    Ok(json!({ "TITLE": item["title"] }))
}

For optional data, prefer returning an empty object over failing the render.

Configuration-Based Providers

Providers can read the site configuration directly from ctx.web_config (a &WebConfig) to provide site-wide data:

pub struct BrandingPageDataProvider;

#[async_trait]
impl PageDataProvider for BrandingPageDataProvider {
    fn provider_id(&self) -> &'static str {
        "branding-page-data"
    }

    async fn provide_page_data(&self, ctx: &PageContext<'_>) -> ProviderResult<Value> {
        let branding = &ctx.web_config.branding;
        Ok(json!({
            "SITE_NAME": branding.name,
            "TWITTER_HANDLE": branding.twitter_handle,
            "LOGO_PATH": branding.logo.primary.svg,
            "FAVICON_PATH": branding.favicon,
        }))
    }
}

Template Usage

Template variables from providers are available in Handlebars:

<head>
    <title>{{TITLE}}</title>
    <meta name="description" content="{{DESCRIPTION}}">
    <meta name="author" content="{{AUTHOR}}">
    <link rel="icon" href="{{FAVICON_PATH}}">
</head>

<article>
    <h1>{{TITLE}}</h1>
    <time datetime="{{DATE_ISO}}">{{DATE}}</time>
    {{{CONTENT}}}
</article>

Testing

Test providers by constructing a PageContext with PageContext::new. The content config and pool parameters are &(dyn Any + Send + Sync), so unit values work when the provider does not touch them:

#[tokio::test]
async fn test_content_provider() {
    let web_config = test_web_config();
    let content_config: &(dyn std::any::Any + Send + Sync) = &();
    let db_pool: &(dyn std::any::Any + Send + Sync) = &();

    let item = json!({
        "title": "Test Post",
        "description": "A test post",
        "published_at": "2026-01-31T00:00:00Z",
    });

    let ctx = PageContext::new("blog", &web_config, content_config, db_pool)
        .with_content_item(&item);
    let provider = ContentPageDataProvider;

    let result = provider.provide_page_data(&ctx).await.unwrap();

    assert_eq!(result["TITLE"], "Test Post");
    assert_eq!(result["DESCRIPTION"], "A test post");
    assert_eq!(result["DATE"], "January 31, 2026");
}