Page Prerenderer
Implement PagePrerenderer::prepare() to build static HTML at publish time for list and index pages, returning a PageRenderSpec with template and output path.
On this page
PagePrerenderer generates static HTML pages at build time. Use this for list pages, index pages, and other content that doesn't come from markdown files.
When It Runs
PagePrerenderers run during the publish pipeline, after content is processed:
Content ingestion
|
Content rendering
|
=======================================
PagePrerenderer::prepare() <- You are here
=======================================
|
Template rendering
|
HTML output
The Trait
#[async_trait]
pub trait PagePrerenderer: Send + Sync {
fn page_type(&self) -> &str;
fn priority(&self) -> u32 {
100
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>)
-> ProviderResult<Option<PageRenderSpec>>;
}
PagePrepareContext
pub struct PagePrepareContext<'a> {
pub web_config: &'a WebConfig,
pub locale: &'a LocaleCode,
// private: content config, database pool, dist dir
}
impl<'a> PagePrepareContext<'a> {
pub fn content_config<T: 'static>(&self) -> Option<&T>;
pub fn db_pool<T: 'static>(&self) -> Option<&T>;
pub fn dist_dir(&self) -> &std::path::Path;
}
web_config and locale are public fields. The content config and database pool are type-erased; downcast them with content_config::<T>() and db_pool::<T>() (the pool is stored as DbPool, an Arc<Database>).
PageRenderSpec
pub struct PageRenderSpec {
pub template_name: String,
pub base_data: Value,
pub output_path: PathBuf,
}
impl PageRenderSpec {
pub fn new(
template_name: impl Into<String>,
base_data: Value,
output_path: impl Into<PathBuf>,
) -> Self;
}
Basic Implementation
use std::path::PathBuf;
use async_trait::async_trait;
use serde_json::json;
use systemprompt::database::DbPool;
use systemprompt::extension::prelude::*;
use systemprompt::traits::ProviderError;
pub struct BlogListPrerenderer;
#[async_trait]
impl PagePrerenderer for BlogListPrerenderer {
fn page_type(&self) -> &str {
"blog-list"
}
fn priority(&self) -> u32 {
100
}
async fn prepare(
&self,
ctx: &PagePrepareContext<'_>,
) -> Result<Option<PageRenderSpec>, ProviderError> {
let posts = self.fetch_posts(ctx).await?;
let posts_html = self.render_post_cards(&posts);
let base_data = json!({
"TITLE": "Blog",
"DESCRIPTION": "Latest posts from our blog",
"POSTS": posts_html,
"POST_COUNT": posts.len(),
});
Ok(Some(PageRenderSpec::new(
"blog-list",
base_data,
PathBuf::from("blog/index.html"),
)))
}
}
impl BlogListPrerenderer {
fn render_post_cards(&self, posts: &[PostSummary]) -> String {
posts.iter()
.map(|p| format!(
r#"<article class="post-card">
<a href="/blog/{}">
<h3>{}</h3>
<p>{}</p>
</a>
</article>"#,
p.slug, p.title, p.description.as_deref().unwrap_or("")
))
.collect::<Vec<_>>()
.join("\n")
}
}
Inside fetch_posts, get the database handle from the context and turn a missing pool into a ProviderError:
let pool = ctx.db_pool::<DbPool>()
.ok_or_else(|| ProviderError::Configuration("Database not available".into()))?;
Returning None
Return None to skip rendering (e.g., when feature is disabled):
async fn prepare(
&self,
ctx: &PagePrepareContext<'_>,
) -> Result<Option<PageRenderSpec>, ProviderError> {
if !self.config.blog_list_enabled {
return Ok(None);
}
// ... render page
}
Registration
impl Extension for WebExtension {
fn page_prerenderers(&self) -> Vec<Arc<dyn PagePrerenderer>> {
vec![
Arc::new(HomepagePrerenderer::new(self.config.clone())),
Arc::new(BlogListPrerenderer),
Arc::new(DocsIndexPrerenderer),
Arc::new(SitemapPrerenderer),
]
}
}
Common Patterns
Homepage
async fn prepare(
&self,
ctx: &PagePrepareContext<'_>,
) -> Result<Option<PageRenderSpec>, ProviderError> {
let pool = ctx.db_pool::<DbPool>()
.ok_or_else(|| ProviderError::Configuration("Database not available".into()))?;
let featured = self.fetch_featured_posts(pool).await?;
let recent = self.fetch_recent_posts(pool, 5).await?;
Ok(Some(PageRenderSpec::new(
"homepage",
json!({
"FEATURED_POSTS": featured,
"RECENT_POSTS": recent,
"HERO_TITLE": ctx.web_config.branding.title,
}),
PathBuf::from("index.html"),
)))
}
Documentation Index
async fn prepare(
&self,
ctx: &PagePrepareContext<'_>,
) -> Result<Option<PageRenderSpec>, ProviderError> {
let pool = ctx.db_pool::<DbPool>()
.ok_or_else(|| ProviderError::Configuration("Database not available".into()))?;
let sections = self.fetch_doc_sections(pool).await?;
Ok(Some(PageRenderSpec::new(
"docs-index",
json!({
"TITLE": "Documentation",
"SECTIONS": sections,
}),
PathBuf::from("docs/index.html"),
)))
}
Multiple Pages
Each prerenderer emits exactly one page per locale. To generate a family of pages, register one prerenderer instance per page with a unique page_type (for example feature-page:{slug}), as the template's FeaturePagePrerenderer does.
Priority
Lower priority values indicate higher importance and execute first. When multiple prerenderers target the same page_type, only the first one (lowest priority value) runs - others are skipped.
| Priority | Use Case |
|---|---|
| 0-49 | Critical - overrides defaults |
| 50-99 | Core application pages |
| 100 | Default (fallback) |
| 101+ | Low priority - easily overridden |
fn priority(&self) -> u32 {
50 // Higher importance than default (100), will override core defaults
}
Example: If your extension's HomepagePrerenderer has priority 10 and the core's DefaultHomepagePrerenderer has priority 100, your prerenderer runs and the core's is skipped.