Skip to main content

Template Data Extender

Implement TemplateDataExtender::extend() to make final edits to assembled template data, adding canonical URLs, OpenGraph tags, and JSON-LD after providers run.

TemplateDataExtender runs after PageDataProviders and ComponentRenderers, allowing you to make final modifications to the assembled template data.

When It Runs

ContentDataProvider::enrich_content()
     |
PageDataProvider::provide_page_data()
     |
ComponentRenderer::render()
     |
=======================================
TemplateDataExtender::extend()  <- You are here
=======================================
     |
Handlebars template rendering

The Trait

#[async_trait]
pub trait TemplateDataExtender: Send + Sync {
    fn extender_id(&self) -> &str;

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

    async fn extend(
        &self,
        ctx: &ExtenderContext<'_>,
        data: &mut Value,
    ) -> ProviderResult<()>;

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

ExtenderContext

pub struct ExtenderContext<'a> {
    pub item: &'a Value,
    pub all_items: &'a [Value],
    pub config: &'a serde_yaml::Value,
    pub web_config: &'a WebConfig,
    pub content_html: &'a str,
    pub url_pattern: &'a str,
    pub source_name: &'a str,
    // private: db_pool
}

impl<'a> ExtenderContext<'a> {
    pub fn db_pool<T: 'static>(&self) -> Option<&T>;
}

Basic Implementation

use async_trait::async_trait;
use serde_json::{json, Value};
use systemprompt::models::Config;
use systemprompt::template_provider::{ExtenderContext, TemplateDataExtender};
use systemprompt::traits::ProviderError;

pub struct CanonicalUrlExtender;

#[async_trait]
impl TemplateDataExtender for CanonicalUrlExtender {
    fn extender_id(&self) -> &str {
        "canonical-url"
    }

    fn applies_to(&self) -> Vec<String> {
        vec![]  // All content types
    }

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

    async fn extend(
        &self,
        ctx: &ExtenderContext<'_>,
        data: &mut Value,
    ) -> Result<(), ProviderError> {
        let slug = ctx.item.get("slug").and_then(|v| v.as_str()).unwrap_or("");
        let canonical = ctx.url_pattern.replace("{slug}", slug);

        let config = Config::get()
            .map_err(|e| ProviderError::Internal(e.to_string()))?;

        if let Some(obj) = data.as_object_mut() {
            obj.insert("CANONICAL_PATH".to_string(), json!(canonical));
            obj.insert("CANONICAL_URL".to_string(), json!(format!(
                "{}{}",
                config.api_external_url,
                canonical
            )));
        }

        Ok(())
    }
}

Targeting Content Types

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

Empty vector = all content types.

Registration

impl Extension for WebExtension {
    fn template_data_extenders(&self) -> Vec<Arc<dyn TemplateDataExtender>> {
        vec![
            Arc::new(CanonicalUrlExtender),
            Arc::new(OpenGraphExtender),
            Arc::new(JsonLdExtender),
        ]
    }
}

Common Patterns

OpenGraph Metadata

async fn extend(&self, ctx: &ExtenderContext<'_>, data: &mut Value) -> Result<(), ProviderError> {
    let title = data.get("TITLE").and_then(|v| v.as_str()).unwrap_or("");
    let description = data.get("DESCRIPTION").and_then(|v| v.as_str()).unwrap_or("");
    let image = ctx.item.get("image").and_then(|v| v.as_str());

    let config = Config::get()
        .map_err(|e| ProviderError::Internal(e.to_string()))?;

    if let Some(obj) = data.as_object_mut() {
        obj.insert("OG_TITLE".to_string(), json!(title));
        obj.insert("OG_DESCRIPTION".to_string(), json!(description));
        if let Some(img) = image {
            obj.insert("OG_IMAGE".to_string(), json!(format!("{}{}", config.api_external_url, img)));
        }
    }

    Ok(())
}

JSON-LD Structured Data

async fn extend(&self, ctx: &ExtenderContext<'_>, data: &mut Value) -> Result<(), ProviderError> {
    let json_ld = json!({
        "@context": "https://schema.org",
        "@type": "Article",
        "headline": data.get("TITLE"),
        "description": data.get("DESCRIPTION"),
        "author": {
            "@type": "Person",
            "name": data.get("AUTHOR")
        }
    });

    if let Some(obj) = data.as_object_mut() {
        let serialized = serde_json::to_string(&json_ld)
            .map_err(|e| ProviderError::Internal(e.to_string()))?;
        obj.insert("JSON_LD".to_string(), json!(serialized));
    }

    Ok(())
}

Conditional Fields

async fn extend(&self, ctx: &ExtenderContext<'_>, data: &mut Value) -> Result<(), ProviderError> {
    let has_toc = ctx.content_html.contains("<h2");

    if let Some(obj) = data.as_object_mut() {
        obj.insert("SHOW_TOC".to_string(), json!(has_toc));
    }

    Ok(())
}