This playbook walks you through creating a TemplateDataExtender that modifies template data after PageDataProviders and ComponentRenderers have run.
When It Runs
TemplateDataExtender runs last in the rendering pipeline:
Database Query
|
ContentDataProvider::enrich_content()
|
PageDataProvider::provide_page_data()
|
ComponentRenderer::render()
|
=======================================
TemplateDataExtender::extend() <-- You are here
=======================================
|
Template rendering
This is the right place to:
- Add computed fields based on assembled data
- Generate canonical URLs from slugs
- Add schema.org structured data
- Final transformations before rendering
Prerequisites
- Existing extension crate in
extensions/ - Understanding of what final modifications are needed
- Content type(s) you want to target
Step 1: Define Your Extender Struct
Create a new file in your extension's extenders/ directory:
use systemprompt::template_provider::{ExtenderContext, TemplateDataExtender};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
pub struct CanonicalUrlExtender;
impl CanonicalUrlExtender {
pub fn new() -> Self {
Self
}
}
Step 2: Implement the Trait
Implement TemplateDataExtender:
#[async_trait]
impl TemplateDataExtender for CanonicalUrlExtender {
fn extender_id(&self) -> &str {
"canonical-url"
}
fn applies_to(&self) -> Vec<String> {
vec![]
}
async fn extend(
&self,
ctx: &ExtenderContext<'_>,
data: &mut Value,
) -> Result<()> {
let slug = ctx.item.get("slug")
.and_then(|v| v.as_str())
.unwrap_or("");
let canonical = ctx.url_pattern.replace("{slug}", slug);
if let Some(obj) = data.as_object_mut() {
obj.insert("CANONICAL_PATH".to_string(), json!(canonical));
}
Ok(())
}
}
Step 3: Access ExtenderContext Fields
The ExtenderContext provides access to all rendering context:
async fn extend(
&self,
ctx: &ExtenderContext<'_>,
data: &mut Value,
) -> Result<()> {
let item = &ctx.item;
let all_items = ctx.all_items;
let config = &ctx.config;
let web_config = ctx.web_config;
let content_html = ctx.content_html;
let url_pattern = ctx.url_pattern;
let source_name = ctx.source_name;
let pool = ctx.db_pool::<Arc<DbPool>>();
Ok(())
}
| Field | Type | Description |
|---|---|---|
item |
&Value |
Current content item JSON |
all_items |
&[Value] |
All content items for this source |
config |
&serde_yaml::Value |
Content source configuration |
web_config |
&WebConfig |
Web configuration |
content_html |
&str |
Rendered HTML content |
url_pattern |
&str |
URL pattern for this content type |
source_name |
&str |
Content source name |
Step 4: Add Structured Data
Generate schema.org JSON-LD:
pub struct SchemaOrgExtender;
#[async_trait]
impl TemplateDataExtender for SchemaOrgExtender {
fn extender_id(&self) -> &str {
"schema-org"
}
fn applies_to(&self) -> Vec<String> {
vec!["blog".to_string()]
}
async fn extend(
&self,
ctx: &ExtenderContext<'_>,
data: &mut Value,
) -> Result<()> {
let title = ctx.item.get("title")
.and_then(|v| v.as_str())
.unwrap_or("");
let description = ctx.item.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let published = ctx.item.get("published_at")
.and_then(|v| v.as_str())
.unwrap_or("");
let schema = json!({
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"datePublished": published,
});
if let Some(obj) = data.as_object_mut() {
obj.insert("SCHEMA_ORG".to_string(), json!(schema.to_string()));
}
Ok(())
}
}
Step 5: Add Reading Time
Calculate reading time from content:
pub struct ReadingTimeExtender;
#[async_trait]
impl TemplateDataExtender for ReadingTimeExtender {
fn extender_id(&self) -> &str {
"reading-time"
}
fn applies_to(&self) -> Vec<String> {
vec!["blog".to_string(), "documentation".to_string()]
}
async fn extend(
&self,
ctx: &ExtenderContext<'_>,
data: &mut Value,
) -> Result<()> {
let word_count = ctx.content_html
.split_whitespace()
.count();
let reading_time = (word_count / 200).max(1);
if let Some(obj) = data.as_object_mut() {
obj.insert("READING_TIME".to_string(), json!(reading_time));
obj.insert("READING_TIME_LABEL".to_string(),
json!(format!("{} min read", reading_time)));
}
Ok(())
}
}
Step 6: Target Specific Content Types
Use applies_to() to run for specific content types:
fn applies_to(&self) -> Vec<String> {
vec!["blog".to_string(), "documentation".to_string()]
}
Return empty vector to run for ALL content types:
fn applies_to(&self) -> Vec<String> {
vec![]
}
Step 7: Set Priority
Control execution order with priority (lower runs first):
fn priority(&self) -> u32 {
50
}
Step 8: Register in Extension
Add to your extension's template_data_extenders():
impl Extension for WebExtension {
fn template_data_extenders(&self) -> Vec<Arc<dyn TemplateDataExtender>> {
vec![
Arc::new(CanonicalUrlExtender::new()),
Arc::new(SchemaOrgExtender::new()),
Arc::new(ReadingTimeExtender::new()),
]
}
}
Step 9: Export from Module
Update extenders/mod.rs:
mod canonical_url;
mod schema_org;
mod reading_time;
pub use canonical_url::CanonicalUrlExtender;
pub use schema_org::SchemaOrgExtender;
pub use reading_time::ReadingTimeExtender;
Step 10: Use in Templates
The extended data is available in templates:
<head>
<link rel="canonical" href="{{CANONICAL_PATH}}">
<script type="application/ld+json">{{{SCHEMA_ORG}}}</script>
</head>
<article>
<span class="reading-time">{{READING_TIME_LABEL}}</span>
</article>
Step 11: Test
Run the publish pipeline:
systemprompt infra jobs run publish_pipeline
Check generated HTML for your extended data.
Complete Example
use systemprompt::template_provider::{ExtenderContext, TemplateDataExtender};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
pub struct CanonicalUrlExtender {
base_url: String,
}
impl CanonicalUrlExtender {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
}
#[async_trait]
impl TemplateDataExtender for CanonicalUrlExtender {
fn extender_id(&self) -> &str {
"canonical-url"
}
fn applies_to(&self) -> Vec<String> {
vec![]
}
fn priority(&self) -> u32 {
100
}
async fn extend(
&self,
ctx: &ExtenderContext<'_>,
data: &mut Value,
) -> Result<()> {
let slug = ctx.item.get("slug")
.and_then(|v| v.as_str())
.unwrap_or("");
let path = ctx.url_pattern.replace("{slug}", slug);
let canonical = format!("{}{}", self.base_url, path);
if let Some(obj) = data.as_object_mut() {
obj.insert("CANONICAL_URL".to_string(), json!(canonical));
obj.insert("CANONICAL_PATH".to_string(), json!(path));
}
Ok(())
}
}
Checklist
- Created extender struct
- Implemented TemplateDataExtender trait
- Set correct
extender_id() - Configured
applies_to()targeting - Implemented
extend()logic - Set priority if needed
- Registered in extension
- Exported from module
- Verified data available in templates
- Tested with publish pipeline