use std::path::Path;
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use minijinja::Environment;
use serde::Serialize;
use crate::BoxError;
#[derive(Clone)]
pub struct Templates {
env: Arc<Environment<'static>>,
}
impl std::fmt::Debug for Templates {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Templates").finish_non_exhaustive()
}
}
impl Templates {
pub fn builder() -> TemplatesBuilder {
TemplatesBuilder::default()
}
#[must_use]
pub fn from_dir(dir: impl AsRef<Path>) -> Self {
let mut env = Environment::new();
env.set_loader(minijinja::path_loader(dir.as_ref()));
Self { env: Arc::new(env) }
}
pub fn render<S: Serialize>(&self, name: &str, context: S) -> Result<String, BoxError> {
let template = self
.env
.get_template(name)
.map_err(|e| format!("template '{name}' not found: {e}"))?;
template
.render(context)
.map_err(|e| format!("rendering template '{name}' failed: {e}").into())
}
#[must_use]
pub fn render_html<S: Serialize>(&self, name: &str, context: S) -> Response {
match self.render(name, context) {
Ok(body) => Html(body).into_response(),
Err(error) => {
tracing::error!(template = name, %error, "template render failed");
(StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
}
}
}
}
#[derive(Default)]
#[must_use = "TemplatesBuilder does nothing until `build` is called"]
pub struct TemplatesBuilder {
sources: Vec<(String, String)>,
}
impl std::fmt::Debug for TemplatesBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TemplatesBuilder")
.field("count", &self.sources.len())
.finish()
}
}
impl TemplatesBuilder {
pub fn template(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
self.sources.push((name.into(), source.into()));
self
}
pub fn build(self) -> Result<Templates, BoxError> {
let mut env = Environment::new();
for (name, source) in self.sources {
env.add_template_owned(name.clone(), source)
.map_err(|e| format!("compiling template '{name}' failed: {e}"))?;
}
Ok(Templates { env: Arc::new(env) })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use axum::body::to_bytes;
use axum::http::header;
use minijinja::context;
use super::*;
fn templates() -> Templates {
Templates::builder()
.template("hello.html", "<p>Hello {{ name }}</p>")
.template("plain.txt", "Hi {{ name }}")
.build()
.unwrap()
}
#[test]
fn renders_to_string() {
let out = templates()
.render("hello.html", context! { name => "Jay" })
.unwrap();
assert_eq!(out, "<p>Hello Jay</p>");
}
#[test]
fn html_template_autoescapes() {
let out = templates()
.render("hello.html", context! { name => "<script>" })
.unwrap();
assert_eq!(out, "<p>Hello <script></p>");
}
#[test]
fn non_html_template_does_not_escape() {
let out = templates()
.render("plain.txt", context! { name => "<x>" })
.unwrap();
assert_eq!(out, "Hi <x>");
}
#[test]
fn unknown_template_is_an_error() {
assert!(templates().render("missing.html", context! {}).is_err());
}
#[test]
fn build_rejects_invalid_syntax() {
let result = Templates::builder()
.template("bad.html", "{{ unclosed")
.build();
assert!(result.is_err());
}
#[test]
fn from_dir_loads_templates() {
let dir = std::env::temp_dir().join(format!("rg-templates-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("page.html"), "<h1>{{ title }}</h1>").unwrap();
let rendered = Templates::from_dir(&dir)
.render("page.html", context! { title => "Hi" })
.unwrap();
assert_eq!(rendered, "<h1>Hi</h1>");
let _cleanup = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn render_html_returns_ok_html() {
let response = templates().render_html("hello.html", context! { name => "Jay" });
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(content_type.starts_with("text/html"));
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&body[..], b"<p>Hello Jay</p>");
}
#[tokio::test]
async fn render_html_error_is_500() {
let response = templates().render_html("missing.html", context! {});
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}