Skip to main content

modo/template/
engine.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use super::config::TemplateConfig;
5use super::static_files;
6use crate::i18n::{I18n, make_t_function};
7
8struct EngineInner {
9    env: std::sync::RwLock<minijinja::Environment<'static>>,
10    config: TemplateConfig,
11}
12
13/// The template engine.
14///
15/// Wraps a MiniJinja [`Environment`](minijinja::Environment) and provides:
16///
17/// - Filesystem-based template loading from the directory in
18///   [`TemplateConfig::templates_path`].
19/// - Automatic registration of [minijinja-contrib](https://docs.rs/minijinja-contrib)
20///   filters and functions.
21/// - A `t()` function (registered only when an [`I18n`](crate::i18n::I18n) handle
22///   is supplied via [`EngineBuilder::i18n`]) that looks up the `locale` context
23///   variable and delegates to the shared translation store.
24/// - A `static_url()` function that appends a content-hash query parameter to asset
25///   paths for cache-busting.
26/// - In debug builds, the template cache is cleared on every render so changes on
27///   disk are picked up without a restart (hot-reload).
28///
29/// `Engine` is cheaply cloneable — it wraps an `Arc` internally.
30///
31/// Use [`Engine::builder`] to obtain an [`EngineBuilder`].
32#[derive(Clone)]
33pub struct Engine {
34    inner: Arc<EngineInner>,
35}
36
37impl Engine {
38    /// Returns a new [`EngineBuilder`] with default settings.
39    ///
40    /// This is the only way to construct an [`Engine`]. Set options on the
41    /// builder and call [`EngineBuilder::build`] to finalize.
42    pub fn builder() -> EngineBuilder {
43        EngineBuilder::default()
44    }
45
46    /// Renders `template_name` with the given MiniJinja `context` and returns the
47    /// output as a `String`.
48    ///
49    /// Returns an error if the template file is not found or if rendering fails.
50    pub(crate) fn render(
51        &self,
52        template_name: &str,
53        context: minijinja::Value,
54    ) -> crate::Result<String> {
55        // In debug mode, clear template cache for hot-reload
56        if cfg!(debug_assertions) {
57            let mut write_guard = self
58                .inner
59                .env
60                .write()
61                .expect("template env RwLock poisoned");
62            write_guard.clear_templates();
63            drop(write_guard);
64        }
65
66        let read_guard = self.inner.env.read().expect("template env RwLock poisoned");
67        let template = read_guard.get_template(template_name).map_err(|e| {
68            crate::Error::internal(format!("Template '{template_name}' not found: {e}"))
69        })?;
70
71        template
72            .render(context)
73            .map_err(|e| crate::Error::internal(format!("Render error in '{template_name}': {e}")))
74    }
75
76    /// Returns an [`axum::Router`] that serves static files from
77    /// [`TemplateConfig::static_path`] under the [`TemplateConfig::static_url_prefix`]
78    /// URL prefix.
79    ///
80    /// In debug builds the router adds `Cache-Control: no-cache`. In release builds it
81    /// adds `Cache-Control: public, max-age=31536000, immutable`.
82    pub fn static_service(&self) -> axum::Router {
83        static_files::static_service(
84            &self.inner.config.static_path,
85            &self.inner.config.static_url_prefix,
86        )
87    }
88}
89
90type EnvCustomizer = Box<dyn FnOnce(&mut minijinja::Environment<'static>) + Send>;
91
92/// Builder for [`Engine`].
93///
94/// Obtained via [`Engine::builder()`]. Call [`EngineBuilder::build`] to construct
95/// the engine after setting options.
96#[must_use]
97#[derive(Default)]
98pub struct EngineBuilder {
99    config: Option<TemplateConfig>,
100    customizers: Vec<EnvCustomizer>,
101    i18n: Option<I18n>,
102}
103
104impl EngineBuilder {
105    /// Sets the template configuration.
106    ///
107    /// If not called, [`TemplateConfig::default()`] is used.
108    pub fn config(mut self, config: TemplateConfig) -> Self {
109        self.config = Some(config);
110        self
111    }
112
113    /// Registers a custom MiniJinja global function.
114    ///
115    /// `name` is the name used in templates (e.g. `"greet"`), `f` is any value that
116    /// implements `minijinja::functions::Function`.
117    pub fn function<N, F, Rv, Args>(mut self, name: N, f: F) -> Self
118    where
119        N: Into<std::borrow::Cow<'static, str>> + Send + 'static,
120        F: minijinja::functions::Function<Rv, Args> + Send + Sync + 'static,
121        Rv: minijinja::value::FunctionResult,
122        Args: for<'a> minijinja::value::FunctionArgs<'a>,
123    {
124        self.customizers
125            .push(Box::new(move |env| env.add_function(name, f)));
126        self
127    }
128
129    /// Registers a custom MiniJinja filter.
130    ///
131    /// `name` is the filter name used in templates (e.g. `"shout"`), `f` is any value
132    /// that implements `minijinja::functions::Function`.
133    pub fn filter<N, F, Rv, Args>(mut self, name: N, f: F) -> Self
134    where
135        N: Into<std::borrow::Cow<'static, str>> + Send + 'static,
136        F: minijinja::functions::Function<Rv, Args> + Send + Sync + 'static,
137        Rv: minijinja::value::FunctionResult,
138        Args: for<'a> minijinja::value::FunctionArgs<'a>,
139    {
140        self.customizers
141            .push(Box::new(move |env| env.add_filter(name, f)));
142        self
143    }
144
145    /// Provide a shared I18n handle so templates can call `{{ t("key") }}`.
146    ///
147    /// If omitted, the engine does not register the `t()` function — templates
148    /// that reference it will fail to render.
149    pub fn i18n(mut self, i18n: I18n) -> Self {
150        self.i18n = Some(i18n);
151        self
152    }
153
154    /// Builds and returns the [`Engine`].
155    ///
156    /// The static-assets directory at [`TemplateConfig::static_path`] is walked
157    /// once to compute SHA-256 content hashes used by the `static_url()`
158    /// template function for cache busting. If the directory does not exist, an
159    /// empty hash map is used and `static_url()` falls back to unversioned URLs.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`Error`](crate::Error) if any file under the static-assets
164    /// directory cannot be read while computing content hashes.
165    pub fn build(self) -> crate::Result<Engine> {
166        let config = self.config.unwrap_or_default();
167
168        // Create MiniJinja environment with filesystem loader
169        let mut env = minijinja::Environment::new();
170        let templates_path = config.templates_path.clone();
171        env.set_loader(minijinja::path_loader(&templates_path));
172
173        // Register minijinja-contrib common filters/functions
174        minijinja_contrib::add_to_environment(&mut env);
175
176        // Register t() function when an I18n handle is supplied.
177        if let Some(ref i18n) = self.i18n {
178            let t_fn = make_t_function(i18n.store().clone());
179            env.add_function("t", t_fn);
180        }
181
182        // Compute static file hashes
183        let static_path = Path::new(&config.static_path);
184        let static_hashes = static_files::compute_hashes(static_path)?;
185
186        // Register static_url() function
187        let static_url_fn = static_files::make_static_url_function(
188            config.static_url_prefix.clone(),
189            static_hashes.clone(),
190        );
191        env.add_function("static_url", static_url_fn);
192
193        // Apply user-registered functions and filters
194        for customizer in self.customizers {
195            customizer(&mut env);
196        }
197
198        let inner = EngineInner {
199            env: std::sync::RwLock::new(env),
200            config,
201        };
202
203        Ok(Engine {
204            inner: Arc::new(inner),
205        })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::i18n::{I18n, I18nConfig};
213    use crate::template::TemplateConfig;
214
215    fn test_config(dir: &std::path::Path) -> TemplateConfig {
216        TemplateConfig {
217            templates_path: dir.join("templates").to_str().unwrap().into(),
218            static_path: dir.join("static").to_str().unwrap().into(),
219            ..TemplateConfig::default()
220        }
221    }
222
223    fn setup_templates(dir: &std::path::Path) {
224        let tpl_dir = dir.join("templates");
225        std::fs::create_dir_all(&tpl_dir).unwrap();
226        std::fs::write(tpl_dir.join("hello.html"), "Hello, {{ name }}!").unwrap();
227    }
228
229    fn setup_static(dir: &std::path::Path) {
230        let static_dir = dir.join("static/css");
231        std::fs::create_dir_all(&static_dir).unwrap();
232        std::fs::write(static_dir.join("app.css"), "body {}").unwrap();
233    }
234
235    fn test_i18n(dir: &std::path::Path) -> I18n {
236        let en_dir = dir.join("locales/en");
237        std::fs::create_dir_all(&en_dir).unwrap();
238        std::fs::write(en_dir.join("common.yaml"), "greeting: Hello").unwrap();
239
240        let config = I18nConfig {
241            locales_path: dir.join("locales").to_str().unwrap().into(),
242            default_locale: "en".into(),
243            ..I18nConfig::default()
244        };
245        I18n::new(&config).unwrap()
246    }
247
248    #[test]
249    fn build_engine_with_templates() {
250        let dir = tempfile::tempdir().unwrap();
251        setup_templates(dir.path());
252        setup_static(dir.path());
253
254        let config = test_config(dir.path());
255        let engine = Engine::builder().config(config).build().unwrap();
256        let result = engine
257            .render("hello.html", minijinja::context! { name => "World" })
258            .unwrap();
259        assert_eq!(result, "Hello, World!");
260    }
261
262    #[test]
263    fn engine_t_function_works() {
264        let dir = tempfile::tempdir().unwrap();
265        setup_static(dir.path());
266
267        let tpl_dir = dir.path().join("templates");
268        std::fs::create_dir_all(&tpl_dir).unwrap();
269        std::fs::write(tpl_dir.join("i18n.html"), "{{ t('common.greeting') }}").unwrap();
270
271        let config = test_config(dir.path());
272        let i18n = test_i18n(dir.path());
273        let engine = Engine::builder().config(config).i18n(i18n).build().unwrap();
274
275        // Render with locale in context
276        let result = engine
277            .render("i18n.html", minijinja::context! { locale => "en" })
278            .unwrap();
279        assert_eq!(result, "Hello");
280    }
281
282    #[test]
283    fn engine_without_i18n_does_not_register_t() {
284        let dir = tempfile::tempdir().unwrap();
285        setup_static(dir.path());
286
287        let tpl_dir = dir.path().join("templates");
288        std::fs::create_dir_all(&tpl_dir).unwrap();
289        std::fs::write(tpl_dir.join("i18n.html"), "{{ t('common.greeting') }}").unwrap();
290
291        let config = test_config(dir.path());
292        let engine = Engine::builder().config(config).build().unwrap();
293
294        // `t()` is not registered, so rendering should fail.
295        let result = engine.render("i18n.html", minijinja::context! { locale => "en" });
296        assert!(result.is_err());
297    }
298
299    #[test]
300    fn engine_static_url_function_works() {
301        let dir = tempfile::tempdir().unwrap();
302        setup_templates(dir.path());
303        setup_static(dir.path());
304
305        let tpl_dir = dir.path().join("templates");
306        std::fs::write(
307            tpl_dir.join("assets.html"),
308            "{{ static_url('css/app.css') }}",
309        )
310        .unwrap();
311
312        let config = test_config(dir.path());
313        let engine = Engine::builder().config(config).build().unwrap();
314
315        let result = engine
316            .render("assets.html", minijinja::context! {})
317            .unwrap();
318        assert!(result.starts_with("/assets/css/app.css?v="));
319        assert_eq!(result.len(), "/assets/css/app.css?v=".len() + 8);
320    }
321
322    #[test]
323    fn custom_function_registered() {
324        let dir = tempfile::tempdir().unwrap();
325        setup_static(dir.path());
326
327        let tpl_dir = dir.path().join("templates");
328        std::fs::create_dir_all(&tpl_dir).unwrap();
329        std::fs::write(tpl_dir.join("greet.html"), "{{ greet() }}").unwrap();
330
331        let config = test_config(dir.path());
332        let engine = Engine::builder()
333            .config(config)
334            .function("greet", || -> Result<String, minijinja::Error> {
335                Ok("Hi!".into())
336            })
337            .build()
338            .unwrap();
339
340        let result = engine.render("greet.html", minijinja::context! {}).unwrap();
341        assert_eq!(result, "Hi!");
342    }
343
344    #[test]
345    fn custom_filter_registered() {
346        let dir = tempfile::tempdir().unwrap();
347        setup_static(dir.path());
348
349        let tpl_dir = dir.path().join("templates");
350        std::fs::create_dir_all(&tpl_dir).unwrap();
351        std::fs::write(tpl_dir.join("shout.html"), r#"{{ "hello"|shout }}"#).unwrap();
352
353        let config = test_config(dir.path());
354        let engine = Engine::builder()
355            .config(config)
356            .filter("shout", |val: String| -> Result<String, minijinja::Error> {
357                Ok(val.to_uppercase())
358            })
359            .build()
360            .unwrap();
361
362        let result = engine.render("shout.html", minijinja::context! {}).unwrap();
363        assert_eq!(result, "HELLO");
364    }
365
366    #[test]
367    fn render_missing_template_returns_error() {
368        let dir = tempfile::tempdir().unwrap();
369        setup_static(dir.path());
370
371        let tpl_dir = dir.path().join("templates");
372        std::fs::create_dir_all(&tpl_dir).unwrap();
373
374        let config = test_config(dir.path());
375        let engine = Engine::builder().config(config).build().unwrap();
376
377        let result = engine.render("nonexistent.html", minijinja::context! {});
378        assert!(result.is_err());
379    }
380}