1use minijinja::{path_loader, AutoEscape, Environment, Value};
4use minijinja_autoreload::AutoReloader;
5use sova_core::{App, Error, Plugin, Request, Response, Result};
6use serde::Serialize;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11type PerRequestProvider = Arc<dyn Fn(&Request) -> Value + Send + Sync>;
12
13#[derive(Default)]
15pub struct TemplateHelpers {
16 map: Mutex<HashMap<String, PerRequestProvider>>,
17}
18
19impl TemplateHelpers {
20 pub fn new() -> Self {
21 Self::default()
22 }
23
24 pub fn register<F>(&self, name: impl Into<String>, provider: F)
25 where
26 F: Fn(&Request) -> Value + Send + Sync + 'static,
27 {
28 self.map
29 .lock()
30 .unwrap()
31 .insert(name.into(), Arc::new(provider));
32 }
33
34 fn snapshot(&self) -> Vec<(String, PerRequestProvider)> {
35 self.map
36 .lock()
37 .unwrap()
38 .iter()
39 .map(|(k, v)| (k.clone(), Arc::clone(v)))
40 .collect()
41 }
42}
43
44pub fn register_per_request<F>(app: &mut App, name: impl Into<String>, provider: F)
46where
47 F: Fn(&Request) -> Value + Send + Sync + 'static,
48{
49 let helpers = ensure_helpers(app);
50 helpers.register(name, provider);
51}
52
53fn ensure_helpers(app: &mut App) -> Arc<TemplateHelpers> {
54 if let Some(h) = app.try_state::<TemplateHelpers>() {
55 return h;
56 }
57 let helpers = TemplateHelpers::new();
58 app.state(helpers);
59 app.try_state::<TemplateHelpers>()
60 .expect("TemplateHelpers just inserted")
61}
62
63pub trait TemplateEngine: Send + Sync {
70 fn render(&self, name: &str, ctx: Value) -> Result<String>;
71}
72
73#[derive(Clone)]
75pub struct MiniJinjaEngine {
76 env: Environment<'static>,
77}
78
79impl MiniJinjaEngine {
80 pub fn new() -> Self {
81 let mut env = Environment::new();
82 env.set_auto_escape_callback(|_| AutoEscape::Html);
84 if cfg!(debug_assertions) {
85 env.set_debug(true);
86 }
87 Self { env }
88 }
89
90 pub fn add_template(&mut self, name: &str, source: &str) -> Result<()> {
91 self.env
92 .add_template_owned(name.to_string(), source.to_string())
93 .map_err(|e| Error::Internal(format!("template: {e}")))?;
94 Ok(())
95 }
96
97 pub fn render_html<S: Serialize>(&self, name: &str, ctx: S) -> Result<Response> {
98 let value = Value::from_serialize(&ctx);
99 let html = TemplateEngine::render(self, name, value)?;
100 Ok(Response::html(html))
101 }
102}
103
104impl Default for MiniJinjaEngine {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl TemplateEngine for MiniJinjaEngine {
111 fn render(&self, name: &str, ctx: Value) -> Result<String> {
112 let tmpl = self
113 .env
114 .get_template(name)
115 .map_err(|e| missing_template_err(name, &e))?;
116
117 tmpl.render(ctx)
118 .map_err(|e| render_failed_err(name, &e))
119 }
120}
121
122fn format_render_err(name: &str, e: &minijinja::Error) -> String {
123 let loc = match (e.name(), e.line()) {
124 (Some(n), Some(line)) => format!(" (in {n}:{line})"),
125 (_, Some(line)) => format!(" (line {line})"),
126 (Some(n), None) => format!(" (in {n})"),
127 (None, None) => String::new(),
128 };
129 if cfg!(debug_assertions) {
130 format!("render {name}{loc}:\n{e:#}")
132 } else {
133 format!("render {name} failed{loc}: {e}")
134 }
135}
136
137fn missing_template_err(name: &str, e: &minijinja::Error) -> Error {
138 let detail = format!("template {name}: {e}");
139 if cfg!(debug_assertions) {
140 Error::Internal(detail)
141 } else {
142 tracing::error!(template = %name, error = %e, "template not found");
143 Error::Internal(format!("template {name} not found"))
144 }
145}
146
147fn render_failed_err(name: &str, e: &minijinja::Error) -> Error {
148 let detail = format_render_err(name, e);
149 if cfg!(debug_assertions) {
150 Error::Internal(detail)
151 } else {
152 tracing::error!(
153 template = %name,
154 line = ?e.line(),
155 error = %e,
156 "template render failed"
157 );
158 Error::Internal("template render failed".into())
159 }
160}
161
162#[derive(Clone, Default)]
167pub struct FrozenAmbient {
168 map: HashMap<String, Value>,
169}
170
171impl FrozenAmbient {
172 pub fn is_empty(&self) -> bool {
173 self.map.is_empty()
174 }
175}
176
177#[derive(Clone)]
179pub struct MiniJinjaTemplates {
180 engine: MiniJinjaTemplatesEngine,
181 globals: Arc<HashMap<String, Value>>,
182 per_request: Arc<HashMap<String, PerRequestProvider>>,
183 helpers: Arc<TemplateHelpers>,
184}
185
186#[derive(Clone)]
187enum MiniJinjaTemplatesEngine {
188 Static(Arc<Environment<'static>>),
189 Reload(Arc<AutoReloader>),
190}
191
192impl MiniJinjaTemplates {
193 pub fn freeze_ambient(&self, req: &Request) -> FrozenAmbient {
195 let mut map = self.merge_base();
196 for (k, provider) in self.helpers.snapshot() {
197 map.insert(k, provider(req));
198 }
199 for (k, provider) in self.per_request.iter() {
200 map.insert(k.clone(), (provider)(req));
201 }
202 FrozenAmbient { map }
203 }
204
205 pub fn freeze_globals(&self) -> FrozenAmbient {
207 FrozenAmbient {
208 map: self.merge_base(),
209 }
210 }
211
212 pub fn render_owned<T: Serialize>(
214 &self,
215 ambient: &FrozenAmbient,
216 name: &str,
217 ctx: T,
218 ) -> Result<String> {
219 self.render_merged(name, ambient.map.clone(), ctx)
220 }
221
222 pub fn render_str<T: Serialize>(&self, req: &Request, name: &str, ctx: T) -> Result<String> {
227 self.render_owned(&self.freeze_ambient(req), name, ctx)
228 }
229
230 pub fn render_str_globals<T: Serialize>(&self, name: &str, ctx: T) -> Result<String> {
232 self.render_owned(&self.freeze_globals(), name, ctx)
233 }
234
235 pub fn render_html<T: Serialize>(&self, req: &Request, name: &str, ctx: T) -> Result<Response> {
236 Ok(Response::html(self.render_str(req, name, ctx)?))
237 }
238
239 fn merge_base(&self) -> HashMap<String, Value> {
240 (*self.globals).clone()
241 }
242
243 fn render_merged<T: Serialize>(
244 &self,
245 name: &str,
246 mut merged: HashMap<String, Value>,
247 ctx: T,
248 ) -> Result<String> {
249 let json = serde_json::to_value(ctx).map_err(Error::from)?;
250 let obj = json.as_object().ok_or_else(|| {
251 Error::BadRequest("templates.render ctx must serialize to an object".into())
252 })?;
253 for (k, v) in obj {
254 merged.insert(k.clone(), Value::from_serialize(v));
255 }
256 let ctx_val = Value::from(merged);
257
258 match &self.engine {
259 MiniJinjaTemplatesEngine::Static(env) => {
260 let tmpl = env
261 .get_template(name)
262 .map_err(|e| missing_template_err(name, &e))?;
263 tmpl.render(ctx_val)
264 .map_err(|e| render_failed_err(name, &e))
265 }
266 MiniJinjaTemplatesEngine::Reload(reloader) => {
267 let env = reloader
268 .acquire_env()
269 .map_err(|e| Error::Internal(format!("autoreload env: {e}")))?;
270 let tmpl = env
271 .get_template(name)
272 .map_err(|e| missing_template_err(name, &e))?;
273 tmpl.render(ctx_val)
274 .map_err(|e| render_failed_err(name, &e))
275 }
276 }
277 }
278
279 pub fn request_reload(&self) {
283 if let MiniJinjaTemplatesEngine::Reload(reloader) = &self.engine {
284 reloader.notifier().request_reload();
285 }
286 }
287}
288
289pub struct Templates;
291
292impl Templates {
293 pub fn minijinja(dir: impl AsRef<Path>) -> MiniJinjaTemplatesBuilder {
298 MiniJinjaTemplatesBuilder::new(dir.as_ref().to_path_buf())
299 }
300}
301
302pub struct MiniJinjaTemplatesBuilder {
304 dir: PathBuf,
305 autoreload: bool,
306 autoreload_explicit: bool,
307 globals: HashMap<String, Value>,
308 per_request: HashMap<String, PerRequestProvider>,
309}
310
311impl MiniJinjaTemplatesBuilder {
312 fn new(dir: PathBuf) -> Self {
313 Self {
314 dir,
315 autoreload: cfg!(debug_assertions),
316 autoreload_explicit: false,
317 globals: HashMap::new(),
318 per_request: HashMap::new(),
319 }
320 }
321
322 pub fn global<V: Serialize + Send + Sync + 'static>(
324 mut self,
325 name: impl Into<String>,
326 value: V,
327 ) -> Self {
328 self.globals
329 .insert(name.into(), Value::from_serialize(&value));
330 self
331 }
332
333 pub fn per_request<F>(mut self, name: impl Into<String>, provider: F) -> Self
337 where
338 F: Fn(&Request) -> Value + Send + Sync + 'static,
339 {
340 self.per_request.insert(name.into(), Arc::new(provider));
341 self
342 }
343
344 pub fn autoreload(mut self, enabled: bool) -> Self {
346 self.autoreload = enabled;
347 self.autoreload_explicit = true;
348 self
349 }
350}
351
352impl Plugin for MiniJinjaTemplatesBuilder {
353 fn id(&self) -> &'static str {
354 "templates"
355 }
356
357 fn meta(&self) -> sova_core::PluginMeta {
358 sova_core::PluginMeta::new("Templates")
359 .description("MiniJinja HTML templates with optional autoreload")
360 .version(env!("CARGO_PKG_VERSION"))
361 }
362
363 fn install(mut self, app: &mut sova_core::App) {
364 if !self.autoreload_explicit {
365 if let Some(doc) = app.config_doc() {
366 if let Some(section) = doc.section("templates") {
367 if let Some(v) = section.get("autoreload").and_then(|v| v.as_bool()) {
368 self.autoreload = v;
369 }
370 }
371 }
372 }
373 let helpers = ensure_helpers(app);
374 let globals = Arc::new(self.globals);
375 let per_request = Arc::new(self.per_request);
376 let dir = self.dir;
377 let check_dir = dir.clone();
378 let engine = if self.autoreload {
379 let dir_str = dir
380 .to_str()
381 .unwrap_or_else(|| panic!("template dir path must be valid UTF-8: {dir:?}"))
382 .to_string();
383 let dir_load = dir.clone();
384 let reloader = AutoReloader::new(move |notifier| {
385 notifier.watch_path(dir_str.as_str(), true);
386 let mut env = Environment::new();
387 env.set_auto_escape_callback(|_| AutoEscape::Html);
388 env.set_debug(cfg!(debug_assertions));
389 env.set_loader(path_loader(&dir_load));
390 Ok(env)
391 });
392 MiniJinjaTemplatesEngine::Reload(Arc::new(reloader))
393 } else {
394 let mut env = Environment::new();
395 env.set_auto_escape_callback(|_| AutoEscape::Html);
396 env.set_debug(cfg!(debug_assertions));
397 env.set_loader(path_loader(&dir));
398 MiniJinjaTemplatesEngine::Static(Arc::new(env))
399 };
400
401 app.state(MiniJinjaTemplates {
402 engine,
403 globals,
404 per_request,
405 helpers,
406 });
407
408 app.register_audit("templates", move |_state| {
409 let dir = check_dir.clone();
410 async move { compile_templates_in_dir(&dir) }
411 });
412 }
413}
414
415fn compile_templates_in_dir(dir: &Path) -> Result<()> {
416 let mut env = Environment::new();
417 env.set_auto_escape_callback(|_| AutoEscape::Html);
418 env.set_loader(path_loader(dir));
419 for entry in walkdir_templates(dir)? {
420 let rel = entry
421 .strip_prefix(dir)
422 .unwrap_or(entry.as_path())
423 .to_string_lossy()
424 .replace('\\', "/");
425 env.get_template(&rel)
426 .map_err(|e| Error::Internal(format!("template `{rel}`: {e}")))?;
427 }
428 Ok(())
429}
430
431fn walkdir_templates(dir: &Path) -> Result<Vec<PathBuf>> {
432 let mut out = Vec::new();
433 fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
434 let rd = std::fs::read_dir(dir).map_err(|e| Error::Internal(format!("templates: {e}")))?;
435 for ent in rd {
436 let ent = ent.map_err(|e| Error::Internal(format!("templates: {e}")))?;
437 let path = ent.path();
438 if path.is_dir() {
439 walk(&path, out)?;
440 } else if path
441 .extension()
442 .and_then(|e| e.to_str())
443 .is_some_and(|e| matches!(e, "html" | "j2" | "jinja" | "jinja2" | "txt"))
444 {
445 out.push(path);
446 }
447 }
448 Ok(())
449 }
450 walk(dir, &mut out)?;
451 Ok(out)
452}
453
454pub trait RenderExt {
456 fn render<T: Serialize>(&self, name: &str, ctx: T) -> Result<Response>;
457
458 fn render_str<T: Serialize>(&self, name: &str, ctx: T) -> Result<String>;
460}
461
462impl RenderExt for Request {
463 fn render<T: Serialize>(&self, name: &str, ctx: T) -> Result<Response> {
464 let templates = self.state::<MiniJinjaTemplates>();
465 templates.render_html(self, name, ctx)
466 }
467
468 fn render_str<T: Serialize>(&self, name: &str, ctx: T) -> Result<String> {
469 let templates = self.state::<MiniJinjaTemplates>();
470 templates.render_str(self, name, ctx)
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use sova_core::{App, Request};
478 use tempfile::tempdir;
479
480 fn install_app(dir: &std::path::Path, builder: MiniJinjaTemplatesBuilder) -> App {
481 let mut app = App::new();
482 app.install(builder);
483 let _ = dir;
484 app
485 }
486
487 #[tokio::test]
488 async fn xss_is_escaped_by_default() {
489 let dir = tempdir().unwrap();
490 std::fs::write(dir.path().join("home.html"), r#"<h1>{{ title }}</h1>"#).unwrap();
491
492 let mut app = install_app(dir.path(), Templates::minijinja(dir.path()).autoreload(false));
493 app.get("/", |req: Request| async move {
494 req.render(
495 "home.html",
496 serde_json::json!({ "title": "<script>alert(1)</script>" }),
497 )
498 });
499
500 let res = app.handle_request(http::Method::GET, "/", "").await;
501 let body = std::str::from_utf8(res.body_bytes().unwrap()).unwrap();
502 assert!(body.contains("<script>alert(1)"), "{body}");
503 assert!(
504 body.contains("</script>") || body.contains("</script>"),
505 "{body}"
506 );
507 }
508
509 #[tokio::test]
510 async fn ambient_t_is_called_and_sees_per_request_state() {
511 let dir = tempdir().unwrap();
512 std::fs::write(dir.path().join("home.html"), r#"<p>{{ t("k") }}</p>"#).unwrap();
513
514 let mut app = install_app(
515 dir.path(),
516 Templates::minijinja(dir.path())
517 .autoreload(false)
518 .per_request("t", |req: &Request| {
519 let prefix = req.header("x-prefix").unwrap_or("no-prefix").to_string();
520 Value::from_function(move |key: String| format!("{prefix}:{key}"))
521 }),
522 );
523
524 app.get("/", |req: Request| async move {
525 req.render("home.html", serde_json::json!({}))
526 });
527
528 let res = app.handle_request(http::Method::GET, "/", "").await;
529 let body = std::str::from_utf8(res.body_bytes().unwrap()).unwrap();
530 assert!(body.contains("no-prefix:k"), "{body}");
531 }
532
533 #[tokio::test]
534 async fn context_merge_priority_local_over_per_request_over_global() {
535 let dir = tempdir().unwrap();
536 std::fs::write(dir.path().join("home.html"), r#"<p>{{ label }}</p>"#).unwrap();
537
538 let mut app = install_app(
539 dir.path(),
540 Templates::minijinja(dir.path())
541 .autoreload(false)
542 .global("label", "global")
543 .per_request("label", |_| Value::from("per-request")),
544 );
545
546 app.get("/g", |req: Request| async move {
547 req.render("home.html", serde_json::json!({}))
549 });
550 app.get("/l", |req: Request| async move {
551 req.render("home.html", serde_json::json!({ "label": "local" }))
552 });
553
554 let g = app.handle_request(http::Method::GET, "/g", "").await;
555 let body = std::str::from_utf8(g.body_bytes().unwrap()).unwrap();
556 assert!(body.contains("per-request"), "{body}");
557
558 let l = app.handle_request(http::Method::GET, "/l", "").await;
559 let body = std::str::from_utf8(l.body_bytes().unwrap()).unwrap();
560 assert!(body.contains("local"), "{body}");
561 }
562
563 #[tokio::test]
564 async fn nested_template_path_loads() {
565 let dir = tempdir().unwrap();
566 std::fs::create_dir_all(dir.path().join("blog")).unwrap();
567 std::fs::write(dir.path().join("blog/post.html"), r#"<h1>{{ title }}</h1>"#).unwrap();
568
569 let mut app = install_app(dir.path(), Templates::minijinja(dir.path()).autoreload(false));
570 app.get("/", |req: Request| async move {
571 req.render("blog/post.html", serde_json::json!({ "title": "Post" }))
572 });
573
574 let res = app.handle_request(http::Method::GET, "/", "").await;
575 let body = std::str::from_utf8(res.body_bytes().unwrap()).unwrap();
576 assert!(body.contains("<h1>Post</h1>"), "{body}");
577 }
578
579 #[tokio::test]
580 async fn missing_template_names_template_in_error() {
581 let dir = tempdir().unwrap();
582 std::fs::write(dir.path().join("home.html"), "ok").unwrap();
583
584 let mut app = install_app(dir.path(), Templates::minijinja(dir.path()).autoreload(false));
585 app.get("/", |req: Request| async move {
586 match req.render("nope.html", serde_json::json!({})) {
587 Ok(r) => r,
588 Err(e) => e.into_response(),
589 }
590 });
591
592 let res = app.handle_request(http::Method::GET, "/", "").await;
593 assert_eq!(res.status_code().as_u16(), 500);
594 let body = std::str::from_utf8(res.body_bytes().unwrap()).unwrap();
595 assert!(body.contains("nope.html"), "{body}");
596 }
597
598 #[tokio::test]
599 async fn render_error_includes_line_number() {
600 let dir = tempdir().unwrap();
601 std::fs::write(
603 dir.path().join("bad.html"),
604 "ok\n{{ title | definitely_not_a_filter }}\n",
605 )
606 .unwrap();
607
608 let mut app = install_app(dir.path(), Templates::minijinja(dir.path()).autoreload(false));
609 app.get("/", |req: Request| async move {
610 match req.render("bad.html", serde_json::json!({ "title": "x" })) {
611 Ok(r) => r,
612 Err(e) => e.into_response(),
613 }
614 });
615
616 let res = app.handle_request(http::Method::GET, "/", "").await;
617 assert_eq!(res.status_code().as_u16(), 500);
618 let body = std::str::from_utf8(res.body_bytes().unwrap()).unwrap();
619 assert!(body.contains("bad.html"), "{body}");
620 assert!(
622 body.contains(":2") || body.contains("line 2") || body.contains("line: 2"),
623 "expected line number in error, got: {body}"
624 );
625 }
626
627 #[tokio::test]
628 async fn autoreload_picks_up_file_change() {
629 let dir = tempdir().unwrap();
630 let path = dir.path().join("home.html");
631 std::fs::write(&path, r#"<p>v1</p>"#).unwrap();
632
633 let mut app = install_app(dir.path(), Templates::minijinja(dir.path()).autoreload(true));
634 app.get("/", |req: Request| async move {
635 req.render("home.html", serde_json::json!({}))
636 });
637 app.get("/reload", |req: Request| async move {
638 req.state::<MiniJinjaTemplates>().request_reload();
639 Response::text("ok")
640 });
641
642 let v1 = app.handle_request(http::Method::GET, "/", "").await;
643 let body = std::str::from_utf8(v1.body_bytes().unwrap()).unwrap();
644 assert!(body.contains("v1"), "{body}");
645
646 std::fs::write(&path, r#"<p>v2</p>"#).unwrap();
647 let _ = app.handle_request(http::Method::GET, "/reload", "").await;
648
649 let v2 = app.handle_request(http::Method::GET, "/", "").await;
650 let body = std::str::from_utf8(v2.body_bytes().unwrap()).unwrap();
651 assert!(body.contains("v2"), "{body}");
652 }
653}