#[cfg(feature = "templates")]
use crate::{
frontmatter,
plugin::{Plugin, PluginContext},
template_engine::{TemplateConfig, TemplateEngine},
MAX_DIR_DEPTH,
};
#[cfg(feature = "templates")]
use anyhow::Result;
#[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<()> {
let sidecar_dir = ctx.build_dir.join(".meta");
let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)?;
if count > 0 {
log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
}
Ok(())
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
let Some(engine) = TemplateEngine::init(self.config.clone())? 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 mut rendered = 0usize;
for html_path in &html_files {
let content = fs::read_to_string(html_path)?;
let fm = 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)?;
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>> {
crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
}
#[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,
}
}
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,
};
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());
}
}