Skip to main content

orbit_md/template/
mod.rs

1//! Pre-compiled Handlebars layout engine.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use handlebars::Handlebars;
7use serde::Serialize;
8
9use crate::config::Config;
10use crate::error::{OrbitError, PageError};
11use crate::models::{CompiledPage, RenderedPage};
12
13/// Template context passed to layout partials.
14#[derive(Debug, Serialize)]
15struct PageContext<'a> {
16    title: String,
17    site_title: &'a str,
18    content: &'a str,
19    date: Option<&'a str>,
20    description: Option<&'a str>,
21    tags: &'a [String],
22}
23
24/// Handlebars registry with templates loaded at startup.
25pub struct TemplateEngine {
26    handlebars: Handlebars<'static>,
27    site_title: String,
28    default_layout: String,
29}
30
31impl TemplateEngine {
32    /// Loads and pre-compiles every `.hbs` file under `template_dir`.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`OrbitError::Template`] when templates cannot be read or compiled.
37    pub fn from_config(config: &Config) -> Result<Self, OrbitError> {
38        let mut handlebars = Handlebars::new();
39        handlebars.set_strict_mode(true);
40
41        if config.template_dir.exists() {
42            for entry in fs::read_dir(&config.template_dir).map_err(|source| OrbitError::Io {
43                path: config.template_dir.clone(),
44                source,
45            })? {
46                let entry = entry.map_err(|source| OrbitError::Io {
47                    path: config.template_dir.clone(),
48                    source,
49                })?;
50                let path = entry.path();
51                if path.extension().is_some_and(|ext| ext == "hbs") {
52                    let name = path
53                        .file_name()
54                        .and_then(|n| n.to_str())
55                        .ok_or_else(|| OrbitError::Template("invalid template filename".into()))?;
56                    let source = fs::read_to_string(&path).map_err(|source| OrbitError::Io {
57                        path: path.clone(),
58                        source,
59                    })?;
60                    handlebars
61                        .register_template_string(name, source)
62                        .map_err(|err| OrbitError::Template(err.to_string()))?;
63                }
64            }
65        }
66
67        Ok(Self {
68            handlebars,
69            site_title: config.title.clone(),
70            default_layout: config.layout.clone(),
71        })
72    }
73
74    /// Wraps a compiled page fragment in the site layout.
75    pub fn render_page(
76        &self,
77        page: CompiledPage,
78        output_root: &Path,
79    ) -> Result<RenderedPage, PageError> {
80        let layout = page
81            .source
82            .front_matter
83            .layout
84            .clone()
85            .unwrap_or_else(|| self.default_layout.clone());
86
87        let fallback_title = page
88            .source
89            .relative_path
90            .file_stem()
91            .and_then(|s| s.to_str())
92            .unwrap_or("Untitled")
93            .to_owned();
94
95        let title = page.source.front_matter.effective_title(&fallback_title);
96
97        let context = PageContext {
98            title: title.clone(),
99            site_title: &self.site_title,
100            content: &page.content_html,
101            date: page.source.front_matter.date.as_deref(),
102            description: page.source.front_matter.description.as_deref(),
103            tags: &page.source.front_matter.tags,
104        };
105
106        let html = self.handlebars.render(&layout, &context).map_err(|err| {
107            PageError::new(
108                &page.source.source_path,
109                format!("template render failed: {err}"),
110            )
111        })?;
112
113        let output_path = output_path_for(&page.source.relative_path, output_root);
114
115        Ok(RenderedPage::new(output_path, html))
116    }
117}
118
119fn output_path_for(relative: &Path, output_root: &Path) -> PathBuf {
120    let mut dest = output_root.to_path_buf();
121    dest.push(relative.with_extension("html"));
122    dest
123}
124
125use rayon::prelude::*;
126
127/// Renders all compiled pages in parallel using a shared, immutable engine.
128///
129/// The engine is `Sync` and borrowed immutably — no mutex on the hot path.
130pub fn render_all(
131    engine: &TemplateEngine,
132    pages: Vec<CompiledPage>,
133    output_root: &Path,
134) -> Result<Vec<RenderedPage>, PageError> {
135    pages
136        .into_par_iter()
137        .map(|page| engine.render_page(page, output_root))
138        .collect()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::models::{FrontMatter, SourcePage};
145    use std::path::PathBuf;
146
147    #[test]
148    fn renders_with_default_layout() {
149        let mut config = Config::default();
150        let dir = tempfile::tempdir().unwrap();
151        config.template_dir = dir.path().to_path_buf();
152        config.layout = "base.hbs".to_owned();
153        std::fs::write(
154            dir.path().join("base.hbs"),
155            "<html><title>{{title}}</title><body>{{{content}}}</body></html>",
156        )
157        .unwrap();
158
159        let engine = TemplateEngine::from_config(&config).unwrap();
160        let compiled = CompiledPage::new(
161            SourcePage {
162                source_path: PathBuf::from("content/a.md"),
163                relative_path: PathBuf::from("a.md"),
164                front_matter: FrontMatter {
165                    title: Some("Page".into()),
166                    ..Default::default()
167                },
168                body: String::new(),
169            },
170            "<p>Hi</p>".to_owned(),
171        );
172
173        let rendered = engine.render_page(compiled, Path::new("dist")).unwrap();
174        assert!(rendered.html.contains("<p>Hi</p>"));
175        assert!(rendered.output_path.ends_with("a.html"));
176    }
177}