Component Renderers

Create ComponentRenderer implementations to generate HTML fragments for your templates.

ComponentRenderer generates HTML fragments that are inserted into template variables. Use this for content cards, navigation menus, related content sections, and any pre-rendered HTML that templates need.

When It Runs

ComponentRenderer runs after PageDataProviders, allowing components to access all template data:

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

The ComponentRenderer Trait

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

    fn variable_name(&self) -> &str;

    fn applies_to(&self) -> Vec<String>;

    fn partial_template(&self) -> Option<PartialTemplate> {
        None
    }

    async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent>;
}

Methods

Method Purpose
component_id() Unique identifier for logging and debugging
variable_name() Template variable to populate with rendered HTML
applies_to() Content types this renderer runs for (empty = all)
partial_template() Optional Handlebars partial to use instead of render()
render() Returns HTML to insert into the template variable

ComponentContext

The ComponentContext provides access to content data:

pub struct ComponentContext<'a> {
    pub web_config: &'a FullWebConfig,
    pub content_item: Option<&'a Value>,
    pub all_items: Option<&'a [Value]>,
    pub popular_ids: Option<&'a [String]>,
}

impl<'a> ComponentContext<'a> {
    pub fn for_content(
        web_config: &'a FullWebConfig,
        item: &'a Value,
        all_items: &'a [Value],
        popular_ids: &'a [String],
    ) -> Self;

    pub fn for_list(
        web_config: &'a FullWebConfig,
        items: &'a [Value],
    ) -> Self;
}

Basic Implementation

use systemprompt::template_provider::{ComponentContext, ComponentRenderer, RenderedComponent};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;

pub struct ContentCardsRenderer;

#[async_trait]
impl ComponentRenderer for ContentCardsRenderer {
    fn component_id(&self) -> &str {
        "content-cards"
    }

    fn variable_name(&self) -> &str {
        "POSTS"
    }

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

    async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
        let items = ctx.all_items.unwrap_or(&[]);

        let html = items
            .iter()
            .filter(|item| {
                item.get("slug")
                    .and_then(|v| v.as_str())
                    .is_some_and(|s| !s.is_empty())
            })
            .map(|item| self.render_single_card(item))
            .collect::<Vec<_>>()
            .join("\n");

        Ok(RenderedComponent::new("POSTS", html))
    }
}

impl ContentCardsRenderer {
    fn render_single_card(&self, item: &Value) -> String {
        let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
        let slug = item.get("slug").and_then(|v| v.as_str()).unwrap_or("");
        let description = item.get("description").and_then(|v| v.as_str()).unwrap_or("");

        format!(
            r#"<article class="card">
  <a href="/{slug}">
    <h3>{title}</h3>
    <p>{description}</p>
  </a>
</article>"#
        )
    }
}

RenderedComponent

The return type contains the variable name and HTML content:

pub struct RenderedComponent {
    pub variable_name: String,
    pub html: String,
}

impl RenderedComponent {
    pub fn new(variable_name: impl Into<String>, html: impl Into<String>) -> Self {
        Self {
            variable_name: variable_name.into(),
            html: html.into(),
        }
    }
}

Targeting Content Types

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

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

Return an empty vector to run for ALL content types:

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

Using Partial Templates

For complex rendering, delegate to a Handlebars partial:

pub struct NavigationRenderer;

#[async_trait]
impl ComponentRenderer for NavigationRenderer {
    fn component_id(&self) -> &str {
        "navigation"
    }

    fn variable_name(&self) -> &str {
        "NAVIGATION"
    }

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

    fn partial_template(&self) -> Option<PartialTemplate> {
        Some(PartialTemplate {
            name: "partials/navigation".to_string(),
        })
    }

    async fn render(&self, _ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
        Ok(RenderedComponent::new("NAVIGATION", String::new()))
    }
}

When partial_template() returns Some, the registry renders the partial using the current template data instead of calling render().

pub struct RelatedContentRenderer;

#[async_trait]
impl ComponentRenderer for RelatedContentRenderer {
    fn component_id(&self) -> &str {
        "related-content"
    }

    fn variable_name(&self) -> &str {
        "RELATED_CONTENT"
    }

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

