Skip to main content

modo/template/
engine.rs

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