#[cfg(feature = "templates")]
use anyhow::{Context, Result};
#[cfg(feature = "templates")]
use std::{collections::HashMap, path::PathBuf};
#[cfg(feature = "templates")]
#[derive(Debug, Clone)]
pub struct TemplateConfig {
pub template_dir: PathBuf,
pub globals: HashMap<String, serde_json::Value>,
pub autoescape: bool,
}
#[cfg(feature = "templates")]
impl Default for TemplateConfig {
fn default() -> Self {
Self {
template_dir: PathBuf::from("templates/tera"),
globals: HashMap::new(),
autoescape: true,
}
}
}
#[cfg(feature = "templates")]
#[derive(Debug)]
pub struct TemplateEngine {
env: minijinja::Environment<'static>,
config: TemplateConfig,
}
#[cfg(feature = "templates")]
impl TemplateEngine {
pub fn init(config: TemplateConfig) -> Result<Option<Self>> {
if !config.template_dir.exists() {
return Ok(None);
}
let mut env = minijinja::Environment::new();
env.set_loader(minijinja::path_loader(&config.template_dir));
if !config.autoescape {
env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
}
env.add_filter("reading_time", reading_time_filter);
env.add_filter("slugify", slugify_filter);
Ok(Some(Self { env, 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 page: serde_json::Map<String, serde_json::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let _ = page.insert(
"content".to_string(),
serde_json::Value::String(page_content.to_string()),
);
let mut ctx = serde_json::Map::new();
let _ = ctx.insert("page".to_string(), serde_json::Value::Object(page));
let _ = ctx.insert(
"site".to_string(),
serde_json::Value::Object(
site_globals
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
),
);
for (k, v) in &self.config.globals {
let _ = ctx.insert(k.clone(), v.clone());
}
let tmpl_name = if self.env.get_template(template_name).is_ok() {
template_name
} else if self.env.get_template("page.html").is_ok() {
"page.html"
} else {
return Ok(page_content.to_string());
};
let tmpl = self.env.get_template(tmpl_name).with_context(|| {
format!("Failed to load template '{tmpl_name}'")
})?;
tmpl.render(serde_json::Value::Object(ctx))
.with_context(|| format!("Failed to render template '{tmpl_name}'"))
}
#[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 Ok(entries) = std::fs::read_dir(&data_dir) else {
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 Ok(content) = std::fs::read_to_string(&path) else {
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 = "templates")]
fn reading_time_filter(value: String) -> String {
let word_count = value.split_whitespace().count();
let minutes = (word_count / 200).max(1);
format!("{minutes} min read")
}
#[cfg(feature = "templates")]
fn slugify_filter(value: String) -> String {
value
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(all(test, feature = "templates"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
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("en") }}">
<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
{% block head_extra %}{% endblock %}
</head>
<body>
<main>{% block content %}{% endblock %}</main>
<footer>{% block footer %}<p>© {{ site.name | default("") }}</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("") }}</h1>
<time>{{ page.date | default("") }}</time>
<p>{{ page.content | reading_time }}</p>
{{ page.content | safe }}
</article>
{% endblock %}"#,
)
.unwrap();
}
#[test]
fn test_init_missing_dir() {
let config = TemplateConfig {
template_dir: PathBuf::from("/nonexistent/path"),
..Default::default()
};
let result = TemplateEngine::init(config).unwrap();
assert!(result.is_none());
}
#[test]
fn test_init_and_render_page() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TemplateConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TemplateEngine::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 = TemplateConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TemplateEngine::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 = TemplateConfig {
template_dir: dir.path().join("tera"),
..Default::default()
};
let engine = TemplateEngine::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 result = reading_time_filter(text);
assert_eq!(result, "2 min read");
}
#[test]
fn test_slugify_filter() {
assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
}
#[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 = TemplateEngine::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 = TemplateEngine::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 = TemplateEngine::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 = TemplateEngine::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());
fs::write(
dir.path().join("tera").join("branded.html"),
r"<p>{{ brand }}</p>",
)
.unwrap();
let config = TemplateConfig {
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 = TemplateEngine::init(config).unwrap().unwrap();
let result = engine
.render_page("branded.html", "", &HashMap::new(), &HashMap::new())
.unwrap();
assert!(result.contains("Acme"));
}
#[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 = TemplateConfig {
template_dir: tera_dir,
..Default::default()
};
let engine = TemplateEngine::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() {
let dir = tempdir().unwrap();
setup_templates(dir.path());
let config = TemplateConfig {
template_dir: dir.path().join("tera"),
autoescape: false,
..Default::default()
};
let engine = TemplateEngine::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_errors_on_render() {
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"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
let config = TemplateConfig {
template_dir: tera_dir,
..Default::default()
};
let engine = TemplateEngine::init(config).unwrap().unwrap();
let result = engine.render_page(
"broken.html",
"",
&HashMap::new(),
&HashMap::new(),
);
assert!(result.is_err());
}
#[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 = TemplateEngine::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 = TemplateEngine::load_data_files(&content);
assert!(result.is_empty());
}
#[test]
fn render_page_propagates_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 = TemplateConfig {
template_dir: tera_dir,
..Default::default()
};
let engine = TemplateEngine::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());
}
}