Page Prerenderer

Generate static HTML pages at build time for list pages, index pages, and configured content.

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<'_>) -> Result<Option<PageRenderSpec>>;
}

PagePrepareContext

impl<'a> PagePrepareContext<'a> {
    pub fn web_config(&self) -> &FullWebConfig;
    pub fn config(&self) -> &ContentConfigRaw;
    pub fn db_pool<T>(&self) -> Option<&T>;
}

PageRenderSpec

pub struct PageRenderSpec {
    pub template_name: String,
    pub template_data: Value,
    pub output_path: PathBuf,
}

impl PageRenderSpec {
    pub fn new(
        template_name: impl Into<String>,
        template_data: Value,
        output_path: impl Into<PathBuf>,
    ) -> Self;
}

Basic Implementation

use systemprompt::template_provider::{PagePrepareContext, PagePrerenderer, PageRenderSpec};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;

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>> {
        let pool = ctx.db_pool::<Arc<PgPool>>()
            .ok_or_else(|| anyhow::anyhow!("Database not available"))?;

        let posts = sqlx::query!(
            r#"SELECT slug, title, description, published_at
               FROM markdown_content
               WHERE source_id = 'blog' AND public = true
               ORDER BY published_at DESC"#
        )
        .fetch_all(&*pool)
        .await?;

        let posts_html = self.render_post_cards(&posts);

        let template_data = json!({
            "TITLE": "Blog",
            "DESCRIPTION": "Latest posts from our blog",
            "POSTS": posts_html,
            "POST_COUNT": posts.len(),
        });

        Ok(Some(PageRenderSpec::new(
            "blog-list",
            template_data,
            PathBuf::from("blog/index.html"),
        )))
    }
}

impl BlogListPrerenderer {
    fn render_post_cards(&self, posts: &[Record]) -> 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")
    }
}

Returning None

Return None to skip rendering (e.g., when feature is disabled):

async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
    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>> {
    let pool = ctx.db_pool::<Arc<PgPool>>()?;

    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().hero.title,
        }),
        PathBuf::from("index.html"),
    )))
}

Documentation Index

async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
    let pool = ctx.db_pool::<Arc<PgPool>>()?;

    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

A single prerenderer can generate multiple pages by using a wrapper that calls it multiple times, or by returning specs for each variant.

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.