sphinx_ultra/
template.rs

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/// Template engine for rendering HTML pages (similar to Jinja2 in Sphinx)
9#[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        // Set up template directories
21        let mut template_dirs = Vec::new();
22
23        // Add user template directories
24        for template_path in &config.templates_path {
25            template_dirs.push(PathBuf::from(template_path));
26        }
27
28        // Add built-in template directory
29        template_dirs.push(PathBuf::from("templates"));
30
31        // Load templates from directories
32        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        // Add built-in templates if no templates found
39        if env.get_template("page.html").is_err() {
40            Self::add_builtin_templates(&mut env)?;
41        }
42
43        // Set up global functions and filters
44        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    /// Load templates from a directory
56    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                // Skip this for now to avoid lifetime issues - templates will be added via built-ins
71                // env.add_template(template_name, &content)?;
72            }
73        }
74
75        Ok(())
76    }
77
78    /// Add built-in templates
79    fn add_builtin_templates(env: &mut Environment<'static>) -> Result<()> {
80        // Basic page template
81        let page_template = include_str!("../templates/page.html");
82        env.add_template("page.html", page_template)?;
83
84        // Layout template
85        let layout_template = include_str!("../templates/layout.html");
86        env.add_template("layout.html", layout_template)?;
87
88        // Index templates
89        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        // Domain index template
99        let domainindex_template = include_str!("../templates/domainindex.html");
100        env.add_template("domainindex.html", domainindex_template)?;
101
102        // Search template
103        let search_template = include_str!("../templates/search.html");
104        env.add_template("search.html", search_template)?;
105
106        // OpenSearch template
107        let opensearch_template = include_str!("../templates/opensearch.xml");
108        env.add_template("opensearch.xml", opensearch_template)?;
109
110        Ok(())
111    }
112
113    /// Set up template functions and filters
114    fn setup_template_functions(env: &mut Environment<'static>) {
115        // Add pathto function (similar to Sphinx's pathto)
116        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                // Simple relative path calculation
138                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        // Add css_tag function
151        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        // Add js_tag function
176        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        // Add toctree function
195        env.add_function(
196            "toctree",
197            |_args: &[Value]| -> Result<Value, MinijinjaError> {
198                // TODO: Implement actual toctree generation
199                Ok(Value::from("<div class=\"toctree-wrapper\"></div>"))
200            },
201        );
202
203        // Add |e filter (HTML escape)
204        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        // Add |striptags filter
213        env.add_filter(
214            "striptags",
215            |value: Value| -> Result<Value, MinijinjaError> {
216                if let Some(s) = value.as_str() {
217                    // Simple HTML tag stripping
218                    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    /// Render a template with the given context
228    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        // Convert context to minijinja Values
239        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    /// Convert serde_json::Value to minijinja::Value
252    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                // Convert to a simple map representation
272                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    /// Set global template context
282    pub fn set_global_context(&mut self, context: HashMap<String, Value>) {
283        self.global_context = context;
284    }
285
286    /// Update global template context
287    pub fn update_global_context(&mut self, key: String, value: Value) {
288        self.global_context.insert(key, value);
289    }
290
291    /// Get newest template modification time
292    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    /// Get newest template name (for logging)
313    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/// Template context helper for building context maps
337#[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}