use std::path::Path;
use anyhow::Context;
use tera::Tera;
use crate::domain::usecases::site::templates::{TemplateContext, TemplateEngine};
use crate::infra::driven::site::default_theme;
#[derive(Debug)]
pub struct TeraAdapter {
tera: Tera,
}
impl TeraAdapter {
pub fn from_directory(root: &Path) -> anyhow::Result<Self> {
if !root.is_dir() {
anyhow::bail!("template root not found: {}", root.display());
}
let glob = format!("{}/**/*.html", root.display());
let tera = Tera::new(&glob)
.with_context(|| format!("loading templates from {}", root.display()))?;
Ok(Self { tera })
}
pub fn from_default() -> anyhow::Result<Self> {
let templates = default_theme::enumerate_templates();
Self::from_template_set(&templates)
}
pub fn from_directory_with_default(root: &Path) -> anyhow::Result<Self> {
if !root.is_dir() {
anyhow::bail!("template root not found: {}", root.display());
}
let mut templates = default_theme::enumerate_templates();
templates.extend(read_directory_templates(root)?);
Self::from_template_set(&templates)
}
fn from_template_set(templates: &[(String, String)]) -> anyhow::Result<Self> {
let refs: Vec<(&str, &str)> = templates
.iter()
.map(|(n, b)| (n.as_str(), b.as_str()))
.collect();
let mut tera = Tera::default();
tera.add_raw_templates(refs)
.context("registering templates")?;
Ok(Self { tera })
}
}
fn read_directory_templates(root: &Path) -> anyhow::Result<Vec<(String, String)>> {
let mut out = Vec::new();
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry.with_context(|| format!("walking {}", root.display()))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().is_none_or(|e| e != "html") {
continue;
}
let rel = path
.strip_prefix(root)
.with_context(|| format!("computing relative path of {}", path.display()))?;
let name = rel
.to_str()
.with_context(|| format!("non-UTF8 template path {}", path.display()))?
.to_string();
let body = std::fs::read_to_string(path)
.with_context(|| format!("reading template {}", path.display()))?;
out.push((name, body));
}
Ok(out)
}
impl TemplateEngine for TeraAdapter {
fn render(&self, name: &str, ctx: &TemplateContext) -> anyhow::Result<String> {
let tera_ctx = tera::Context::from_value(ctx.0.clone())
.with_context(|| format!("preparing context for template {name}"))?;
self.tera
.render(name, &tera_ctx)
.with_context(|| format!("rendering template {name}"))
}
}
#[cfg(test)]
mod tests {
use std::fs;
use serde_json::json;
use tempfile::TempDir;
use super::*;
fn write_template(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn renders_a_simple_template_with_context() {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "hello.html", "Hello {{ name }}!");
let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
let ctx = TemplateContext(json!({ "name": "world" }));
assert_eq!(engine.render("hello.html", &ctx).unwrap(), "Hello world!");
}
#[test]
fn registers_templates_under_their_relative_path_name() {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "decisions/adr/single.html", "ADR: {{ title }}");
let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
let ctx = TemplateContext(json!({ "title": "Use Rust" }));
assert_eq!(
engine.render("decisions/adr/single.html", &ctx).unwrap(),
"ADR: Use Rust"
);
}
#[test]
fn missing_template_surfaces_an_error_naming_the_template() {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "exists.html", "ok");
let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
let err = engine
.render("does-not-exist.html", &TemplateContext::new())
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("does-not-exist.html"), "got: {msg}");
}
#[test]
fn parse_error_surfaces_at_load_time() {
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "broken.html", "{% if x %}oops");
let err = TeraAdapter::from_directory(tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("broken.html") || msg.to_lowercase().contains("template"),
"expected parse error to mention the template, got: {msg}"
);
}
#[test]
fn missing_root_directory_surfaces_an_error() {
let err = TeraAdapter::from_directory(Path::new("/tmp/does-not-exist-cartulary-xyz"))
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("template root not found"), "got: {msg}");
}
#[test]
fn from_default_loads_the_embedded_homepage_template() {
let engine = TeraAdapter::from_default().unwrap();
let ctx = TemplateContext(json!({
"site": { "title": "Cartulary" },
"sections": [],
}));
let html = engine.render("layouts/index.html", &ctx).unwrap();
assert!(html.contains("<!doctype html>"), "got: {html}");
assert!(html.contains("Cartulary"), "got: {html}");
}
#[test]
fn from_directory_with_default_overlays_user_template_on_top_of_embedded() {
let tmp = TempDir::new().unwrap();
write_template(
tmp.path(),
"layouts/index.html",
"{% extends \"layouts/base.html\" %}\
{% block content %}USER-OVERRIDE{% endblock %}",
);
let engine = TeraAdapter::from_directory_with_default(tmp.path()).unwrap();
let ctx = TemplateContext(json!({ "site": { "title": "Cartulary" } }));
let html = engine.render("layouts/index.html", &ctx).unwrap();
assert!(html.contains("USER-OVERRIDE"), "got: {html}");
assert!(html.contains("<!doctype html>"), "got: {html}");
}
#[test]
fn from_directory_with_default_keeps_embedded_template_when_user_does_not_override() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("layouts")).ok();
let engine = TeraAdapter::from_directory_with_default(tmp.path()).unwrap();
let ctx = TemplateContext(json!({
"site": { "title": "Cartulary" },
"sections": [],
}));
let html = engine.render("layouts/index.html", &ctx).unwrap();
assert!(html.contains("Cartulary"), "got: {html}");
}
#[test]
fn from_serialize_helper_produces_a_usable_context() {
#[derive(serde::Serialize)]
struct Page<'a> {
title: &'a str,
}
let tmp = TempDir::new().unwrap();
write_template(tmp.path(), "p.html", "T={{ title }}");
let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
let ctx = TemplateContext::from(&Page { title: "X" }).unwrap();
assert_eq!(engine.render("p.html", &ctx).unwrap(), "T=X");
}
}