Skip to main content

systemprompt_generator/prerender/
engine.rs

1//! Top-level prerender entry points: `prerender_content` walks every source
2//! and renders content pages, while `prerender_pages` runs registered
3//! page-prerenderer extensions to produce one-off pages (homepage, search,
4//! error pages, …).
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use std::collections::HashSet;
10use std::path::PathBuf;
11
12use systemprompt_content::ContentRepository;
13use systemprompt_database::DbPool;
14use systemprompt_identifiers::LocaleCode;
15use systemprompt_models::AppPaths;
16use systemprompt_template_provider::{
17    ComponentContext, DynPagePrerenderer, PageContext, PagePrepareContext,
18};
19use tokio::fs;
20
21use crate::error::{GeneratorResult as Result, PublishError};
22use crate::prerender::content::process_all_sources;
23use crate::prerender::context::{PrerenderContext, load_prerender_context};
24use crate::prerender::utils::{merge_json_data, render_components};
25
26pub async fn prerender_content(
27    db_pool: DbPool,
28    content_repo: ContentRepository,
29    paths: &AppPaths,
30) -> Result<()> {
31    let ctx = load_prerender_context(db_pool, content_repo, paths).await?;
32    let total_rendered = process_all_sources(&ctx).await?;
33    tracing::info!(items_rendered = total_rendered, "Prerendering completed");
34    Ok(())
35}
36
37#[derive(Debug)]
38pub struct PagePrerenderResult {
39    pub page_type: String,
40    pub output_path: PathBuf,
41}
42
43pub async fn prerender_pages(
44    db_pool: DbPool,
45    content_repo: ContentRepository,
46    paths: &AppPaths,
47) -> Result<Vec<PagePrerenderResult>> {
48    let ctx = load_prerender_context(db_pool, content_repo, paths).await?;
49    prerender_pages_with_context(&ctx).await
50}
51
52async fn prerender_pages_with_context(ctx: &PrerenderContext) -> Result<Vec<PagePrerenderResult>> {
53    let prerenderers = ctx.template_registry.page_prerenderers();
54
55    if prerenderers.is_empty() {
56        tracing::warn!("No page prerenderers registered - no pages will be rendered");
57        return Ok(Vec::new());
58    }
59
60    let prerenderer_count = prerenderers.len();
61    let page_types: Vec<_> = prerenderers.iter().map(|p| p.page_type()).collect();
62    tracing::info!(
63        count = prerenderer_count,
64        page_types = ?page_types,
65        "Discovered page prerenderers"
66    );
67
68    let mut results = Vec::new();
69
70    for locale in &ctx.web_config.i18n.supported_locales {
71        let locale_prefix = ctx.web_config.i18n.locale_prefix(locale);
72        let prepare_ctx =
73            PagePrepareContext::new(&ctx.web_config, &ctx.config, &ctx.db_pool, &ctx.dist_dir)
74                .with_locale(locale);
75
76        let mut rendered_page_types: HashSet<String> = HashSet::new();
77
78        for prerenderer in prerenderers {
79            let page_type = prerenderer.page_type();
80
81            if rendered_page_types.contains(page_type) {
82                tracing::debug!(
83                    page_type = %page_type,
84                    locale = %locale,
85                    priority = prerenderer.priority(),
86                    "Skipping prerenderer, page type already rendered by higher-priority prerenderer"
87                );
88                continue;
89            }
90
91            let render = RenderPageParams {
92                ctx,
93                prerenderer,
94                prepare_ctx: &prepare_ctx,
95                locale,
96                locale_prefix: &locale_prefix,
97            };
98
99            if let Some(result) = render_prerenderer_page(&render).await? {
100                rendered_page_types.insert(page_type.to_owned());
101                results.push(result);
102            }
103        }
104    }
105
106    Ok(results)
107}
108
109struct RenderPageParams<'a> {
110    ctx: &'a PrerenderContext,
111    prerenderer: &'a DynPagePrerenderer,
112    prepare_ctx: &'a PagePrepareContext<'a>,
113    locale: &'a LocaleCode,
114    locale_prefix: &'a str,
115}
116
117async fn render_prerenderer_page(
118    params: &RenderPageParams<'_>,
119) -> Result<Option<PagePrerenderResult>> {
120    let RenderPageParams {
121        ctx,
122        prerenderer,
123        prepare_ctx,
124        locale,
125        locale_prefix,
126    } = params;
127
128    let page_type = prerenderer.page_type();
129
130    let render_spec = prerenderer
131        .prepare(prepare_ctx)
132        .await
133        .map_err(|e| PublishError::page_prerenderer_failed(page_type, e.to_string()))?;
134
135    let Some(spec) = render_spec else {
136        tracing::debug!(page_type = %page_type, locale = %locale, "Prerenderer returned None, skipping");
137        return Ok(None);
138    };
139
140    if !ctx.template_registry.has_template(&spec.template_name) {
141        tracing::warn!(
142            page_type = %page_type,
143            template = %spec.template_name,
144            "Template not found, skipping page"
145        );
146        return Ok(None);
147    }
148
149    let page_data = collect_page_data(ctx, page_type, locale, spec.base_data).await?;
150
151    let html = ctx
152        .template_registry
153        .render(&spec.template_name, &page_data)
154        .map_err(|e| PublishError::render_failed(&spec.template_name, None, e.to_string()))?;
155
156    let prefixed_output = if locale_prefix.is_empty() {
157        spec.output_path.clone()
158    } else {
159        PathBuf::from(locale_prefix.trim_start_matches('/')).join(&spec.output_path)
160    };
161    let output_path = ctx.dist_dir.join(&prefixed_output);
162
163    if let Some(parent) = output_path.parent() {
164        fs::create_dir_all(parent).await?;
165    }
166
167    fs::write(&output_path, html).await?;
168
169    tracing::info!(
170        page_type = %page_type,
171        locale = %locale,
172        path = %output_path.display(),
173        "Generated page"
174    );
175
176    Ok(Some(PagePrerenderResult {
177        page_type: page_type.to_owned(),
178        output_path,
179    }))
180}
181
182async fn collect_page_data(
183    ctx: &PrerenderContext,
184    page_type: &str,
185    locale: &LocaleCode,
186    base_data: serde_json::Value,
187) -> Result<serde_json::Value> {
188    let mut page_data = base_data;
189    if let Some(obj) = page_data.as_object_mut() {
190        obj.insert(
191            "locale".to_owned(),
192            serde_json::Value::String(locale.to_string()),
193        );
194    }
195
196    let page_ctx =
197        PageContext::new(page_type, &ctx.web_config, &ctx.config, &ctx.db_pool).with_locale(locale);
198    let providers = ctx.template_registry.page_providers_for(page_type);
199    let provider_ids: Vec<_> = providers.iter().map(|p| p.provider_id()).collect();
200
201    tracing::debug!(
202        page_type = %page_type,
203        locale = %locale,
204        provider_count = providers.len(),
205        provider_ids = ?provider_ids,
206        "Collecting page data from providers"
207    );
208
209    for provider in &providers {
210        let data = provider
211            .provide_page_data(&page_ctx)
212            .await
213            .map_err(|e| PublishError::provider_failed(provider.provider_id(), e.to_string()))?;
214        merge_json_data(&mut page_data, &data);
215    }
216
217    let component_ctx = ComponentContext::for_page(&ctx.web_config);
218    render_components(
219        &ctx.template_registry,
220        page_type,
221        &component_ctx,
222        &mut page_data,
223    )
224    .await;
225
226    Ok(page_data)
227}