use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use minijinja::{
escape_formatter, AutoEscape, Environment, Error, ErrorKind, Output, State, Value,
};
pub fn env() -> Arc<Environment<'static>> {
static CELL: OnceLock<Arc<Environment<'static>>> = OnceLock::new();
CELL.get_or_init(|| Arc::new(environment(&TemplatingConfig::default())))
.clone()
}
#[derive(Clone, Debug)]
pub struct TemplatingConfig {
pub overrides_root: Option<PathBuf>,
pub auto_reload: bool,
}
impl Default for TemplatingConfig {
fn default() -> Self {
let overrides_root = std::env::current_dir()
.ok()
.map(|cwd| cwd.join("templates"))
.filter(|p| p.is_dir());
Self {
overrides_root,
auto_reload: cfg!(debug_assertions),
}
}
}
pub fn environment(config: &TemplatingConfig) -> Environment<'static> {
let mut env = Environment::new();
env.set_auto_escape_callback(|name| {
if name.ends_with(".html") || name.ends_with(".htm") {
minijinja::AutoEscape::Html
} else {
minijinja::AutoEscape::None
}
});
env.set_formatter(admin_html_formatter);
let overrides_root: Option<Arc<Path>> = config.overrides_root.clone().map(Into::into);
let auto_reload = config.auto_reload;
if auto_reload {
let overrides_root = overrides_root.clone();
env.set_loader(move |name| load_template(name, overrides_root.as_deref()));
} else {
let resolved = resolve_all(overrides_root.as_deref());
env.set_loader(move |name| Ok(resolved.lookup(name).map(|s| s.to_string())));
}
env
}
fn admin_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
if matches!(state.auto_escape(), AutoEscape::Html) && !value.is_safe() {
return write!(out, "{}", HtmlEscapeNoSlash(&value.to_string())).map_err(Error::from);
}
escape_formatter(out, state, value)
}
struct HtmlEscapeNoSlash<'a>(&'a str);
impl fmt::Display for HtmlEscapeNoSlash<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = self.0;
let mut start = 0;
for (i, b) in s.bytes().enumerate() {
let replacement = match b {
b'<' => "<",
b'>' => ">",
b'&' => "&",
b'"' => """,
b'\'' => "'",
_ => continue,
};
if start < i {
f.write_str(&s[start..i])?;
}
f.write_str(replacement)?;
start = i + 1;
}
if start < s.len() {
f.write_str(&s[start..])?;
}
Ok(())
}
}
fn load_template(
name: &str,
overrides_root: Option<&Path>,
) -> Result<Option<String>, minijinja::Error> {
if let Some(root) = overrides_root {
match read_override(root, name) {
Ok(Some(source)) => return Ok(Some(source)),
Ok(None) => {}
Err(err) => {
return Err(minijinja::Error::new(
ErrorKind::InvalidOperation,
format!("reading override template {name}: {err}"),
));
}
}
}
Ok(embedded(name).map(str::to_string))
}
fn read_override(root: &Path, name: &str) -> std::io::Result<Option<String>> {
let path = safe_join(root, name);
if !path.is_file() {
return Ok(None);
}
std::fs::read_to_string(path).map(Some)
}
fn safe_join(root: &Path, name: &str) -> PathBuf {
let mut out = root.to_path_buf();
for segment in name.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
continue;
}
out.push(segment);
}
out
}
struct ResolvedSet {
cache: std::collections::HashMap<&'static str, String>,
}
impl ResolvedSet {
fn lookup(&self, name: &str) -> Option<&str> {
if let Some(s) = self.cache.get(name) {
return Some(s.as_str());
}
embedded(name)
}
}
fn resolve_all(overrides_root: Option<&Path>) -> ResolvedSet {
let mut cache = std::collections::HashMap::with_capacity(EMBEDDED.len());
for (name, default_source) in EMBEDDED {
let source = overrides_root
.and_then(|root| std::fs::read_to_string(safe_join(root, name)).ok())
.unwrap_or_else(|| (*default_source).to_string());
cache.insert(*name, source);
}
ResolvedSet { cache }
}
const EMBEDDED: &[(&str, &str)] = &[
(
"base.html",
include_str!("../../assets/templates/base.html"),
),
(
"base_admin.html",
include_str!("../../assets/templates/base_admin.html"),
),
(
"includes/header.html",
include_str!("../../assets/templates/includes/header.html"),
),
(
"includes/sidebar.html",
include_str!("../../assets/templates/includes/sidebar.html"),
),
(
"includes/footer.html",
include_str!("../../assets/templates/includes/footer.html"),
),
(
"admin/dashboard.html",
include_str!("../../assets/templates/admin/dashboard.html"),
),
(
"admin/list.html",
include_str!("../../assets/templates/admin/list.html"),
),
(
"admin/form.html",
include_str!("../../assets/templates/admin/form.html"),
),
(
"admin/profile.html",
include_str!("../../assets/templates/admin/profile.html"),
),
(
"admin/password_change.html",
include_str!("../../assets/templates/admin/password_change.html"),
),
(
"admin/password_change_done.html",
include_str!("../../assets/templates/admin/password_change_done.html"),
),
(
"admin/actions.html",
include_str!("../../assets/templates/admin/actions.html"),
),
(
"admin/suggestion_review.html",
include_str!("../../assets/templates/admin/suggestion_review.html"),
),
(
"admin/suggestion_applied.html",
include_str!("../../assets/templates/admin/suggestion_applied.html"),
),
(
"auth/login.html",
include_str!("../../assets/templates/auth/login.html"),
),
(
"auth/forbidden.html",
include_str!("../../assets/templates/auth/forbidden.html"),
),
(
"auth/not_found.html",
include_str!("../../assets/templates/auth/not_found.html"),
),
];
fn embedded(name: &str) -> Option<&'static str> {
EMBEDDED
.iter()
.find_map(|(n, s)| if *n == name { Some(*s) } else { None })
}
pub const BUNDLED_ASSETS: &[(&str, &str, &[u8])] = &[
(
"admin.css",
"text/css; charset=utf-8",
include_bytes!(concat!(env!("OUT_DIR"), "/admin.css")),
),
(
"app.js",
"application/javascript; charset=utf-8",
include_bytes!("../../assets/static/app.js"),
),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_embedded_template_parses() {
let env = environment(&TemplatingConfig {
overrides_root: None,
auto_reload: false,
});
for (name, _) in EMBEDDED {
env.get_template(name)
.unwrap_or_else(|e| panic!("embedded template {name} failed to parse: {e}"));
}
}
#[test]
fn html_escape_no_slash_escapes_markup_but_keeps_slash() {
let out = HtmlEscapeNoSlash("/a/b <x> & \"q\" 'p'").to_string();
assert_eq!(out, "/a/b <x> & "q" 'p'");
}
#[test]
fn admin_formatter_keeps_url_slashes_but_escapes_dangerous_chars() {
let env = environment(&TemplatingConfig {
overrides_root: None,
auto_reload: false,
});
let url = env
.render_named_str(
"probe.html",
"{{ url }}",
minijinja::context! { url => "/admin/customers/50/edit" },
)
.unwrap();
assert_eq!(url, "/admin/customers/50/edit");
let danger = env
.render_named_str(
"probe.html",
"{{ s }}",
minijinja::context! { s => "<script>&'\"" },
)
.unwrap();
assert_eq!(danger, "<script>&'"");
}
#[test]
fn dashboard_renders_with_minimum_context() {
let env = environment(&TemplatingConfig {
overrides_root: None,
auto_reload: false,
});
let tmpl = env.get_template("admin/dashboard.html").unwrap();
let out = tmpl
.render(minijinja::context! {
design => minijinja::context! { project_name => "Test" },
current_user => minijinja::Value::from(()),
sidebar_entries => Vec::<minijinja::Value>::new(),
dashboard_cards => Vec::<minijinja::Value>::new(),
})
.unwrap();
assert!(out.contains("Overview"));
assert!(out.contains("workspace"));
assert!(out.contains("Test"));
}
#[test]
fn safe_join_blocks_traversal() {
let root = PathBuf::from("/tmp/root");
assert_eq!(safe_join(&root, "../etc/passwd"), root.join("etc/passwd"));
assert_eq!(safe_join(&root, "./a"), root.join("a"));
}
#[test]
fn bundled_assets_are_non_empty() {
for (path, _ctype, bytes) in BUNDLED_ASSETS {
assert!(!bytes.is_empty(), "bundled asset {path} is empty");
}
}
#[test]
fn env_accessor_is_cached() {
let a = super::env();
let b = super::env();
assert!(
Arc::ptr_eq(&a, &b),
"env() should return the same Arc on repeated calls"
);
a.get_template("base.html").expect("base.html missing");
}
}