1use 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#[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
24pub struct TemplateEngine {
26 handlebars: Handlebars<'static>,
27 site_title: String,
28 default_layout: String,
29}
30
31impl TemplateEngine {
32 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 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
127pub 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}