Generate static HTML pages at build time using PagePrerenderer, including list pages with custom card rendering.
PagePrerenderer vs PageDataProvider
| Aspect | PageDataProvider | PagePrerenderer |
|---|---|---|
| When | Runtime (per request) | Build time (once) |
| Purpose | Inject data into existing pages | Generate entire pages |
| Use for | Navigation, metadata | List pages, sitemaps |
| Output | JSON data for templates | Complete HTML files |
Use PagePrerenderer when:
- Generating list pages (blog index, docs index)
- Creating static pages from database content
- Pre-rendering pages that don't change per-request
PagePrerenderer Trait
#[async_trait]
pub trait PagePrerenderer: Send + Sync {
/// Unique identifier for this prerenderer
fn page_type(&self) -> &'static str;
/// Execution priority (lower = earlier)
fn priority(&self) -> u32;
/// Prepare page for rendering
/// Returns None to skip, Some(spec) to render
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>>;
}
PageRenderSpec
pub struct PageRenderSpec {
/// Template name (from templates.yaml)
pub template: String,
/// Template variables (JSON object)
pub data: serde_json::Value,
/// Output path relative to web/dist/
pub output_path: PathBuf,
}
impl PageRenderSpec {
pub fn new(template: &str, data: Value, output_path: PathBuf) -> Self {
Self {
template: template.to_string(),
data,
output_path,
}
}
}
Example: DocsIndexPrerenderer
File: extensions/web/src/docs/prerenderer.rs
This prerenderer generates the /documentation/ index page with child documentation cards.
pub struct DocsIndexPrerenderer;
#[async_trait]
impl PagePrerenderer for DocsIndexPrerenderer {
fn page_type(&self) -> &'static str {
"docs-index-prerenderer"
}
fn priority(&self) -> u32 {
50 // Lower value = higher importance, overrides core defaults (100)
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
// 1. Get database connection
let db = ctx.db_pool::<Arc<Database>>()?;
let pool = db.pool()?;
// 2. Fetch index content
let row = sqlx::query!(
r#"
SELECT id, title, description, body, author, updated_at,
COALESCE(after_reading_this, '[]'::jsonb) as "after_reading_this!",
COALESCE(related_playbooks, '[]'::jsonb) as "related_playbooks!"
FROM markdown_content
WHERE source_id = 'documentation' AND slug = ''
"#,
)
.fetch_optional(&*pool)
.await?;
let Some(row) = row else {
return Ok(None); // No index content, skip
};
// 3. Fetch children
let children = DocsContentDataProvider::new()
.get_children_static(&pool, "documentation", "")
.await;
// 4. Build template data
let mut template_data = json!({
"TITLE": row.title,
"DESCRIPTION": row.description,
"CONTENT": markdown_to_html(&row.body),
});
// 5. Render children as HTML cards
if !children.is_empty() {
let children_html = render_children_cards(&children);
template_data["CHILDREN"] = json!(children_html);
}
// 6. Return render spec
let output_path = PathBuf::from("documentation/index.html");
Ok(Some(PageRenderSpec::new(
"docs-list", // Template name
template_data, // Variables
output_path, // Output file
)))
}
}
Rendering Cards
Card Template
File: services/web/templates/partials/content-card-image.html
<a href="{{URL}}" class="blog-card-link" data-category="{{CATEGORY}}">
<article class="blog-card content-card content-card--{{KIND}}" data-category="{{CATEGORY}}">
{{#if IMAGE}}
<div class="card-image">
<img src="{{IMAGE}}" alt="{{TITLE}}" loading="lazy" />
</div>
{{/if}}
<div class="card-content">
{{#if CATEGORY}}
<span class="card-category card-category--{{CATEGORY}}">{{CATEGORY}}</span>
{{/if}}
<h2 class="card-title">{{TITLE}}</h2>
<p class="card-description">{{DESCRIPTION}}</p>
<div class="meta">
{{#if DATE}}
<time datetime="{{DATE_ISO}}" class="meta-date">{{DATE}}</time>
{{/if}}
</div>
</div>
</article>
</a>
Rendering Cards in Rust
fn render_blog_cards(posts: &[BlogPost]) -> String {
posts
.iter()
.map(|post| {
format!(
r#"<a href="/blog/{}" class="blog-card-link" data-category="{}">
<article class="blog-card content-card" data-category="{}">
<div class="card-image">
<img src="{}" alt="{}" loading="lazy" />
</div>
<div class="card-content">
<span class="card-category card-category--{}">{}</span>
<h2 class="card-title">{}</h2>
<p class="card-description">{}</p>
<div class="meta">
<time class="card-date">{}</time>
</div>
</div>
</article>
</a>"#,
post.slug,
post.category.as_deref().unwrap_or(""),
post.category.as_deref().unwrap_or(""),
post.image.as_deref().unwrap_or("/files/images/blog/placeholder.svg"),
html_escape(&post.title),
post.category.as_deref().unwrap_or(""),
post.category.as_deref().unwrap_or(""),
html_escape(&post.title),
html_escape(&post.description),
post.published_at.format("%B %d, %Y"),
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
Creating a Blog List Prerenderer
Step 1: Create the Prerenderer
File: extensions/web/src/blog/prerenderer.rs
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use systemprompt::database::Database;
use systemprompt::template_provider::{PagePrepareContext, PagePrerenderer, PageRenderSpec};
pub struct BlogListPrerenderer;
impl BlogListPrerenderer {
pub const fn new() -> Self {
Self
}
}
#[async_trait]
impl PagePrerenderer for BlogListPrerenderer {
fn page_type(&self) -> &'static str {
"blog-list-prerenderer"
}
fn priority(&self) -> u32 {
100
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
let db = ctx.db_pool::<Arc<Database>>().ok_or_else(|| {
anyhow::anyhow!("No database in context")
})?;
let pool = db.pool().ok_or_else(|| {
anyhow::anyhow!("Pool not initialized")
})?;
// Fetch all blog posts
let posts = sqlx::query!(
r#"
SELECT slug, title, description, image, category, published_at
FROM markdown_content
WHERE source_id = 'blog' AND public = true
ORDER BY published_at DESC
"#
)
.fetch_all(&*pool)
.await?;
// Render cards with category
let cards_html = render_blog_cards(&posts);
// Build template data
let template_data = json!({
"POSTS": cards_html,
"site": ctx.web_config,
});
let output_path = PathBuf::from("blog/index.html");
Ok(Some(PageRenderSpec::new(
"blog-list",
template_data,
output_path,
)))
}
}
Step 2: Create Module
File: extensions/web/src/blog/mod.rs
mod prerenderer;
pub use prerenderer::BlogListPrerenderer;
Step 3: Export Module
File: extensions/web/src/lib.rs
pub mod blog;
pub use blog::BlogListPrerenderer;
Step 4: Register Prerenderer
File: extensions/web/src/extension.rs
impl Extension for WebExtension {
fn page_prerenderers(&self) -> Vec<Arc<dyn PagePrerenderer>> {
vec![
Arc::new(DocsIndexPrerenderer::new()),
Arc::new(BlogListPrerenderer::new()), // Add this
]
}
}
Template Variables
Common variables passed to list templates:
| Variable | Type | Description |
|---|---|---|
POSTS |
String | Pre-rendered HTML cards |
CHILDREN |
String | Pre-rendered child cards |
TITLE |
String | Page title |
DESCRIPTION |
String | Page description |
CONTENT |
String | Rendered markdown body |
site |
Object | Site configuration |
Using in Templates
<main class="blog-list">
<header class="page-header">
<h1>{{TITLE}}</h1>
<p>{{DESCRIPTION}}</p>
</header>
<div class="blog-grid" id="blog-grid">
{{{POSTS}}}
</div>
</main>
Note: Use triple braces {{{POSTS}}} for pre-rendered HTML (no escaping).
Filtering with data-category
The data-category attribute enables client-side filtering:
HTML Structure
<a href="/blog/my-post" class="blog-card-link" data-category="announcement">
<article class="blog-card" data-category="announcement">
...
</article>
</a>
CSS Filtering
/* Hide cards that don't match filter */
.blog-grid[data-filter="announcement"] a:not([data-category="announcement"]),
.blog-grid[data-filter="guide"] a:not([data-category="guide"]),
.blog-grid[data-filter="article"] a:not([data-category="article"]) {
display: none;
}
JavaScript Filter
function applyFilter(filter) {
const grid = document.getElementById('blog-grid');
grid.dataset.filter = filter || '';
}
CLI Commands
# Run prerendering
systemprompt infra jobs run content_prerender
# Full publish pipeline
systemprompt infra jobs run publish_pipeline
# Check generated output
ls -la web/dist/blog/
cat web/dist/blog/index.html | grep "data-category"
Troubleshooting
| Problem | Solution |
|---|---|
| Page not generated | Check prepare() returns Some(spec) |
| Wrong template | Verify template name in PageRenderSpec |
| Missing variables | Add to template_data JSON |
| Cards missing data | Check database query includes all fields |
| Filtering not working | Verify data-category in rendered HTML |
Quick Reference
| Task | Location |
|---|---|
| Create prerenderer | extensions/web/src/*/prerenderer.rs |
| Register prerenderer | extension.rs → page_prerenderers() |
| Card template | services/web/templates/partials/content-card-image.html |
| List template | services/web/templates/*-list.html |
| Run prerender | systemprompt infra jobs run content_prerender |