#[cfg(feature = "tera-templates")]
use anyhow::{Context, Result};
#[cfg(feature = "tera-templates")]
use std::{collections::HashMap, path::PathBuf};
#[cfg(feature = "tera-templates")]
#[derive(Debug, Clone)]
pub struct TeraConfig {
pub template_dir: PathBuf,
pub globals: HashMap<String, serde_json::Value>,
pub autoescape: bool,
}
#[cfg(feature = "tera-templates")]
impl Default for TeraConfig {
fn default() -> Self {
Self {
template_dir: PathBuf::from("templates/tera"),
globals: HashMap::new(),
autoescape: true,
}
}
}
#[cfg(feature = "tera-templates")]
#[derive(Debug)]
pub struct TeraEngine {
tera: tera::Tera,
config: TeraConfig,
}
#[cfg(feature = "tera-templates")]
impl TeraEngine {
pub fn init(config: TeraConfig) -> Result<Option<Self>> {
if !config.template_dir.exists() {
return Ok(None);
}
let glob = config
.template_dir
.join("**/*.html")
.to_string_lossy()
.to_string();
let mut tera = tera::Tera::new(&glob).with_context(|| {
format!(
"Failed to load Tera templates from {:?}",
config.template_dir
)
})?;
tera.register_filter("reading_time", reading_time_filter);
if !config.autoescape {
tera.autoescape_on(vec![]);
}
Ok(Some(Self { tera, config }))
}
pub fn render_page(
&self,
template_name: &str,
page_content: &str,
frontmatter: &HashMap<String, serde_json::Value>,
site_globals: &HashMap<String, serde_json::Value>,
) -> Result<String> {
let mut context = tera::Context::new();
let mut page = HashMap::new();
for (k, v) in frontmatter {
let _ = page.insert(k.clone(), v.clone());
}
let _ = page.insert(
"content".to_string(),
serde_json::Value::String(page_content.to_string()),
);
context.insert("page", &page);
context.insert("site", site_globals);
for (k, v) in &self.config.globals {
context.insert(k, v);
}
let tmpl = if self.tera.get_template(template_name).is_ok() {
template_name.to_string()
} else if self.tera.get_template("page.html").is_ok() {
"page.html".to_string()
} else {
return Ok(page_content.to_string());
};
self.tera
.render(&tmpl, &context)
.with_context(|| format!("Failed to render template '{tmpl}'"))
}
#[must_use]
pub fn site_globals_from_config(
config: &crate::cmd::SsgConfig,
) -> HashMap<String, serde_json::Value> {
let mut globals = HashMap::new();
let _ = globals.insert(
"name".to_string(),
serde_json::Value::String(config.site_name.clone()),
);
let _ = globals.insert(
"title".to_string(),
serde_json::Value::String(config.site_title.clone()),
);
let _ = globals.insert(
"description".to_string(),
serde_json::Value::String(config.site_description.clone()),
);
let _ = globals.insert(
"base_url".to_string(),
serde_json::Value::String(config.base_url.clone()),
);
let _ = globals.insert(
"language".to_string(),
serde_json::Value::String(config.language.clone()),
);
globals
}
#[must_use]
pub fn load_data_files(
content_dir: &std::path::Path,
) -> HashMap<String, serde_json::Value> {
let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
let mut data = HashMap::new();
if !data_dir.exists() {
return data;
}
let entries = match std::fs::read_dir(&data_dir) {
Ok(e) => e,
Err(_) => return data,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let value: Option<serde_json::Value> = match ext.as_str() {
"toml" => toml::from_str::<serde_json::Value>(&content).ok(),
"json" => serde_json::from_str(&content).ok(),
"yml" | "yaml" => {
serde_json::from_str(&content).ok()
}
_ => None,
};
if let Some(val) = value {
let _ = data.insert(stem, val);
}
}
data
}
}
#[cfg(feature = "tera-templates")]
fn reading_time_filter(
value: &tera::Value,
_args: &HashMap<String, tera::Value>,
) -> tera::Result<tera::Value> {
let text = value.as_str().unwrap_or("");
let word_count = text.split_whitespace().count();
let minutes = (word_count / 200).max(1);
Ok(tera::Value::String(format!("{minutes} min read")))
}
#[cfg(all(test, feature = "tera-templates"))]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn setup_templates(dir: &Path) {
crate::test_support::init_logger();
let tera_dir = dir.join("tera");
fs::create_dir_all(&tera_dir).unwrap();
fs::write(
tera_dir.join("base.html"),
r#"<!DOCTYPE html>
<html lang="{{ site.language | default(value="en") }}">
<head><title>{% block title %}{{ page.title | default(value="Untitled") }}{% endblock %}</title>
{% block head_extra %}{% endblock %}
</head>
<body>
<main>{% block content %}{% endblock %}</main>
<footer>{% block footer %}<p>© {{ site.name | default(value="") }}</p>{% endblock %}</footer>
</body>
</html>"#,
)
.unwrap();
fs::write(
tera_dir.join("page.html"),
r#"{% extends "base.html" %}
{% block content %}{{ page.content | safe }}{% endblock %}"#,
)
.unwrap();
fs::write(
tera_dir.join("post.html"),
r#"{% extends "base.html" %}
{% block content %}
<article>
<h1>{{ page.title | default(value="") }}</h1>
<time>{{ page.date | default(value="") }}</time>
<p>{{ page.content | reading_time }}</p>
{{ page.content | safe }}
</article>
{% endblock %}"#,
)
.unwrap();
}
#[test]
fn test_init_missing_dir() {
let config = TeraConfig {
template_dir: PathBuf::from("/nonexistent/path"),
..Default::default()
};
let result = TeraEngine::init(config).unwrap();
assert!(result.is_none());
}
#[test]
fn test_init_and_render_page() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TeraConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let mut fm = HashMap::new();
let _ = fm.insert(
"title".to_string(),
serde_json::Value::String("Hello".to_string()),
);
let mut site = HashMap::new();
let _ = site.insert(
"name".to_string(),
serde_json::Value::String("My Site".to_string()),
);
let _ = site.insert(
"language".to_string(),
serde_json::Value::String("en-GB".to_string()),
);
let result = engine
.render_page("page.html", "<p>Body</p>", &fm, &site)
.unwrap();
assert!(result.contains("Hello"));
assert!(result.contains("<p>Body</p>"));
assert!(result.contains("My Site"));
assert!(result.contains("en-GB"));
}
#[test]
fn test_render_post_with_reading_time() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TeraConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let content = "word ".repeat(600); let mut fm = HashMap::new();
let _ = fm.insert(
"title".to_string(),
serde_json::Value::String("Post".to_string()),
);
let _ = fm.insert(
"date".to_string(),
serde_json::Value::String("2026-01-01".to_string()),
);
let site = HashMap::new();
let result = engine
.render_page("post.html", &content, &fm, &site)
.unwrap();
assert!(result.contains("3 min read"));
assert!(result.contains("<article>"));
}
#[test]
fn test_fallback_to_page_html() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TeraConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let fm = HashMap::new();
let site = HashMap::new();
let result = engine
.render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
.unwrap();
assert!(result.contains("<p>fallback</p>"));
}
#[test]
fn test_reading_time_filter_direct() {
let text = "word ".repeat(400);
let val = tera::Value::String(text);
let result = reading_time_filter(&val, &HashMap::new()).unwrap();
assert_eq!(result, tera::Value::String("2 min read".to_string()));
}
#[test]
fn load_data_files_missing_data_dir_returns_empty_map() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.is_empty());
}
#[test]
fn load_data_files_parses_toml_and_json_and_yaml() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
.unwrap();
fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
fs::write(data.join("ignored.txt"), "not parsed").unwrap();
let sub = data.join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("inside.json"), "{}").unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.contains_key("site"));
assert!(result.contains_key("nav"));
assert!(result.contains_key("conf"));
assert!(!result.contains_key("ignored"));
assert!(!result.contains_key("sub"));
}
#[test]
fn load_data_files_skips_files_with_invalid_content() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
fs::write(data.join("broken.json"), "{not valid").unwrap();
fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.contains_key("good"));
assert!(!result.contains_key("broken"));
}
#[test]
fn load_data_files_ignores_unsupported_extensions() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("a.xml"), "<x/>").unwrap();
fs::write(data.join("b.csv"), "a,b").unwrap();
fs::write(data.join("c"), "no extension").unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.is_empty());
}
#[test]
fn render_page_injects_custom_globals_from_config() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let mut globals = HashMap::new();
let _ = globals.insert(
"brand".to_string(),
serde_json::Value::String("Acme".to_string()),
);
let config = TeraConfig {
template_dir: dir.path().join("tera"),
globals,
..Default::default()
};
let _ = TeraEngine::init(config).unwrap().unwrap();
fs::write(
dir.path().join("tera").join("branded.html"),
r#"<p>{{ brand }}</p>"#,
)
.unwrap();
let config2 = TeraConfig {
template_dir: dir.path().join("tera"),
globals: {
let mut g = HashMap::new();
let _ = g.insert(
"brand".to_string(),
serde_json::Value::String("Acme".to_string()),
);
g
},
..Default::default()
};
let engine = TeraEngine::init(config2).unwrap().unwrap();
let result = engine
.render_page("branded.html", "", &HashMap::new(), &HashMap::new())
.unwrap();
assert!(result.contains("Acme"));
let _ = engine; }
#[test]
fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
{
let dir = tempdir().unwrap();
let tera_dir = dir.path().join("tera");
fs::create_dir_all(&tera_dir).unwrap();
fs::write(
tera_dir.join("base.html"),
r#"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>"#,
)
.unwrap();
let config = TeraConfig {
template_dir: tera_dir,
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let content = "<p>raw content</p>";
let result = engine
.render_page(
"nonexistent.html",
content,
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, content);
}
#[test]
fn init_with_autoescape_false_calls_autoescape_on_with_empty_vec() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TeraConfig {
template_dir: dir.path().join("tera"),
autoescape: false,
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let result = engine
.render_page(
"page.html",
"<p>x</p>",
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert!(result.contains("<p>x</p>"));
}
#[test]
fn init_with_broken_template_propagates_with_context_error() {
let dir = tempdir().unwrap();
let tera_dir = dir.path().join("tera");
fs::create_dir_all(&tera_dir).unwrap();
fs::write(tera_dir.join("broken.html"), "{% block %}").unwrap();
let config = TeraConfig {
template_dir: tera_dir,
..Default::default()
};
let result = TeraEngine::init(config);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("Failed to load Tera templates"));
}
#[test]
#[cfg(unix)]
fn load_data_files_unreadable_file_continues_silently() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(data.join("not-really.toml")).unwrap();
fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.contains_key("real"));
assert!(!result.contains_key("not-really"));
}
#[test]
fn load_data_files_data_dir_is_a_file_returns_empty() {
let dir = tempdir().unwrap();
let content = dir.path().join("content");
fs::create_dir_all(&content).unwrap();
let data = dir.path().join("data");
fs::write(&data, "I am a file, not a directory").unwrap();
let result = TeraEngine::load_data_files(&content);
assert!(result.is_empty());
}
#[test]
fn render_page_propagates_tera_render_errors() {
let dir = tempdir().unwrap();
let tera_dir = dir.path().join("tera");
fs::create_dir_all(&tera_dir).unwrap();
fs::write(
tera_dir.join("broken.html"),
r#"{{ page.title | nonexistent_filter }}"#,
)
.unwrap();
let config = TeraConfig {
template_dir: tera_dir,
..Default::default()
};
let engine = TeraEngine::init(config).unwrap().unwrap();
let mut fm = HashMap::new();
let _ = fm.insert(
"title".to_string(),
serde_json::Value::String("T".to_string()),
);
let result =
engine.render_page("broken.html", "", &fm, &HashMap::new());
assert!(result.is_err());
}
}