Skip to main content

sova_templates/
lib.rs

1//! Template engines for Sova (MiniJinja).
2
3use 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/// Shared ambient per-request helpers (plugins register into this at install).
14#[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
44/// Get-or-create [`TemplateHelpers`] on the app and register a per-request provider.
45pub 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
63/// Pluggable template engine.
64///
65/// New code should prefer [`Templates`] + [`RenderExt`], which adds:
66/// - autoescape/XSS safety
67/// - loader from a directory
68/// - ambient (global + per-request) context providers
69pub trait TemplateEngine: Send + Sync {
70    fn render(&self, name: &str, ctx: Value) -> Result<String>;
71}
72
73/// Simple in-memory MiniJinja engine (manual `add_template`).
74#[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        // Security default: always escape HTML-sensitive characters.
83        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        // MiniJinja `#` debug rendering includes source snippet around the line.
131        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/// Pre-evaluated ambient template context (globals ± per-request helpers).
163///
164/// Freeze once at compose time (e.g. `req.mail()`), then render later without a
165/// [`Request`] — used by mail deferred `.view()` and other non-HTTP consumers.
166#[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/// MiniJinja template set stored in Sova app state.
178#[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    /// Globals + per-request helpers evaluated against `req` (owned, no borrow).
194    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    /// Globals only — for background jobs / mail compose without a [`Request`].
206    pub fn freeze_globals(&self) -> FrozenAmbient {
207        FrozenAmbient {
208            map: self.merge_base(),
209        }
210    }
211
212    /// Render `name` merging a previously frozen ambient map with local `ctx`.
213    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    /// Render a template to a string (same ambient merge as [`Self::render_html`]).
223    ///
224    /// Use for email bodies, fragments, or any non-HTTP consumer. Layouts work via
225    /// Jinja `{% extends %}` in the template file.
226    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    /// Render with globals only (no per-request helpers) — for background jobs without a [`Request`].
231    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    /// Schedule a filesystem reload on the next render (no-op for static engine).
280    ///
281    /// Useful in tests; in production the FS watcher usually triggers this.
282    pub fn request_reload(&self) {
283        if let MiniJinjaTemplatesEngine::Reload(reloader) = &self.engine {
284            reloader.notifier().request_reload();
285        }
286    }
287}
288
289/// Plugin entrypoint for template sets.
290pub struct Templates;
291
292impl Templates {
293    /// Configure MiniJinja templates from a directory.
294    ///
295    /// Template names are **relative to this directory**, including extension,
296    /// e.g. `home.html`, `blog/post.html`.
297    pub fn minijinja(dir: impl AsRef<Path>) -> MiniJinjaTemplatesBuilder {
298        MiniJinjaTemplatesBuilder::new(dir.as_ref().to_path_buf())
299    }
300}
301
302/// Builder that installs MiniJinja templates into app state.
303pub 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    /// Add a variable available in all templates and requests.
323    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    /// Provide a per-request variable (called on every render).
334    ///
335    /// Typical usage: provide `t` translation function for current request.
336    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    /// Enable/disable filesystem autoreload (dev convenience).
345    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
454/// Extension that renders a template into a HTML response or string.
455pub trait RenderExt {
456    fn render<T: Serialize>(&self, name: &str, ctx: T) -> Result<Response>;
457
458    /// Same merge as [`Self::render`], but returns the HTML/string body (e.g. for email).
459    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("&lt;script&gt;alert(1)"), "{body}");
503        assert!(
504            body.contains("&lt;&#x2f;script&gt;") || body.contains("&lt;/script&gt;"),
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            // No local override → per-request wins over global.
548            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        // Line 1 ok, line 2 undefined filter → error at line 2.
602        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        // MiniJinja attaches line; our formatter adds `:2` or `(line 2)`.
621        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}