Component Renderers
Implement ComponentRenderer with component_id(), variable_name(), and render() to build HTML fragments like cards and navigation for template variables.
On this page
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().
Related Content Renderer
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>"#
)
}
}
Popular Items Renderer
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"));
}