1use anyhow::Result;
2use log::info;
3use minijinja::{Environment, Error as MinijinjaError, ErrorKind, Value};
4use serde::Serialize;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
10pub struct TemplateEngine {
11 env: Environment<'static>,
12 template_dirs: Vec<PathBuf>,
13 global_context: HashMap<String, Value>,
14}
15
16impl TemplateEngine {
17 pub fn new(config: &crate::config::BuildConfig) -> Result<Self> {
18 let mut env = Environment::new();
19
20 let mut template_dirs = Vec::new();
22
23 for template_path in &config.templates_path {
25 template_dirs.push(PathBuf::from(template_path));
26 }
27
28 template_dirs.push(PathBuf::from("templates"));
30
31 for template_dir in &template_dirs {
33 if template_dir.exists() {
34 Self::load_templates_from_dir(&mut env, template_dir)?;
35 }
36 }
37
38 if env.get_template("page.html").is_err() {
40 Self::add_builtin_templates(&mut env)?;
41 }
42
43 Self::setup_template_functions(&mut env);
45
46 let global_context = HashMap::new();
47
48 Ok(Self {
49 env,
50 template_dirs,
51 global_context,
52 })
53 }
54
55 fn load_templates_from_dir(_env: &mut Environment<'static>, dir: &Path) -> Result<()> {
57 info!("Loading templates from: {}", dir.display());
58
59 for entry in std::fs::read_dir(dir)? {
60 let entry = entry?;
61 let path = entry.path();
62
63 if path.is_file() && path.extension().is_some_and(|ext| ext == "html") {
64 let _template_name = path
65 .file_name()
66 .and_then(|name| name.to_str())
67 .unwrap_or("unknown");
68
69 let _content = std::fs::read_to_string(&path)?;
70 }
73 }
74
75 Ok(())
76 }
77
78 fn add_builtin_templates(env: &mut Environment<'static>) -> Result<()> {
80 let page_template = include_str!("../templates/page.html");
82 env.add_template("page.html", page_template)?;
83
84 let layout_template = include_str!("../templates/layout.html");
86 env.add_template("layout.html", layout_template)?;
87
88 let genindex_template = include_str!("../templates/genindex.html");
90 env.add_template("genindex.html", genindex_template)?;
91
92 let genindex_split_template = include_str!("../templates/genindex-split.html");
93 env.add_template("genindex-split.html", genindex_split_template)?;
94
95 let genindex_single_template = include_str!("../templates/genindex-single.html");
96 env.add_template("genindex-single.html", genindex_single_template)?;
97
98 let domainindex_template = include_str!("../templates/domainindex.html");
100 env.add_template("domainindex.html", domainindex_template)?;
101
102 let search_template = include_str!("../templates/search.html");
104 env.add_template("search.html", search_template)?;
105
106 let opensearch_template = include_str!("../templates/opensearch.xml");
108 env.add_template("opensearch.xml", opensearch_template)?;
109
110 Ok(())
111 }
112
113 fn setup_template_functions(env: &mut Environment<'static>) {
115 env.add_function(
117 "pathto",
118 |args: &[Value]| -> Result<Value, MinijinjaError> {
119 let target = args
120 .first()
121 .ok_or_else(|| {
122 MinijinjaError::new(
123 ErrorKind::InvalidOperation,
124 "pathto requires target argument",
125 )
126 })?
127 .as_str()
128 .ok_or_else(|| {
129 MinijinjaError::new(ErrorKind::InvalidOperation, "target must be string")
130 })?;
131
132 let resource = args
133 .get(1)
134 .and_then(|v| v.as_str().map(|s| s == "true"))
135 .unwrap_or(false);
136
137 let path = if resource {
139 format!("_static/{}", target)
140 } else if target.starts_with("http") {
141 target.to_string()
142 } else {
143 format!("{}.html", target)
144 };
145
146 Ok(Value::from(path))
147 },
148 );
149
150 env.add_function(
152 "css_tag",
153 |args: &[Value]| -> Result<Value, MinijinjaError> {
154 let css = args.first().ok_or_else(|| {
155 MinijinjaError::new(
156 ErrorKind::InvalidOperation,
157 "css_tag requires css argument",
158 )
159 })?;
160
161 let filename = if let Some(css_str) = css.as_str() {
162 css_str
163 } else {
164 return Ok(Value::from(""));
165 };
166
167 let tag = format!(
168 r#"<link rel="stylesheet" href="{}" type="text/css" />"#,
169 filename
170 );
171 Ok(Value::from(tag))
172 },
173 );
174
175 env.add_function(
177 "js_tag",
178 |args: &[Value]| -> Result<Value, MinijinjaError> {
179 let js = args.first().ok_or_else(|| {
180 MinijinjaError::new(ErrorKind::InvalidOperation, "js_tag requires js argument")
181 })?;
182
183 let filename = if let Some(js_str) = js.as_str() {
184 js_str
185 } else {
186 return Ok(Value::from(""));
187 };
188
189 let tag = format!(r#"<script src="{}"></script>"#, filename);
190 Ok(Value::from(tag))
191 },
192 );
193
194 env.add_function(
196 "toctree",
197 |_args: &[Value]| -> Result<Value, MinijinjaError> {
198 Ok(Value::from("<div class=\"toctree-wrapper\"></div>"))
200 },
201 );
202
203 env.add_filter("e", |value: Value| -> Result<Value, MinijinjaError> {
205 if let Some(s) = value.as_str() {
206 Ok(Value::from(html_escape::encode_text(s).to_string()))
207 } else {
208 Ok(value)
209 }
210 });
211
212 env.add_filter(
214 "striptags",
215 |value: Value| -> Result<Value, MinijinjaError> {
216 if let Some(s) = value.as_str() {
217 let stripped = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(s, "");
219 Ok(Value::from(stripped.to_string()))
220 } else {
221 Ok(value)
222 }
223 },
224 );
225 }
226
227 pub fn render(
229 &self,
230 template_name: &str,
231 context: &serde_json::Map<String, serde_json::Value>,
232 ) -> Result<String> {
233 let template = self
234 .env
235 .get_template(template_name)
236 .map_err(|e| anyhow::anyhow!("Template '{}' not found: {}", template_name, e))?;
237
238 let mut full_context = self.global_context.clone();
240 for (key, value) in context {
241 full_context.insert(key.clone(), Self::json_to_value(value));
242 }
243
244 let rendered = template
245 .render(&full_context)
246 .map_err(|e| anyhow::anyhow!("Failed to render template '{}': {}", template_name, e))?;
247
248 Ok(rendered)
249 }
250
251 fn json_to_value(json_value: &serde_json::Value) -> Value {
253 match json_value {
254 serde_json::Value::Null => Value::UNDEFINED,
255 serde_json::Value::Bool(b) => Value::from(*b),
256 serde_json::Value::Number(n) => {
257 if let Some(i) = n.as_i64() {
258 Value::from(i)
259 } else if let Some(f) = n.as_f64() {
260 Value::from(f)
261 } else {
262 Value::UNDEFINED
263 }
264 }
265 serde_json::Value::String(s) => Value::from(s.clone()),
266 serde_json::Value::Array(arr) => {
267 let values: Vec<Value> = arr.iter().map(Self::json_to_value).collect();
268 Value::from(values)
269 }
270 serde_json::Value::Object(obj) => {
271 let map: HashMap<String, Value> = obj
273 .iter()
274 .map(|(k, v)| (k.clone(), Self::json_to_value(v)))
275 .collect();
276 Value::from_serialize(&map)
277 }
278 }
279 }
280
281 pub fn set_global_context(&mut self, context: HashMap<String, Value>) {
283 self.global_context = context;
284 }
285
286 pub fn update_global_context(&mut self, key: String, value: Value) {
288 self.global_context.insert(key, value);
289 }
290
291 pub fn newest_template_mtime(&self) -> std::time::SystemTime {
293 let mut newest = std::time::UNIX_EPOCH;
294
295 for template_dir in &self.template_dirs {
296 if let Ok(entries) = std::fs::read_dir(template_dir) {
297 for entry in entries.flatten() {
298 if let Ok(metadata) = entry.metadata() {
299 if let Ok(mtime) = metadata.modified() {
300 if mtime > newest {
301 newest = mtime;
302 }
303 }
304 }
305 }
306 }
307 }
308
309 newest
310 }
311
312 pub fn newest_template_name(&self) -> String {
314 let mut newest_time = std::time::UNIX_EPOCH;
315 let mut newest_name = String::new();
316
317 for template_dir in &self.template_dirs {
318 if let Ok(entries) = std::fs::read_dir(template_dir) {
319 for entry in entries.flatten() {
320 if let Ok(metadata) = entry.metadata() {
321 if let Ok(mtime) = metadata.modified() {
322 if mtime > newest_time {
323 newest_time = mtime;
324 newest_name = entry.file_name().to_string_lossy().to_string();
325 }
326 }
327 }
328 }
329 }
330 }
331
332 newest_name
333 }
334}
335
336#[derive(Debug, Default)]
338pub struct TemplateContext {
339 context: serde_json::Map<String, serde_json::Value>,
340}
341
342impl TemplateContext {
343 pub fn new() -> Self {
344 Self::default()
345 }
346
347 pub fn insert<T: Serialize>(&mut self, key: &str, value: T) -> Result<()> {
348 let json_value = serde_json::to_value(value)?;
349 self.context.insert(key.to_string(), json_value);
350 Ok(())
351 }
352
353 pub fn extend(&mut self, other: serde_json::Map<String, serde_json::Value>) {
354 self.context.extend(other);
355 }
356
357 pub fn build(self) -> serde_json::Map<String, serde_json::Value> {
358 self.context
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::config::BuildConfig;
366
367 #[test]
368 fn test_template_engine_creation() {
369 let config = BuildConfig::default();
370 let engine = TemplateEngine::new(&config);
371 assert!(engine.is_ok());
372 }
373
374 #[test]
375 fn test_template_context() {
376 let mut ctx = TemplateContext::new();
377 ctx.insert("title", "Test Title").unwrap();
378 ctx.insert("count", 42).unwrap();
379
380 let context = ctx.build();
381 assert_eq!(
382 context.get("title").and_then(|v| v.as_str()),
383 Some("Test Title")
384 );
385 assert_eq!(context.get("count").and_then(|v| v.as_i64()), Some(42));
386 }
387}