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#[derive(Clone)]
33pub struct Engine {
34 inner: Arc<EngineInner>,
35}
36
37impl Engine {
38 pub fn builder() -> EngineBuilder {
43 EngineBuilder::default()
44 }
45
46 pub(crate) fn render(
51 &self,
52 template_name: &str,
53 context: minijinja::Value,
54 ) -> crate::Result<String> {
55 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 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#[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 pub fn config(mut self, config: TemplateConfig) -> Self {
109 self.config = Some(config);
110 self
111 }
112
113 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 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 pub fn i18n(mut self, i18n: I18n) -> Self {
150 self.i18n = Some(i18n);
151 self
152 }
153
154 pub fn build(self) -> crate::Result<Engine> {
166 let config = self.config.unwrap_or_default();
167
168 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 minijinja_contrib::add_to_environment(&mut env);
175
176 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 let static_path = Path::new(&config.static_path);
184 let static_hashes = static_files::compute_hashes(static_path)?;
185
186 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 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 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 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}