use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use crate::errors::TemplateError;
use crate::markdown::SimpleMetadata;
use parking_lot::RwLock;
use tera::{Context, Tera};
#[derive(Clone)]
pub struct Templates {
tera: Arc<RwLock<Tera>>,
template_path: PathBuf,
}
fn contains_html_file(dir: &Path) -> bool {
walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(Result::ok)
.any(|entry| is_html_file(&entry))
}
fn is_html_file(entry: &walkdir::DirEntry) -> bool {
entry.file_type().is_file()
&& entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
}
pub fn escape_json_for_script(json: &str) -> String {
json.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026")
}
fn json_for_script(value: &serde_json::Value) -> String {
escape_json_for_script(&serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
}
fn nav_page_json(page: Option<&serde_json::Value>) -> String {
match page {
Some(page) => json_for_script(&serde_json::json!({
"url": page.get("url").and_then(|v| v.as_str()).unwrap_or_default(),
"title": page.get("title").and_then(|v| v.as_str()).unwrap_or_default(),
})),
None => "null".to_string(),
}
}
fn collect_user_templates(dir: &Path) -> Vec<(String, String)> {
let mut templates: Vec<(String, String)> = walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(Result::ok)
.filter(is_html_file)
.filter_map(|entry| {
let name = entry
.path()
.strip_prefix(dir)
.ok()?
.to_string_lossy()
.replace('\\', "/");
let contents = std::fs::read_to_string(entry.path())
.inspect_err(|e| {
tracing::warn!(
"Skipping unreadable user template {}: {}",
entry.path().display(),
e
)
})
.ok()?;
Some((name, contents))
})
.collect();
templates.sort_by(|(a, _), (b, _)| a.cmp(b));
templates
}
fn load_tera_per_file(template_path: &Path) -> Tera {
let mut tera = Tera::default();
let mut pending = collect_user_templates(template_path);
while !pending.is_empty() {
let attempted = pending.len();
let mut failed: Vec<(String, String, tera::Error)> = Vec::new();
for (name, contents) in std::mem::take(&mut pending) {
let mut candidate = tera.clone();
match candidate.add_raw_template(&name, &contents) {
Ok(()) => tera = candidate,
Err(e) => failed.push((name, contents, e)),
}
}
if failed.len() == attempted {
for (name, _, e) in failed {
tracing::warn!(
"Skipping malformed user template {}: {}. Using the built-in default instead.",
name,
e
);
}
break;
}
pending = failed.into_iter().map(|(n, c, _)| (n, c)).collect();
}
tera
}
impl Templates {
pub fn new(root_path: &Path, template_folder: Option<&Path>) -> Result<Self, TemplateError> {
let template_path = if let Some(tf) = template_folder {
tf.to_path_buf()
} else {
root_path.join(".mbr")
};
let tera = Self::load_tera(&template_path)?;
Ok(Templates {
tera: Arc::new(RwLock::new(tera)),
template_path,
})
}
fn load_tera(template_path: &Path) -> Result<Tera, TemplateError> {
let source_desc = format!("{}", template_path.display());
let template_path_str = template_path
.to_str()
.ok_or(TemplateError::InvalidPathEncoding)?;
#[cfg(windows)]
let glob_base = template_path_str.replace('\\', "/");
#[cfg(not(windows))]
let glob_base = template_path_str.to_string();
let globs = format!("{}/**/*.html", glob_base.trim_end_matches('/'));
let mut tera = Tera::new(&globs).unwrap_or_else(|e| {
tracing::warn!(
"Failed to load user templates from {} as a set: {}. Retrying per file so one \
malformed template does not discard the others.",
source_desc,
e
);
load_tera_per_file(template_path)
});
if tera.get_template_names().next().is_none() && contains_html_file(template_path) {
tracing::warn!(
"No templates matched glob {} even though {} contains .html files. \
Using built-in defaults; user template overrides will be ignored.",
globs,
source_desc
);
}
tera.register_filter("humandate", humandate_filter);
for (name, tpl) in DEFAULT_TEMPLATES.iter() {
if tera.get_template(name).is_err() {
tracing::debug!("Adding default template {}", name);
tera.add_raw_template(name, tpl)
.map_err(|e| TemplateError::RenderFailed {
template_name: name.to_string(),
source: e,
})?;
}
}
Ok(tera)
}
pub fn reload(&self) -> Result<(), TemplateError> {
tracing::info!("Reloading templates from {:?}", self.template_path);
let new_tera = Self::load_tera(&self.template_path)?;
*self.tera.write() = new_tera;
tracing::debug!("Templates reloaded successfully");
Ok(())
}
pub fn tera_clone(&self) -> Tera {
self.tera.read().clone()
}
pub fn render_markdown(
&self,
html: &str,
frontmatter: SimpleMetadata,
extra_context: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_markdown_with_tera(&tera, html, frontmatter, extra_context)
}
pub fn render_markdown_with_tera(
tera: &Tera,
html: &str,
frontmatter: SimpleMetadata,
extra_context: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
tracing::debug!("frontmatter: {:?}", &frontmatter);
let frontmatter_json = escape_json_for_script(
&serde_json::to_string(&frontmatter).unwrap_or_else(|_| "{}".to_string()),
);
let headings_json = extra_context
.get("headings")
.map(json_for_script)
.unwrap_or_else(|| "[]".to_string());
let file_path_json = extra_context
.get("file_path")
.map(json_for_script)
.unwrap_or_else(|| "\"\"".to_string());
let prev_page_json = nav_page_json(extra_context.get("prev_page"));
let next_page_json = nav_page_json(extra_context.get("next_page"));
let mut context = Context::new();
frontmatter.iter().for_each(|(k, v)| {
if k == "style" {
let normalized = normalize_style_value(v);
context.insert(k, &normalized);
} else {
context.insert(k, v);
}
});
extra_context.iter().for_each(|(k, v)| {
context.insert(k, v);
});
context.insert("markdown", html);
context.insert("frontmatter_json", &frontmatter_json);
context.insert("headings_json", &headings_json);
context.insert("file_path_json", &file_path_json);
context.insert("prev_page_json", &prev_page_json);
context.insert("next_page_json", &next_page_json);
let html_output =
tera.render("index.html", &context)
.map_err(|e| TemplateError::RenderFailed {
template_name: "index.html".to_string(),
source: e,
})?;
Ok(html_output)
}
pub fn render_template_with_tera(
tera: &Tera,
template_name: &str,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let mut context = Context::new();
context_data.iter().for_each(|(k, v)| {
context.insert(k, v);
});
tera.render(template_name, &context)
.map_err(|e| TemplateError::RenderFailed {
template_name: template_name.to_string(),
source: e,
})
}
pub fn render_section(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "section.html", context_data)
}
pub fn render_home(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "home.html", context_data)
}
pub fn render_error(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "error.html", context_data)
}
pub fn render_tag(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "tag.html", context_data)
}
pub fn render_tag_index(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "tag_index.html", context_data)
}
pub fn render_media_viewer(
&self,
context_data: HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let tera = self.tera.read();
Self::render_template_with_tera(&tera, "media_viewer.html", context_data)
}
}
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
fn humandate_filter(
value: &serde_json::Value,
_args: &HashMap<String, serde_json::Value>,
) -> tera::Result<serde_json::Value> {
match value {
serde_json::Value::String(s) => Ok(serde_json::Value::String(humanize_date(s))),
other => Ok(other.clone()),
}
}
fn is_all_ascii_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn parse_year4(s: &str) -> Option<u32> {
if s.len() == 4 && is_all_ascii_digits(s) {
s.parse().ok()
} else {
None
}
}
fn parse_month2(s: &str) -> Option<u32> {
if s.len() == 2 && is_all_ascii_digits(s) {
let m: u32 = s.parse().ok()?;
(1..=12).contains(&m).then_some(m)
} else {
None
}
}
fn parse_day2(s: &str) -> Option<u32> {
if s.len() == 2 && is_all_ascii_digits(s) {
let d: u32 = s.parse().ok()?;
(1..=31).contains(&d).then_some(d)
} else {
None
}
}
fn humanize_date(input: &str) -> String {
let parts: Vec<&str> = input.split('-').collect();
match parts.as_slice() {
[y, m, d] => {
if let (Some(year), Some(month), Some(day)) =
(parse_year4(y), parse_month2(m), parse_day2(d))
{
return format!("{} {}, {}", MONTH_NAMES[(month - 1) as usize], day, year);
}
}
[y, m] => {
if let (Some(year), Some(month)) = (parse_year4(y), parse_month2(m)) {
return format!("{} {}", MONTH_NAMES[(month - 1) as usize], year);
}
}
_ => {}
}
input.to_string()
}
fn normalize_style_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" "),
other => other.to_string(),
}
}
const DEFAULT_TEMPLATES: &[(&str, &str)] = &[
("_head.html", include_str!("../templates/_head.html")),
(
"_head_custom.html",
include_str!("../templates/_head_custom.html"),
),
(
"_head_markdown.html",
include_str!("../templates/_head_markdown.html"),
),
("_nav.html", include_str!("../templates/_nav.html")),
(
"_breadcrumbs.html",
include_str!("../templates/_breadcrumbs.html"),
),
("_footer.html", include_str!("../templates/_footer.html")),
(
"_footer_custom.html",
include_str!("../templates/_footer_custom.html"),
),
("_scripts.html", include_str!("../templates/_scripts.html")),
(
"_display_enhancements.html",
include_str!("../templates/_display_enhancements.html"),
),
(
"_person_infobox.html",
include_str!("../templates/_person_infobox.html"),
),
("index.html", include_str!("../templates/index.html")),
("section.html", include_str!("../templates/section.html")),
("home.html", include_str!("../templates/home.html")),
("error.html", include_str!("../templates/error.html")),
("tag.html", include_str!("../templates/tag.html")),
(
"tag_index.html",
include_str!("../templates/tag_index.html"),
),
(
"media_viewer.html",
include_str!("../templates/media_viewer.html"),
),
];
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_user_templates_are_loaded_from_mbr_folder() {
let tmp = tempfile::tempdir().unwrap();
let mbr_dir = tmp.path().join(".mbr");
std::fs::create_dir_all(mbr_dir.join("partials")).unwrap();
std::fs::write(mbr_dir.join("zz_custom_page.html"), "<p>custom</p>").unwrap();
std::fs::write(
mbr_dir.join("partials").join("zz_nested_page.html"),
"<p>nested</p>",
)
.unwrap();
let templates = Templates::new(tmp.path(), None).expect("templates should build");
let tera = templates.tera.read();
let names: Vec<String> = tera.get_template_names().map(String::from).collect();
assert!(
names.iter().any(|n| n == "zz_custom_page.html"),
"top-level user template should be loaded, got: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("zz_nested_page.html")),
"nested user template should be loaded via `**`, got: {names:?}"
);
}
#[test]
fn test_templates_fall_back_to_defaults_without_user_html() {
let tmp = tempfile::tempdir().unwrap();
let mbr_dir = tmp.path().join(".mbr");
std::fs::create_dir_all(&mbr_dir).unwrap();
std::fs::write(mbr_dir.join("config.toml"), "theme = \"amber\"").unwrap();
let templates = Templates::new(tmp.path(), None).expect("templates should build");
let tera = templates.tera.read();
assert!(
tera.get_template_names().any(|n| n == "index.html"),
"built-in defaults should still be registered"
);
}
#[test]
fn test_normalize_style_string() {
let value = json!("slides");
assert_eq!(normalize_style_value(&value), "slides");
}
#[test]
fn test_normalize_style_string_with_spaces() {
let value = json!("slides other");
assert_eq!(normalize_style_value(&value), "slides other");
}
#[test]
fn test_normalize_style_array() {
let value = json!(["slides", "other"]);
assert_eq!(normalize_style_value(&value), "slides other");
}
#[test]
fn test_normalize_style_array_single_element() {
let value = json!(["slides"]);
assert_eq!(normalize_style_value(&value), "slides");
}
#[test]
fn test_normalize_style_array_empty() {
let value = json!([]);
assert_eq!(normalize_style_value(&value), "");
}
#[test]
fn test_normalize_style_null() {
let value = json!(null);
assert_eq!(normalize_style_value(&value), "null");
}
#[test]
fn test_normalize_style_number() {
let value = json!(42);
assert_eq!(normalize_style_value(&value), "42");
}
#[test]
fn test_humanize_date_full() {
assert_eq!(humanize_date("1855-10-30"), "October 30, 1855");
assert_eq!(humanize_date("1902-01-10"), "January 10, 1902");
}
#[test]
fn test_humanize_date_full_strips_leading_zero_day() {
assert_eq!(humanize_date("1855-10-05"), "October 5, 1855");
assert_eq!(humanize_date("2000-12-01"), "December 1, 2000");
}
#[test]
fn test_humanize_date_year_month() {
assert_eq!(humanize_date("1855-10"), "October 1855");
assert_eq!(humanize_date("1902-01"), "January 1902");
}
#[test]
fn test_humanize_date_year_only_unchanged() {
assert_eq!(humanize_date("1855"), "1855");
}
#[test]
fn test_humanize_date_invalid_passthrough() {
assert_eq!(humanize_date("2020-13-40"), "2020-13-40");
assert_eq!(humanize_date("2020-00-10"), "2020-00-10");
assert_eq!(humanize_date("2020-02-32"), "2020-02-32");
assert_eq!(humanize_date("circa 1855"), "circa 1855");
assert_eq!(humanize_date("October 30, 1855"), "October 30, 1855");
assert_eq!(humanize_date("1855-1-1"), "1855-1-1"); assert_eq!(humanize_date("55-10-30"), "55-10-30"); assert_eq!(humanize_date(""), "");
}
#[test]
fn test_humandate_filter_string_and_passthrough() {
let args = HashMap::new();
assert_eq!(
humandate_filter(&json!("1855-10-30"), &args).unwrap(),
json!("October 30, 1855")
);
assert_eq!(humandate_filter(&json!(1855), &args).unwrap(), json!(1855));
assert_eq!(humandate_filter(&json!(null), &args).unwrap(), json!(null));
}
#[test]
fn test_humandate_filter_registered_in_tera() {
let mut tera = Tera::default();
tera.register_filter("humandate", humandate_filter);
tera.add_raw_template("t", "{{ born | humandate }}")
.unwrap();
let mut ctx = Context::new();
ctx.insert("born", "1855-10-30");
assert_eq!(tera.render("t", &ctx).unwrap(), "October 30, 1855");
}
fn render_page(
frontmatter: SimpleMetadata,
extra_context: HashMap<String, serde_json::Value>,
) -> String {
let mut context = HashMap::from([("sidebar_style".to_string(), json!("auto"))]);
context.extend(extra_context);
let tmp = tempfile::tempdir().unwrap();
let templates = Templates::new(tmp.path(), None).expect("templates should build");
templates
.render_markdown("<p>body</p>", frontmatter, context)
.expect("page should render")
}
fn js_value<'a>(html: &'a str, prefix: &str) -> &'a str {
html.lines()
.find_map(|line| line.trim().strip_prefix(prefix))
.unwrap_or_else(|| panic!("no line starting with {prefix:?} in:\n{html}"))
.trim_end()
.trim_end_matches([',', ';'])
}
fn parse_js_value(html: &str, prefix: &str) -> serde_json::Value {
let raw = js_value(html, prefix);
serde_json::from_str(raw)
.unwrap_or_else(|e| panic!("{prefix:?} payload is not parseable ({e}): {raw}"))
}
#[test]
fn test_escape_json_for_script_neutralizes_markup_characters() {
let escaped = escape_json_for_script(r#"{"title":"a </script> & <b>"}"#);
assert!(
!escaped.contains("</script>"),
"script terminator must not survive: {escaped}"
);
assert!(
escaped.contains("\\u003c"),
"`<` must be escaped: {escaped}"
);
assert!(
escaped.contains("\\u003e"),
"`>` must be escaped: {escaped}"
);
assert!(
escaped.contains("\\u0026"),
"`&` must be escaped: {escaped}"
);
}
#[test]
fn test_escape_json_for_script_preserves_decoded_value() {
let original = json!({"title": "a </script> & <b> \\ \" done"});
let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
assert_eq!(round_tripped, original);
}
proptest::proptest! {
#[test]
fn prop_escape_json_for_script_is_inert_and_lossless(
key in "\\PC*",
value in "\\PC*",
) {
let original = json!({ key: value });
let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
proptest::prop_assert!(
!escaped.contains(['<', '>', '&']),
"escaped payload must contain no markup characters: {escaped}"
);
let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
proptest::prop_assert_eq!(round_tripped, original);
}
}
#[test]
fn test_frontmatter_json_cannot_break_out_of_script_block() {
let frontmatter = SimpleMetadata::from([
(
"title".to_string(),
json!("Avoiding </script> in templates"),
),
("note".to_string(), json!("<img src=x onerror=alert(1)>")),
]);
let html = render_page(frontmatter, HashMap::new());
let payload = js_value(&html, "window.frontmatter = ");
assert!(
!payload.contains("</script>"),
"frontmatter payload must not contain a raw script terminator: {payload}"
);
assert!(
payload.contains("\\u003c/script\\u003e"),
"expected the escaped terminator in: {payload}"
);
assert!(
!payload.contains("<img"),
"raw markup must not survive into the script: {payload}"
);
let parsed = parse_js_value(&html, "window.frontmatter = ");
assert_eq!(parsed["title"], json!("Avoiding </script> in templates"));
assert_eq!(parsed["note"], json!("<img src=x onerror=alert(1)>"));
}
#[test]
fn test_title_is_html_escaped_exactly_once() {
let html = render_page(
SimpleMetadata::from([("title".to_string(), json!("Tips & Tricks"))]),
HashMap::new(),
);
assert!(
html.contains("<title>Tips & Tricks</title>"),
"title should be escaped exactly once, got:\n{html}"
);
assert!(
!html.contains("&amp;"),
"no value should be double-escaped, got:\n{html}"
);
}
#[test]
fn test_title_markup_is_not_emitted_live() {
let html = render_page(
SimpleMetadata::from([(
"title".to_string(),
json!("</title><img src=x onerror=alert(1)>"),
)]),
HashMap::new(),
);
assert!(
!html.contains("<img src=x"),
"a frontmatter title must never produce live markup, got:\n{html}"
);
assert!(
html.contains("<img src=x onerror=alert(1)>"),
"title should render as escaped text, got:\n{html}"
);
}
#[test]
fn test_file_path_and_sibling_urls_are_json_serialized() {
let file_path = r#"docs/He said "hi" \ again.md"#;
let extra = HashMap::from([
("file_path".to_string(), json!(file_path)),
(
"prev_page".to_string(),
json!({"url": r#"/a"b\c/"#, "title": r#"A "quoted" & <angled> title"#}),
),
(
"next_page".to_string(),
json!({"url": "/plain/", "title": "Plain"}),
),
]);
let html = render_page(SimpleMetadata::new(), extra);
assert_eq!(parse_js_value(&html, "filePath: "), json!(file_path));
let prev = parse_js_value(&html, "prevPage: ");
assert_eq!(prev["url"], json!(r#"/a"b\c/"#));
assert_eq!(prev["title"], json!(r#"A "quoted" & <angled> title"#));
let next = parse_js_value(&html, "nextPage: ");
assert_eq!(next["url"], json!("/plain/"));
assert_eq!(next["title"], json!("Plain"));
}
#[test]
fn test_headings_are_json_serialized() {
let extra = HashMap::from([(
"headings".to_string(),
json!([
{"level": 2, "id": "q-and-a", "text": r#"Q & A: "the ' quote" </script>"#},
{"level": 3, "id": "plain", "text": "Plain"},
]),
)]);
let html = render_page(SimpleMetadata::new(), extra);
let headings = parse_js_value(&html, "window.headings = ");
assert_eq!(headings[0]["level"], json!(2));
assert_eq!(headings[0]["id"], json!("q-and-a"));
assert_eq!(
headings[0]["text"],
json!(r#"Q & A: "the ' quote" </script>"#),
"heading text must round-trip verbatim, not HTML-entity encoded"
);
assert_eq!(headings[1]["text"], json!("Plain"));
assert!(
!js_value(&html, "window.headings = ").contains("</script>"),
"heading text must not terminate the script block"
);
}
#[test]
fn test_script_payload_defaults_without_extra_context() {
let html = render_page(SimpleMetadata::new(), HashMap::new());
assert_eq!(parse_js_value(&html, "window.headings = "), json!([]));
assert_eq!(parse_js_value(&html, "filePath: "), json!(""));
assert!(!html.contains("prevPage:"));
assert!(!html.contains("nextPage:"));
assert!(html.contains("fleschReadingEase: null"));
assert!(html.contains("wordCount: 0"));
}
#[test]
fn test_malformed_user_template_falls_back_to_defaults() {
let tmp = tempfile::tempdir().unwrap();
let mbr_dir = tmp.path().join(".mbr");
std::fs::create_dir_all(&mbr_dir).unwrap();
std::fs::write(mbr_dir.join("_nav.html"), "<nav>ZZ_CUSTOM_NAV</nav>").unwrap();
std::fs::write(
mbr_dir.join("index.html"),
"{% if broken %}<p>ZZ_BROKEN</p>",
)
.unwrap();
let templates = Templates::new(tmp.path(), None)
.expect("a malformed user template must not fail construction");
let html = templates
.render_markdown(
"<p>body</p>",
SimpleMetadata::new(),
HashMap::from([("sidebar_style".to_string(), json!("auto"))]),
)
.expect("index.html should render from the built-in default");
assert!(
html.contains("ZZ_CUSTOM_NAV"),
"the valid override must survive its malformed sibling, got:\n{html}"
);
assert!(
html.contains("<mbr-nav></mbr-nav>"),
"the built-in index.html should be used for the malformed override, got:\n{html}"
);
assert!(
!html.contains("ZZ_BROKEN"),
"the malformed template must not be loaded, got:\n{html}"
);
}
#[test]
fn test_per_file_loading_resolves_inheritance_regardless_of_order() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("child.html"), "{% extends \"zz_base.html\" %}").unwrap();
std::fs::write(dir.join("zz_base.html"), "ZZ_BASE_OUTPUT").unwrap();
std::fs::write(dir.join("busted.html"), "{% if never_closed %}oops").unwrap();
let tera = load_tera_per_file(dir);
assert_eq!(
tera.render("child.html", &Context::new()).unwrap(),
"ZZ_BASE_OUTPUT"
);
assert!(
tera.get_template("busted.html").is_err(),
"the malformed template should be skipped"
);
}
}