This playbook walks you through creating a ComponentRenderer that generates HTML fragments for your templates.
Prerequisites
- Existing extension crate in
extensions/ - Understanding of what HTML fragments your templates need
- Content type(s) you want to target
Step 1: Define Your Renderer Struct
Create a new file in your extension's components/ directory:
use systemprompt::template_provider::{ComponentContext, ComponentRenderer, RenderedComponent};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
pub struct ContentCardsRenderer;
impl ContentCardsRenderer {
pub fn new() -> Self {
Self
}
}
Step 2: Implement the Trait
Implement ComponentRenderer:
#[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_card(item))
.collect::<Vec<_>>()
.join("\n");
Ok(RenderedComponent::new("POSTS", html))
}
}
Step 3: Create the Card Rendering Method
Add a method to render individual cards:
impl ContentCardsRenderer {
pub fn new() -> Self {
Self
}
fn render_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>"#
)
}
}
Step 4: Add Image Support
Enhance cards with images:
fn render_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("");
let image = item.get("image").and_then(|v| v.as_str());
let image_html = match image {
Some(src) => format!(r#"<img src="{src}" alt="{title}" loading="lazy" />"#),
None => r#"<div class="card-placeholder"></div>"#.to_string(),
};
format!(
r#"<article class="card">
<a href="/{slug}">
{image_html}
<div class="card-content">
<h3>{title}</h3>
<p>{description}</p>
</div>
</a>
</article>"#
)
}
Step 5: Add Date Formatting
Include formatted dates:
fn render_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("");
let date = item.get("published_at")
.and_then(|v| v.as_str())
.and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
.map(|dt| dt.format("%B %d, %Y").to_string())
.unwrap_or_default();
format!(
r#"<article class="card">
<a href="/{slug}">
<h3>{title}</h3>
<p>{description}</p>
<time>{date}</time>
</a>
</article>"#
)
}
Step 6: Target Specific Content Types
For list pages only:
fn applies_to(&self) -> Vec<String> {
vec!["blog-list".to_string(), "documentation-list".to_string()]
}
Step 7: Register in Extension
Add to your extension's component_renderers():
impl Extension for WebExtension {
fn component_renderers(&self) -> Vec<Arc<dyn ComponentRenderer>> {
vec![
Arc::new(ContentCardsRenderer::new()),
]
}
}
Step 8: Export from Module
Update components/mod.rs:
mod cards;
pub use cards::ContentCardsRenderer;
Step 9: Use in Templates
Use triple braces for unescaped HTML:
<main class="content-list">
{{{POSTS}}}
</main>
Step 10: Test
Run the publish pipeline:
systemprompt infra jobs run publish_pipeline
Check generated HTML for your cards.
Complete Example
use systemprompt::template_provider::{ComponentContext, ComponentRenderer, RenderedComponent};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
pub struct ContentCardsRenderer;
impl ContentCardsRenderer {
pub fn new() -> Self {
Self
}
fn render_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("");
let image = item.get("image").and_then(|v| v.as_str());
let image_html = match image {
Some(src) => format!(r#"<img src="{src}" alt="{title}" loading="lazy" />"#),
None => r#"<div class="card-placeholder"></div>"#.to_string(),
};
let date = item.get("published_at")
.and_then(|v| v.as_str())
.and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
.map(|dt| dt.format("%B %d, %Y").to_string())
.unwrap_or_default();
format!(
r#"<article class="card">
<a href="/{slug}">
{image_html}
<div class="card-content">
<h3>{title}</h3>
<p>{description}</p>
<time>{date}</time>
</div>
</a>
</article>"#
)
}
}
#[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_card(item))
.collect::<Vec<_>>()
.join("\n");
Ok(RenderedComponent::new("POSTS", html))
}
}
Using Partial Templates
For complex rendering, delegate to a Handlebars partial:
fn partial_template(&self) -> Option<PartialTemplate> {
Some(PartialTemplate {
name: "partials/card".to_string(),
})
}
Create the partial at templates/partials/card.html:
{{#each items}}
<article class="card">
<a href="/{{this.slug}}">
<h3>{{this.title}}</h3>
<p>{{this.description}}</p>
</a>
</article>
{{/each}}
Checklist
- Created renderer struct
- Implemented ComponentRenderer trait
- Set correct
component_id() - Set correct
variable_name() - Configured
applies_to()targeting - Created card rendering method
- Added image support
- Added date formatting
- Registered in extension
- Exported from module
- Used triple braces in templates
- Tested with publish pipeline