use std::collections::HashMap;
use crate::config::Config;
use crate::content::{self, Page, Section};
#[derive(Debug, Clone, serde::Serialize)]
pub struct TaxonomyTerm {
pub name: String,
pub slug: String,
pub permalink: String,
pub pages: Vec<Page>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Paginator {
pub pages: Vec<Page>,
pub current_index: usize,
pub number_pagers: usize,
pub previous: Option<String>,
pub next: Option<String>,
pub first: String,
pub last: String,
}
pub fn setup_tera(
templates_dir: &std::path::Path,
config: &Config,
sections: &HashMap<String, Section>,
) -> anyhow::Result<tera::Tera> {
let mut tera = tera::Tera::default();
if let Some(ref theme_name) = config.theme {
let theme = crate::themes::Theme::from_name(theme_name).ok_or_else(|| {
anyhow::anyhow!(
"Unknown theme '{}', available: {}",
theme_name,
crate::themes::Theme::available().join(", ")
)
})?;
tera.add_raw_templates(theme.templates())?;
}
if templates_dir.exists() {
let templates_glob = format!("{}/**/*.html", templates_dir.display());
match tera::Tera::parse(&templates_glob) {
Ok(local) => {
let mut overrides = Vec::new();
for name in local.templates.keys() {
let path = templates_dir.join(name);
if path.exists() {
let content = std::fs::read_to_string(&path)?;
overrides.push((name.clone(), content));
}
}
if !overrides.is_empty() {
let raw: Vec<(&str, &str)> = overrides
.iter()
.map(|(n, c)| (n.as_str(), c.as_str()))
.collect();
tera.add_raw_templates(raw)?;
}
}
Err(e) => {
let msg = e.to_string();
if !msg.contains("No files matched") {
return Err(anyhow::anyhow!(
"failed to parse templates in {}: {e}",
templates_dir.display()
));
}
}
}
}
register_functions(&mut tera, config, sections);
register_filters(&mut tera);
register_tests(&mut tera);
Ok(tera)
}
fn register_functions(tera: &mut tera::Tera, config: &Config, sections: &HashMap<String, Section>) {
let base_url = config.base_url.clone();
tera.register_function(
"get_url",
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("get_url requires a 'path' argument"))?;
if let Some(content_path) = path.strip_prefix("@/") {
let url = if content_path.ends_with("_index.md") {
let section_path =
content::section_url_path(&content::parent_dir(content_path));
format!("{base_url}{section_path}")
} else {
let stem = std::path::Path::new(content_path)
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let slug = slug::slugify(stem.as_ref());
let page_path =
content::page_url_path(&content::parent_dir(content_path), &slug);
format!("{base_url}{page_path}")
};
Ok(tera::Value::String(url))
} else {
if path.starts_with("http://") || path.starts_with("https://") {
Ok(tera::Value::String(path.to_string()))
} else {
let url = format!("{}/{}", base_url, path.trim_start_matches('/'));
Ok(tera::Value::String(url))
}
}
},
);
let sections_clone = sections.clone();
tera.register_function(
"get_section",
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("get_section requires a 'path' argument"))?;
if let Some(section) = sections_clone.get(path) {
let val = serde_json::to_value(section)
.map_err(|e| tera::Error::msg(format!("Serialization error: {e}")))?;
Ok(val)
} else {
Err(tera::Error::msg(format!("Section not found: {path}")))
}
},
);
let base_url2 = config.base_url.clone();
tera.register_function(
"get_taxonomy_url",
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let kind = args
.get("kind")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("get_taxonomy_url requires 'kind'"))?;
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("get_taxonomy_url requires 'name'"))?;
let slug = slug::slugify(name);
let url = format!("{}/{kind}/{slug}/", base_url2);
Ok(tera::Value::String(url))
},
);
tera.register_function(
"now",
|_args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
Ok(tera::Value::String(now))
},
);
}
fn register_filters(tera: &mut tera::Tera) {
tera.register_filter(
"pluralize",
|value: &tera::Value, _args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let n = value
.as_u64()
.or_else(|| value.as_i64().map(|i| i as u64))
.or_else(|| value.as_f64().map(|f| f as u64))
.unwrap_or(0);
if n == 1 {
Ok(tera::Value::String(String::new()))
} else {
Ok(tera::Value::String("s".to_string()))
}
},
);
tera.register_filter(
"slice",
|value: &tera::Value, args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let arr = value
.as_array()
.ok_or_else(|| tera::Error::msg("slice filter requires an array"))?;
let start = args.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let end = args
.get("end")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.unwrap_or(arr.len());
let end = end.min(arr.len());
let start = start.min(end);
Ok(tera::Value::Array(arr[start..end].to_vec()))
},
);
tera.register_filter(
"date",
|value: &tera::Value, args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let date_str = value
.as_str()
.ok_or_else(|| tera::Error::msg("date filter requires a string"))?;
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%d");
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S") {
return Ok(tera::Value::String(dt.format(format).to_string()));
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
return Ok(tera::Value::String(d.format(format).to_string()));
}
Ok(tera::Value::String(date_str.to_string()))
},
);
}
fn register_tests(tera: &mut tera::Tera) {
tera.register_tester(
"starting_with",
|value: Option<&tera::Value>, args: &[tera::Value]| -> tera::Result<bool> {
let value = value.and_then(|v| v.as_str()).unwrap_or("");
let prefix = args.first().and_then(|v| v.as_str()).unwrap_or("");
Ok(value.starts_with(prefix))
},
);
}
pub fn page_context(page: &Page, config: &Config) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("page", page);
ctx.insert("config", &config_to_value(config));
ctx.insert("section", &tera::Value::Null);
ctx
}
pub fn section_context(
section: &Section,
config: &Config,
paginator: Option<&Paginator>,
) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("section", section);
ctx.insert("config", &config_to_value(config));
ctx.insert("page", &tera::Value::Null);
if let Some(pag) = paginator {
ctx.insert("paginator", pag);
}
ctx
}
pub fn taxonomy_list_context(terms: &[TaxonomyTerm], config: &Config) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("terms", terms);
ctx.insert("config", &config_to_value(config));
ctx.insert("page", &tera::Value::Null);
ctx.insert("section", &tera::Value::Null);
ctx
}
pub fn taxonomy_single_context(term: &TaxonomyTerm, config: &Config) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("term", term);
ctx.insert("config", &config_to_value(config));
ctx.insert("page", &tera::Value::Null);
ctx.insert("section", &tera::Value::Null);
ctx
}
pub fn config_to_value(config: &Config) -> serde_json::Value {
serde_json::to_value(config).expect("Config serialization should never fail")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::content::{Frontmatter, build_page, build_section};
use tempfile::TempDir;
fn minimal_config() -> Config {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("config.toml"),
r#"
base_url = "https://example.com"
title = "Test Site"
[extra]
author = "Tester"
"#,
)
.unwrap();
Config::load(tmp.path()).unwrap()
}
fn minimal_page() -> Page {
build_page(
Frontmatter {
title: Some("Test Page".into()),
..Default::default()
},
"Hello world".into(),
"posts/test.md",
"https://example.com",
)
}
fn minimal_section() -> Section {
build_section(
Frontmatter {
title: Some("Blog".into()),
..Default::default()
},
"Section content".into(),
"posts/_index.md",
"https://example.com",
)
}
#[test]
fn test_config_to_value_fields() {
let config = minimal_config();
let val = config_to_value(&config);
assert_eq!(val["base_url"], "https://example.com");
assert_eq!(val["title"], "Test Site");
assert_eq!(val["extra"]["author"], "Tester");
assert!(val["markdown"].is_object());
}
#[test]
fn test_page_context_keys() {
let config = minimal_config();
let page = minimal_page();
let ctx = page_context(&page, &config);
let json = ctx.into_json();
assert!(json.get("page").is_some());
assert!(json.get("config").is_some());
assert!(json.get("section").unwrap().is_null());
}
#[test]
fn test_section_context_keys() {
let config = minimal_config();
let section = minimal_section();
let ctx = section_context(§ion, &config, None);
let json = ctx.into_json();
assert!(json.get("section").is_some());
assert!(json.get("config").is_some());
assert!(json.get("page").unwrap().is_null());
}
#[test]
fn test_section_context_with_paginator() {
let config = minimal_config();
let section = minimal_section();
let pag = Paginator {
pages: vec![],
current_index: 1,
number_pagers: 3,
previous: None,
next: Some("https://example.com/posts/page/2/".into()),
first: "https://example.com/posts/".into(),
last: "https://example.com/posts/page/3/".into(),
};
let ctx = section_context(§ion, &config, Some(&pag));
let json = ctx.into_json();
let p = json.get("paginator").unwrap();
assert_eq!(p["current_index"], 1);
assert_eq!(p["number_pagers"], 3);
}
#[test]
fn test_pluralize_filter() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(tmpl_dir.join("test.html"), "{{ count | pluralize }}").unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let mut ctx = tera::Context::new();
ctx.insert("count", &1);
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "");
ctx.insert("count", &5);
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "s");
}
#[test]
fn test_slice_filter() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(
tmpl_dir.join("test.html"),
r#"{% for item in items | slice(end=2) %}{{ item }}{% endfor %}"#,
)
.unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let mut ctx = tera::Context::new();
ctx.insert("items", &vec!["a", "b", "c", "d"]);
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "ab");
}
#[test]
fn test_date_filter() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(
tmpl_dir.join("test.html"),
r#"{{ d | date(format="%B %d, %Y") }}"#,
)
.unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let mut ctx = tera::Context::new();
ctx.insert("d", "2025-06-15");
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "June 15, 2025");
}
#[test]
fn test_starting_with_tester() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(
tmpl_dir.join("test.html"),
r#"{% if path is starting_with("/blog") %}yes{% else %}no{% endif %}"#,
)
.unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let mut ctx = tera::Context::new();
ctx.insert("path", "/blog/post");
assert_eq!(tera.render("test.html", &ctx).unwrap(), "yes");
ctx.insert("path", "/about");
assert_eq!(tera.render("test.html", &ctx).unwrap(), "no");
}
#[test]
fn test_get_url_content_path() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(
tmpl_dir.join("test.html"),
r#"{{ get_url(path="@/posts/hello.md") | safe }}"#,
)
.unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let ctx = tera::Context::new();
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "https://example.com/posts/hello/");
}
#[test]
fn test_get_url_static_path() {
let tmp = TempDir::new().unwrap();
let tmpl_dir = tmp.path().join("templates");
std::fs::create_dir_all(&tmpl_dir).unwrap();
std::fs::write(
tmpl_dir.join("test.html"),
r#"{{ get_url(path="/img/photo.png") | safe }}"#,
)
.unwrap();
let config = minimal_config();
let sections = HashMap::new();
let tera = setup_tera(&tmpl_dir, &config, §ions).unwrap();
let ctx = tera::Context::new();
let result = tera.render("test.html", &ctx).unwrap();
assert_eq!(result, "https://example.com/img/photo.png");
}
}