Skip to main content

Component Renderers

Implement ComponentRenderer with component_id(), variable_name(), and render() to build HTML fragments like cards and navigation for template variables.

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) -> &'static str;

    fn variable_name(&self) -> &'static str;

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

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

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

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

ProviderResult<T> is Result<T, ProviderError>, the shared error type for provider traits.

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 (default empty = all)
partial_template() Optional Handlebars partial to use instead of render()
render() Returns HTML to insert into the template variable
priority() Execution order, lower runs first (default 100)

ComponentContext

The ComponentContext provides access to content data:

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

impl<'a> ComponentContext<'a> {
    pub const fn for_page(web_config: &'a WebConfig) -> Self;

    pub const fn for_content(
        web_config: &'a WebConfig,
        item: &'a Value,
        all_items: &'a [Value],
        popular_ids: &'a [String],
    ) -> Self;

    pub const fn for_list(
        web_config: &'a WebConfig,
        all_items: &'a [Value],
    ) -> Self;
}

Basic Implementation

use systemprompt::template_provider::{ComponentContext, ComponentRenderer, RenderedComponent};
use systemprompt::traits::ProviderError;
use async_trait::async_trait;
use serde_json::Value;

pub struct ContentCardsRenderer;

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

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

    async fn render(
        &self,
        ctx: &ComponentContext<'_>,
    ) -> Result<RenderedComponent, ProviderError> {
        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 html: String,
    pub variable_name: String,
}

impl RenderedComponent {
    pub fn new(variable_name: impl Into<String>, html: impl Into<String>) -> Self {
        Self {
            html: html.into(),
            variable_name: variable_name.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()]
}

The default implementation returns an empty vector, which runs the renderer for ALL content types. Omit the method to keep that behavior.

Using Partial Templates

For complex rendering, delegate to a Handlebars partial. PartialTemplate carries a name and a source: PartialTemplate::embedded(name, content) for an include_str! template compiled into the binary, or PartialTemplate::file(name, path) to load from disk:

pub struct NavigationRenderer;

impl NavigationRenderer {
    const TEMPLATE: &str = include_str!("../templates/partials/navigation.html");
}

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

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

    fn partial_template(&self) -> Option<PartialTemplate> {
        Some(PartialTemplate::embedded("navigation", Self::TEMPLATE))
    }

    async fn render(
        &self,
        _ctx: &ComponentContext<'_>,
    ) -> Result<RenderedComponent, ProviderError> {
        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) -> &'static str {
        "related-content"
    }

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

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

    async fn render(
        &self,
        ctx: &ComponentContext<'_>,
    ) -> Result<RenderedComponent, ProviderError> {
        let current_item = ctx
            .item
            .ok_or_else(|| ProviderError::InvalidInput("No content item".to_string()))?;
        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) -> &'static str {
        "popular-posts"
    }

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

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

    async fn render(
        &self,
        ctx: &ComponentContext<'_>,
    ) -> Result<RenderedComponent, ProviderError> {
        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

The registry sorts components by priority(), lowest first. If one component must run before another, give it a lower priority value:

impl ComponentRenderer for HeadAssetsRenderer {
    // ...
    fn priority(&self) -> u32 {
        5 // runs before the default 100
    }
}

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, ProviderError> {
    let items = ctx.all_items.ok_or_else(|| {
        ProviderError::InvalidInput("all_items required for card rendering".to_string())
    })?;

    if items.is_empty() {
        return Err(ProviderError::RenderFailed("No items to render".to_string()));
    }

    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 web_config = load_test_web_config(); // build a WebConfig from your services/web config
    let ctx = ComponentContext::for_list(&web_config, &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"));
}