Page Data Providers
Implement PageDataProvider::provide_page_data() to supply TITLE, DESCRIPTION, DATE, and every other Handlebars template variable the generator core does not.
On this page
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");
}