    async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
        let current_item = ctx.content_item.ok_or_else(|| anyhow::anyhow!("No content item"))?;
        let all_items = ctx.all_items.unwrap_or(&[]);

        let current_slug = current_item
            .get("slug")
            .and_then(|v| v.as_str())
            .unwrap_or("");

        let related: Vec<String> = all_items
            .iter()
            .filter(|item| {
                item.get("slug")
                    .and_then(|v| v.as_str())
                    .is_some_and(|s| s != current_slug && !s.is_empty())
            })
            .take(3)
            .map(|item| self.render_related_card(item))
            .collect();

        if related.is_empty() {
            return Ok(RenderedComponent::new("RELATED_CONTENT", String::new()));
        }

        let html = format!(
            r#"<section class="related-content">
  <h2>Related Posts</h2>
  <div class="related-grid">
    {}
  </div>
</section>"#,
            related.join("\n")
        );

        Ok(RenderedComponent::new("RELATED_CONTENT", html))
    }
}

impl RelatedContentRenderer {
    fn render_related_card(&self, item: &Value) -> String {
        let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
        let slug = item.get("slug").and_then(|v| v.as_str()).unwrap_or("");

        format!(
            r#"<a href="/{slug}" class="related-card">
  <h4>{title}</h4>
</a>"#
        )
    }
}

Access popular item IDs from the context:

pub struct PopularPostsRenderer;

#[async_trait]
impl ComponentRenderer for PopularPostsRenderer {
    fn component_id(&self) -> &str {
        "popular-posts"
    }

    fn variable_name(&self) -> &str {
        "POPULAR_POSTS"
    }

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

    async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
        let popular_ids = ctx.popular_ids.unwrap_or(&[]);
        let all_items = ctx.all_items.unwrap_or(&[]);

        let popular: Vec<&Value> = popular_ids
            .iter()
            .filter_map(|id| {
                all_items.iter().find(|item| {
                    item.get("id").and_then(|v| v.as_str()) == Some(id)
                })
            })
            .take(5)
            .collect();

        let html = popular
            .iter()
            .map(|item| self.render_item(item))
            .collect::<Vec<_>>()
            .join("\n");

        Ok(RenderedComponent::new("POPULAR_POSTS", html))
    }
}

Registration

Register ComponentRenderers in your extension:

impl Extension for WebExtension {
    fn component_renderers(&self) -> Vec<Arc<dyn ComponentRenderer>> {
        vec![
            Arc::new(ContentCardsRenderer),
            Arc::new(RelatedContentRenderer),
            Arc::new(PopularPostsRenderer),
            Arc::new(NavigationRenderer::new(self.nav_config.clone())),
        ]
    }
}

Template Usage

Use the triple-brace syntax to insert unescaped HTML:

<main>
    {{{CONTENT}}}

    {{{RELATED_CONTENT}}}

    {{{POPULAR_POSTS}}}
</main>

<aside>
    {{{NAVIGATION}}}
</aside>

Double braces escape HTML, triple braces insert raw HTML.

Order of Execution

Components execute in registration order. If one component depends on another's output, register them in the correct order:

fn component_renderers(&self) -> Vec<Arc<dyn ComponentRenderer>> {
    vec![
        Arc::new(CardDataProvider),
        Arc::new(CardListRenderer),
    ]
}

Error Handling

When a component fails, the generator logs a warning and continues with an empty value:

async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
    let items = ctx.all_items
        .ok_or_else(|| anyhow::anyhow!("all_items required for card rendering"))?;

    if items.is_empty() {
        return Err(anyhow::anyhow!("No items to render"));
    }

    Ok(RenderedComponent::new("POSTS", self.render_cards(items)))
}

Testing

Test renderers by constructing ComponentContext:

#[tokio::test]
async fn test_cards_renderer() {
    let items = vec![
        json!({ "slug": "post-1", "title": "First Post", "description": "Description 1" }),
        json!({ "slug": "post-2", "title": "Second Post", "description": "Description 2" }),
    ];

    let ctx = ComponentContext::for_list(&FullWebConfig::default(), &items);
    let renderer = ContentCardsRenderer;

    let result = renderer.render(&ctx).await.unwrap();

    assert_eq!(result.variable_name, "POSTS");
    assert!(result.html.contains("First Post"));
    assert!(result.html.contains("Second Post"));
}