#[cfg(feature = "templates")]
use crate::{
error::{PathErrorExt, SsgError},
frontmatter,
plugin::{Plugin, PluginContext},
template_engine::{TemplateConfig, TemplateEngine},
MAX_DIR_DEPTH,
};
#[cfg(feature = "templates")]
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
#[cfg(feature = "templates")]
#[derive(Debug)]
pub struct TemplatePlugin {
config: TemplateConfig,
}
#[cfg(feature = "templates")]
impl TemplatePlugin {
#[must_use]
pub const fn new(config: TemplateConfig) -> Self {
Self { config }
}
#[must_use]
pub fn from_template_dir(template_dir: &Path) -> Self {
Self {
config: TemplateConfig {
template_dir: template_dir.join("tera"),
..Default::default()
},
}
}
}
#[cfg(feature = "templates")]
impl Plugin for TemplatePlugin {
fn name(&self) -> &'static str {
"templates"
}
fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
let sidecar_dir = ctx.build_dir.join(".meta");
let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)
.map_err(|e| SsgError::io(e, &sidecar_dir))?;
if count > 0 {
log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
}
Ok(())
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
let Some(engine) = TemplateEngine::init(self.config.clone())
.map_err(|e| SsgError::io(e, &self.config.template_dir))?
else {
log::info!(
"[templates] No templates at {}, skipping",
self.config.template_dir.display()
);
return Ok(());
};
let mut site_globals = ctx
.config
.as_ref()
.map(TemplateEngine::site_globals_from_config)
.unwrap_or_default();
let data_files = TemplateEngine::load_data_files(&ctx.content_dir);
if !data_files.is_empty() {
let _ = site_globals.insert(
"data".to_string(),
serde_json::Value::Object(data_files.into_iter().collect()),
);
}
let sidecar_dir = ctx.build_dir.join(".meta");
let html_files = collect_html_files(&ctx.site_dir)?;
let enriched_fm_map =
enrich_with_related_posts(&html_files, &ctx.site_dir, &sidecar_dir);
let mut rendered = 0usize;
for html_path in &html_files {
let content = fs::read_to_string(html_path).with_path(html_path)?;
let fm =
enriched_fm_map.get(html_path).cloned().unwrap_or_else(|| {
read_frontmatter_for_html(
html_path,
&ctx.site_dir,
&sidecar_dir,
)
});
let layout =
fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
let template_name = format!("{layout}.html");
match engine.render_page(
&template_name,
&content,
&fm,
&site_globals,
) {
Ok(output) => {
fs::write(html_path, output).with_path(html_path)?;
rendered += 1;
}
Err(e) => {
log::warn!(
"[templates] Failed to render {}: {e}",
html_path.display()
);
}
}
}
if rendered > 0 {
log::info!("[templates] Rendered {rendered} page(s)");
}
Ok(())
}
}
#[cfg(feature = "templates")]
fn read_frontmatter_for_html(
html_path: &Path,
site_dir: &Path,
sidecar_dir: &Path,
) -> HashMap<String, serde_json::Value> {
let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
let sidecar = sidecar_dir.join(rel).with_extension("meta.json");
if sidecar.exists() {
if let Ok(content) = fs::read_to_string(&sidecar) {
if let Ok(meta) = serde_json::from_str(&content) {
return meta;
}
}
}
HashMap::new()
}
#[cfg(feature = "templates")]
fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
}
#[cfg(feature = "templates")]
fn enrich_with_related_posts(
html_files: &[PathBuf],
site_dir: &Path,
sidecar_dir: &Path,
) -> HashMap<PathBuf, HashMap<String, serde_json::Value>> {
let mut pages_meta = HashMap::new();
for html_path in html_files {
let fm = read_frontmatter_for_html(html_path, site_dir, sidecar_dir);
let mut terms = std::collections::HashSet::new();
if let Some(tags_val) = fm.get("tags") {
if let Some(arr) = tags_val.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
let _ = terms.insert(s.to_string());
}
}
} else if let Some(s) = tags_val.as_str() {
for part in s.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() {
let _ = terms.insert(trimmed.to_string());
}
}
}
}
if let Some(cats_val) = fm.get("categories") {
if let Some(arr) = cats_val.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
let _ = terms.insert(s.to_string());
}
}
} else if let Some(s) = cats_val.as_str() {
for part in s.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() {
let _ = terms.insert(trimmed.to_string());
}
}
}
}
let title = fm
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
.to_string();
let date = fm
.get("date")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
let _ =
pages_meta.insert(html_path.clone(), (fm, title, terms, date, url));
}
let mut enriched = HashMap::new();
for (html_path, (fm, _title, terms, _date, _url)) in &pages_meta {
let mut candidates = Vec::new();
for (
other_path,
(_other_fm, other_title, other_terms, other_date, other_url),
) in &pages_meta
{
if other_path == html_path {
continue;
}
let overlap = terms.intersection(other_terms).count();
if overlap > 0 {
candidates.push((
overlap,
other_date.clone(),
other_title.clone(),
other_url.clone(),
));
}
}
candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
let top_3: Vec<serde_json::Value> = candidates
.into_iter()
.take(3)
.map(|(_overlap, other_date, other_title, other_url)| {
let mut obj = serde_json::Map::new();
let _ = obj.insert(
"title".to_string(),
serde_json::Value::String(other_title),
);
let _ = obj.insert(
"url".to_string(),
serde_json::Value::String(other_url),
);
let _ = obj.insert(
"date".to_string(),
serde_json::Value::String(other_date),
);
serde_json::Value::Object(obj)
})
.collect();
let mut new_fm = fm.clone();
let _ = new_fm.insert(
"related_posts".to_string(),
serde_json::Value::Array(top_3),
);
let _ = enriched.insert(html_path.clone(), new_fm);
}
enriched
}
#[cfg(all(test, feature = "templates"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::SsgConfig;
use crate::test_support::init_logger;
use std::fs;
use tempfile::{tempdir, TempDir};
fn layout() -> (TempDir, PathBuf, PathBuf, PathBuf, PathBuf) {
init_logger();
let dir = tempdir().expect("tempdir");
let content = dir.path().join("content");
let build = dir.path().join("build");
let site = dir.path().join("site");
let templates = dir.path().join("templates/tera");
for d in [&content, &build, &site, &templates] {
fs::create_dir_all(d).expect("mkdir");
}
(dir, content, build, site, templates)
}
fn make_config(root: &Path) -> SsgConfig {
SsgConfig {
site_name: "Test".to_string(),
site_title: "Test Site".to_string(),
site_description: "Desc".to_string(),
base_url: "http://localhost".to_string(),
language: "en-GB".to_string(),
content_dir: root.join("content"),
output_dir: root.join("build"),
template_dir: root.join("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
}
}
fn setup_project(dir: &Path) {
let content = dir.join("content");
let build = dir.join("build");
let site = dir.join("site");
let templates = dir.join("templates/tera");
fs::create_dir_all(&content).unwrap();
fs::create_dir_all(&build).unwrap();
fs::create_dir_all(&site).unwrap();
fs::create_dir_all(&templates).unwrap();
fs::write(
templates.join("base.html"),
r#"<!DOCTYPE html>
<html><head><title>{{ page.title | default("") }}</title></head>
<body>{% block content %}{% endblock %}</body></html>"#,
)
.unwrap();
fs::write(
templates.join("page.html"),
r#"{% extends "base.html" %}
{% block content %}{{ page.content | safe }}{% endblock %}"#,
)
.unwrap();
fs::write(
content.join("index.md"),
"---\ntitle: Home\nlayout: page\n---\n# Welcome\n",
)
.unwrap();
fs::write(site.join("index.html"), "<h1>Welcome</h1>").unwrap();
let meta_dir = build.join(".meta");
fs::create_dir_all(&meta_dir).unwrap();
fs::write(
meta_dir.join("index.meta.json"),
r#"{"title": "Home", "layout": "page"}"#,
)
.unwrap();
}
#[test]
fn test_template_plugin_renders() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("templates/tera"),
..Default::default()
});
let config = SsgConfig {
site_name: "Test".to_string(),
site_title: "Test Site".to_string(),
site_description: "Desc".to_string(),
base_url: "http://localhost".to_string(),
language: "en-GB".to_string(),
content_dir: dir.path().join("content"),
output_dir: dir.path().join("build"),
template_dir: dir.path().join("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
};
let content_dir = config.content_dir.clone();
let output_dir = config.output_dir.clone();
let template_dir = config.template_dir.clone();
let site = dir.path().join("site");
let ctx = PluginContext::with_config(
&content_dir,
&output_dir,
&site,
&template_dir,
config,
);
plugin.after_compile(&ctx).unwrap();
let output =
fs::read_to_string(dir.path().join("site/index.html")).unwrap();
assert!(output.contains("<!DOCTYPE html>"));
assert!(output.contains("Home"));
assert!(output.contains("<h1>Welcome</h1>"));
}
#[test]
fn test_template_plugin_skips_missing_templates() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("index.html"), "<p>hello</p>").unwrap();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("nonexistent"),
..Default::default()
});
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
plugin.after_compile(&ctx).unwrap();
let output = fs::read_to_string(site.join("index.html")).unwrap();
assert_eq!(output, "<p>hello</p>");
}
#[test]
fn name_returns_templates_identifier() {
let plugin = TemplatePlugin::new(TemplateConfig::default());
assert_eq!(plugin.name(), "templates");
}
#[test]
fn new_stores_supplied_config() {
let cfg = TemplateConfig {
template_dir: std::env::temp_dir().join("ssg_template_fake"),
..Default::default()
};
let plugin = TemplatePlugin::new(cfg.clone());
assert_eq!(plugin.config.template_dir, cfg.template_dir);
}
#[test]
fn from_template_dir_nests_under_tera_subdirectory() {
let plugin =
TemplatePlugin::from_template_dir(Path::new("/my/templates"));
assert!(plugin.config.template_dir.ends_with("templates/tera"));
}
#[test]
fn before_compile_emits_sidecars_from_content_markdown() {
let (_tmp, content, build, _site, templates) = layout();
fs::write(content.join("index.md"), "---\ntitle: Test\n---\nbody")
.unwrap();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: templates,
..Default::default()
});
let ctx = PluginContext::new(&content, &build, &content, &content);
plugin.before_compile(&ctx).unwrap();
assert!(build.join(".meta").join("index.meta.json").exists());
}
#[test]
fn before_compile_no_markdown_files_still_returns_ok() {
let (_tmp, content, build, _site, templates) = layout();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: templates,
..Default::default()
});
let ctx = PluginContext::new(&content, &build, &content, &content);
plugin.before_compile(&ctx).unwrap();
}
#[test]
fn after_compile_without_config_uses_empty_site_globals() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("templates/tera"),
..Default::default()
});
let ctx = PluginContext::new(
&dir.path().join("content"),
&dir.path().join("build"),
&dir.path().join("site"),
&dir.path().join("templates"),
);
plugin.after_compile(&ctx).unwrap();
let output =
fs::read_to_string(dir.path().join("site").join("index.html"))
.unwrap();
assert!(output.contains("<!DOCTYPE html>"));
}
#[test]
fn after_compile_loads_data_files_into_context() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let data = dir.path().join("data");
fs::create_dir_all(&data).unwrap();
fs::write(data.join("nav.toml"), r#"site = "demo""#).unwrap();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("templates/tera"),
..Default::default()
});
let config = make_config(dir.path());
let ctx = PluginContext::with_config(
&config.content_dir.clone(),
&config.output_dir.clone(),
&dir.path().join("site"),
&config.template_dir.clone(),
config,
);
plugin.after_compile(&ctx).unwrap();
let output =
fs::read_to_string(dir.path().join("site").join("index.html"))
.unwrap();
assert!(output.contains("<!DOCTYPE html>"));
}
#[test]
fn after_compile_unknown_layout_does_not_propagate_error() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let meta_dir = dir.path().join("build").join(".meta");
fs::write(
meta_dir.join("index.meta.json"),
r#"{"title": "Home", "layout": "unknown_layout_999"}"#,
)
.unwrap();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("templates/tera"),
..Default::default()
});
let ctx = PluginContext::new(
&dir.path().join("content"),
&dir.path().join("build"),
&dir.path().join("site"),
&dir.path().join("templates"),
);
plugin
.after_compile(&ctx)
.expect("render failure must not propagate");
}
#[test]
fn after_compile_default_layout_is_page_when_missing_field() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let meta_dir = dir.path().join("build").join(".meta");
fs::write(meta_dir.join("index.meta.json"), r#"{"title": "Home"}"#)
.unwrap();
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: dir.path().join("templates/tera"),
..Default::default()
});
let ctx = PluginContext::new(
&dir.path().join("content"),
&dir.path().join("build"),
&dir.path().join("site"),
&dir.path().join("templates"),
);
plugin.after_compile(&ctx).unwrap();
let out =
fs::read_to_string(dir.path().join("site").join("index.html"))
.unwrap();
assert!(out.contains("<!DOCTYPE html>"));
}
#[test]
fn read_frontmatter_for_html_direct_sidecar_match() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let sidecars = dir.path().join(".meta");
fs::create_dir_all(&site).unwrap();
fs::create_dir_all(&sidecars).unwrap();
let html = site.join("post.html");
fs::write(&html, "").unwrap();
fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
.unwrap();
let meta = read_frontmatter_for_html(&html, &site, &sidecars);
assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Direct"));
}
#[test]
fn read_frontmatter_for_html_invalid_sidecar_returns_empty() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let sidecars = dir.path().join(".meta");
fs::create_dir_all(&site).unwrap();
fs::create_dir_all(&sidecars).unwrap();
let html = site.join("post.html");
fs::write(&html, "").unwrap();
fs::write(sidecars.join("post.meta.json"), "{not valid").unwrap();
let meta = read_frontmatter_for_html(&html, &site, &sidecars);
assert!(meta.is_empty());
}
#[test]
fn read_frontmatter_for_html_no_match_returns_empty_map() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let sidecars = dir.path().join(".meta");
fs::create_dir_all(&site).unwrap();
fs::create_dir_all(&sidecars).unwrap();
let html = site.join("ghost.html");
fs::write(&html, "").unwrap();
let meta = read_frontmatter_for_html(&html, &site, &sidecars);
assert!(meta.is_empty());
}
#[test]
fn collect_html_files_filters_non_html_extensions() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.html"), "").unwrap();
fs::write(dir.path().join("b.css"), "").unwrap();
fs::write(dir.path().join("c.js"), "").unwrap();
let files = collect_html_files(dir.path()).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn collect_html_files_recurses_into_subdirectories() {
let dir = tempdir().unwrap();
let nested = dir.path().join("blog").join("2026");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("index.html"), "").unwrap();
fs::write(nested.join("post.html"), "").unwrap();
let files = collect_html_files(dir.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn collect_html_files_returns_empty_for_missing_directory() {
let dir = tempdir().unwrap();
let result = collect_html_files(&dir.path().join("missing")).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_template_plugin_from_template_dir() {
let p = TemplatePlugin::from_template_dir(Path::new("t"));
assert_eq!(p.name(), "templates");
}
#[test]
fn test_template_plugin_before_compile_error() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
fs::write(content_dir.join("index.md"), "---\ntitle: test\n---\n")
.unwrap();
let build_file = dir.path().join("build_file");
fs::write(&build_file, "").unwrap();
let ctx = PluginContext::new(
&content_dir,
&build_file,
dir.path(),
dir.path(),
);
let res =
TemplatePlugin::new(TemplateConfig::default()).before_compile(&ctx);
assert!(res.is_err());
}
#[test]
#[cfg(unix)]
fn test_template_plugin_after_compile_read_html_error() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
fs::create_dir_all(&site_dir).unwrap();
let html_path = site_dir.join("test.html");
fs::write(&html_path, "test").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&html_path, fs::Permissions::from_mode(0o000))
.unwrap();
}
let templates = dir.path().join("tera");
fs::create_dir_all(&templates).unwrap();
fs::write(templates.join("base.html"), "").unwrap();
let ctx =
PluginContext::new(dir.path(), dir.path(), &site_dir, &templates);
let plugin = TemplatePlugin::new(TemplateConfig {
template_dir: templates,
..Default::default()
});
let res = plugin.after_compile(&ctx);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(
&html_path,
fs::Permissions::from_mode(0o644),
);
}
#[cfg(unix)]
assert!(res.is_err());
}
#[test]
fn test_enrich_with_related_posts_direct() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let sidecars = dir.path().join(".meta");
fs::create_dir_all(&site).unwrap();
fs::create_dir_all(&sidecars).unwrap();
let html1 = site.join("p1.html");
let html2 = site.join("p2.html");
let html3 = site.join("p3.html");
fs::write(&html1, "").unwrap();
fs::write(&html2, "").unwrap();
fs::write(&html3, "").unwrap();
fs::write(sidecars.join("p1.meta.json"), r#"{"title": "P1", "date": "2026-06-01", "tags": ["rust", "web"], "categories": "coding,tech"}"#).unwrap();
fs::write(sidecars.join("p2.meta.json"), r#"{"title": "P2", "date": "2026-06-02", "tags": "rust,systems", "categories": ["coding"]}"#).unwrap();
fs::write(
sidecars.join("p3.meta.json"),
r#"{"title": "P3", "date": "2026-06-03", "tags": ["other"]}"#,
)
.unwrap();
let files = vec![html1.clone(), html2.clone(), html3.clone()];
let enriched = enrich_with_related_posts(&files, &site, &sidecars);
let p1_fm = enriched.get(&html1).unwrap();
let related_p1 =
p1_fm.get("related_posts").unwrap().as_array().unwrap();
assert_eq!(related_p1.len(), 1);
assert_eq!(related_p1[0]["title"], "P2");
assert_eq!(related_p1[0]["url"], "/p2.html");
assert_eq!(related_p1[0]["date"], "2026-06-02");
let p3_fm = enriched.get(&html3).unwrap();
let related_p3 =
p3_fm.get("related_posts").unwrap().as_array().unwrap();
assert!(related_p3.is_empty());
}
}