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#[derive(Clone)]
34pub struct Engine {
35 inner: Arc<EngineInner>,
36}
37
38impl Engine {
39 pub fn builder() -> EngineBuilder {
41 EngineBuilder::default()
42 }
43
44 pub(crate) fn render(
49 &self,
50 template_name: &str,
51 context: minijinja::Value,
52 ) -> crate::Result<String> {
53 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 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#[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 pub fn config(mut self, config: TemplateConfig) -> Self {
115 self.config = Some(config);
116 self
117 }
118
119 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 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 pub fn locale_resolvers(mut self, resolvers: Vec<Arc<dyn LocaleResolver>>) -> Self {
159 self.locale_resolvers = Some(resolvers);
160 self
161 }
162
163 pub fn build(self) -> crate::Result<Engine> {
170 let config = self.config.unwrap_or_default();
171
172 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 minijinja_contrib::add_to_environment(&mut env);
179
180 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 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 let static_path = Path::new(&config.static_path);
199 let static_hashes = static_files::compute_hashes(static_path)?;
200
201 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 for customizer in self.customizers {
210 customizer(&mut env);
211 }
212
213 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 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 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}