use anyhow::Result;
use log::info;
use minijinja::{Environment, Error as MinijinjaError, ErrorKind, Value};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct TemplateEngine {
env: Environment<'static>,
template_dirs: Vec<PathBuf>,
global_context: HashMap<String, Value>,
}
impl TemplateEngine {
pub fn new(config: &crate::config::BuildConfig) -> Result<Self> {
let mut env = Environment::new();
let mut template_dirs = Vec::new();
for template_path in &config.templates_path {
template_dirs.push(PathBuf::from(template_path));
}
template_dirs.push(PathBuf::from("templates"));
for template_dir in &template_dirs {
if template_dir.exists() {
Self::load_templates_from_dir(&mut env, template_dir)?;
}
}
if env.get_template("page.html").is_err() {
Self::add_builtin_templates(&mut env)?;
}
Self::setup_template_functions(&mut env);
let global_context = HashMap::new();
Ok(Self {
env,
template_dirs,
global_context,
})
}
fn load_templates_from_dir(_env: &mut Environment<'static>, dir: &Path) -> Result<()> {
info!("Loading templates from: {}", dir.display());
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "html") {
let _template_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown");
let _content = std::fs::read_to_string(&path)?;
}
}
Ok(())
}
fn add_builtin_templates(env: &mut Environment<'static>) -> Result<()> {
let page_template = include_str!("../templates/page.html");
env.add_template("page.html", page_template)?;
let layout_template = include_str!("../templates/layout.html");
env.add_template("layout.html", layout_template)?;
let genindex_template = include_str!("../templates/genindex.html");
env.add_template("genindex.html", genindex_template)?;
let genindex_split_template = include_str!("../templates/genindex-split.html");
env.add_template("genindex-split.html", genindex_split_template)?;
let genindex_single_template = include_str!("../templates/genindex-single.html");
env.add_template("genindex-single.html", genindex_single_template)?;
let domainindex_template = include_str!("../templates/domainindex.html");
env.add_template("domainindex.html", domainindex_template)?;
let search_template = include_str!("../templates/search.html");
env.add_template("search.html", search_template)?;
let opensearch_template = include_str!("../templates/opensearch.xml");
env.add_template("opensearch.xml", opensearch_template)?;
Ok(())
}
fn setup_template_functions(env: &mut Environment<'static>) {
env.add_function(
"pathto",
|args: &[Value]| -> Result<Value, MinijinjaError> {
let target = args
.first()
.ok_or_else(|| {
MinijinjaError::new(
ErrorKind::InvalidOperation,
"pathto requires target argument",
)
})?
.as_str()
.ok_or_else(|| {
MinijinjaError::new(ErrorKind::InvalidOperation, "target must be string")
})?;
let resource = args
.get(1)
.and_then(|v| v.as_str().map(|s| s == "true"))
.unwrap_or(false);
let path = if resource {
format!("_static/{}", target)
} else if target.starts_with("http") {
target.to_string()
} else {
format!("{}.html", target)
};
Ok(Value::from(path))
},
);
env.add_function(
"css_tag",
|args: &[Value]| -> Result<Value, MinijinjaError> {
let css = args.first().ok_or_else(|| {
MinijinjaError::new(
ErrorKind::InvalidOperation,
"css_tag requires css argument",
)
})?;
let filename = if let Some(css_str) = css.as_str() {
css_str
} else {
return Ok(Value::from(""));
};
let tag = format!(
r#"<link rel="stylesheet" href="{}" type="text/css" />"#,
filename
);
Ok(Value::from(tag))
},
);
env.add_function(
"js_tag",
|args: &[Value]| -> Result<Value, MinijinjaError> {
let js = args.first().ok_or_else(|| {
MinijinjaError::new(ErrorKind::InvalidOperation, "js_tag requires js argument")
})?;
let filename = if let Some(js_str) = js.as_str() {
js_str
} else {
return Ok(Value::from(""));
};
let tag = format!(r#"<script src="{}"></script>"#, filename);
Ok(Value::from(tag))
},
);
env.add_function(
"toctree",
|_args: &[Value]| -> Result<Value, MinijinjaError> {
Ok(Value::from("<div class=\"toctree-wrapper\"></div>"))
},
);
env.add_filter("e", |value: Value| -> Result<Value, MinijinjaError> {
if let Some(s) = value.as_str() {
Ok(Value::from(html_escape::encode_text(s).to_string()))
} else {
Ok(value)
}
});
env.add_filter(
"striptags",
|value: Value| -> Result<Value, MinijinjaError> {
if let Some(s) = value.as_str() {
let stripped = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(s, "");
Ok(Value::from(stripped.to_string()))
} else {
Ok(value)
}
},
);
}
pub fn render(
&self,
template_name: &str,
context: &serde_json::Map<String, serde_json::Value>,
) -> Result<String> {
let template = self
.env
.get_template(template_name)
.map_err(|e| anyhow::anyhow!("Template '{}' not found: {}", template_name, e))?;
let mut full_context = self.global_context.clone();
for (key, value) in context {
full_context.insert(key.clone(), Self::json_to_value(value));
}
let rendered = template
.render(&full_context)
.map_err(|e| anyhow::anyhow!("Failed to render template '{}': {}", template_name, e))?;
Ok(rendered)
}
fn json_to_value(json_value: &serde_json::Value) -> Value {
match json_value {
serde_json::Value::Null => Value::UNDEFINED,
serde_json::Value::Bool(b) => Value::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
Value::from(f)
} else {
Value::UNDEFINED
}
}
serde_json::Value::String(s) => Value::from(s.clone()),
serde_json::Value::Array(arr) => {
let values: Vec<Value> = arr.iter().map(Self::json_to_value).collect();
Value::from(values)
}
serde_json::Value::Object(obj) => {
let map: HashMap<String, Value> = obj
.iter()
.map(|(k, v)| (k.clone(), Self::json_to_value(v)))
.collect();
Value::from_serialize(&map)
}
}
}
pub fn set_global_context(&mut self, context: HashMap<String, Value>) {
self.global_context = context;
}
pub fn update_global_context(&mut self, key: String, value: Value) {
self.global_context.insert(key, value);
}
pub fn newest_template_mtime(&self) -> std::time::SystemTime {
let mut newest = std::time::UNIX_EPOCH;
for template_dir in &self.template_dirs {
if let Ok(entries) = std::fs::read_dir(template_dir) {
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if let Ok(mtime) = metadata.modified() {
if mtime > newest {
newest = mtime;
}
}
}
}
}
}
newest
}
pub fn newest_template_name(&self) -> String {
let mut newest_time = std::time::UNIX_EPOCH;
let mut newest_name = String::new();
for template_dir in &self.template_dirs {
if let Ok(entries) = std::fs::read_dir(template_dir) {
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if let Ok(mtime) = metadata.modified() {
if mtime > newest_time {
newest_time = mtime;
newest_name = entry.file_name().to_string_lossy().to_string();
}
}
}
}
}
}
newest_name
}
}
#[derive(Debug, Default)]
pub struct TemplateContext {
context: serde_json::Map<String, serde_json::Value>,
}
impl TemplateContext {
pub fn new() -> Self {
Self::default()
}
pub fn insert<T: Serialize>(&mut self, key: &str, value: T) -> Result<()> {
let json_value = serde_json::to_value(value)?;
self.context.insert(key.to_string(), json_value);
Ok(())
}
pub fn extend(&mut self, other: serde_json::Map<String, serde_json::Value>) {
self.context.extend(other);
}
pub fn build(self) -> serde_json::Map<String, serde_json::Value> {
self.context
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::BuildConfig;
#[test]
fn test_template_engine_creation() {
let config = BuildConfig::default();
let engine = TemplateEngine::new(&config);
assert!(engine.is_ok());
}
#[test]
fn test_template_context() {
let mut ctx = TemplateContext::new();
ctx.insert("title", "Test Title").unwrap();
ctx.insert("count", 42).unwrap();
let context = ctx.build();
assert_eq!(
context.get("title").and_then(|v| v.as_str()),
Some("Test Title")
);
assert_eq!(context.get("count").and_then(|v| v.as_i64()), Some(42));
}
}