1use handlebars::{Handlebars, Renderable, Template as HandlebarsTemplate};
2use minijinja::{Environment, Template as JinjaTemplate, Value as JinjaValue};
3use minijinja_contrib::add_to_environment;
4use self_cell::self_cell;
5use serde::{Deserialize, Serialize};
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::fmt::Write;
9
10#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum TemplateDialect {
12 #[default]
13 Jinja,
14 Static,
15 Handlebars,
16}
17
18enum Engine {
19 Jinja {
20 env: Environment<'static>,
21 },
22 Static,
23 Handlebars {
24 registry: Handlebars<'static>,
25 globals: HashMap<String, serde_json::Value>,
26 },
27}
28
29fn source_is_static(dialect: TemplateDialect, source: &str) -> bool {
32 match dialect {
33 TemplateDialect::Static => true,
34 TemplateDialect::Handlebars => !source.contains("{{"),
35 TemplateDialect::Jinja => {
36 !(source.contains("{{") || source.contains("{%") || source.contains("{#"))
37 }
38 }
39}
40
41fn trim_static_source(dialect: TemplateDialect, source: &str) -> &str {
46 if dialect == TemplateDialect::Jinja {
47 let s = source.strip_suffix('\n').unwrap_or(source);
48 s.strip_suffix('\r').unwrap_or(s)
49 } else {
50 source
51 }
52}
53
54pub enum Template<'env, 'source> {
55 Jinja(JinjaTemplate<'env, 'source>),
56 Static(&'env str),
57 Handlebars {
58 engine: &'env TemplateEngine,
59 template: &'env HandlebarsTemplate,
60 },
61}
62
63impl<'env, 'source> Template<'env, 'source> {
64 pub fn is_static(&self) -> bool {
67 matches!(self, Self::Static(_))
68 }
69
70 pub fn render<S: Serialize>(&self, ctx: S) -> anyhow::Result<String> {
71 match &self {
72 Self::Jinja(t) => Ok(t.render(ctx)?),
73 Self::Static(s) => Ok(s.to_string()),
74 Self::Handlebars { .. } => {
75 let mut output: Vec<u8> = vec![];
76 self.render_to_write(&ctx, &mut output)?;
77 Ok(String::from_utf8(output)?)
78 }
79 }
80 }
81
82 pub fn render_to_write<S: Serialize, W: std::io::Write>(
83 &self,
84 ctx: S,
85 mut w: W,
86 ) -> anyhow::Result<()> {
87 match &self {
88 Self::Jinja(t) => {
89 t.render_captured_to(ctx, w)?;
90 Ok(())
91 }
92 Self::Static(s) => {
93 w.write_all(s.as_bytes())?;
94 Ok(())
95 }
96 Self::Handlebars { engine, template } => {
97 let Engine::Handlebars { registry, globals } = &engine.engine else {
98 anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
99 };
100
101 let context = serde_json::to_value(ctx)?;
102 let context = merge_contexts(globals, context);
103 let context = handlebars::Context::wraps(context)?;
104
105 let output = render_handlebars(registry, template, &context)?;
106 w.write_all(output.as_bytes())?;
107 Ok(())
108 }
109 }
110 }
111
112 pub fn render_prepared(&self, ctx: &PreparedContext) -> anyhow::Result<String> {
115 match (self, ctx) {
116 (Self::Jinja(t), PreparedContext::Jinja(value)) => Ok(t.render(value)?),
117 (Self::Static(s), _) => Ok(s.to_string()),
118 (Self::Handlebars { engine, template }, PreparedContext::Handlebars(context)) => {
119 let Engine::Handlebars { registry, .. } = &engine.engine else {
120 anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
121 };
122 render_handlebars(registry, template, context)
123 }
124 _ => anyhow::bail!("prepared context dialect does not match template dialect"),
125 }
126 }
127}
128
129fn render_handlebars(
132 registry: &Handlebars<'static>,
133 template: &HandlebarsTemplate,
134 context: &handlebars::Context,
135) -> anyhow::Result<String> {
136 let mut render_context = handlebars::RenderContext::new(None);
137 render_context.set_recursive_lookup(true);
138 let is_html = template
139 .name
140 .as_deref()
141 .map(|name| name.ends_with(".html"))
142 .unwrap_or(false);
143 render_context.set_disable_escape(!is_html);
144
145 Ok(template.renders(registry, context, &mut render_context)?)
146}
147
148pub enum PreparedContext {
152 Jinja(JinjaValue),
153 Static,
154 Handlebars(handlebars::Context),
155}
156
157pub struct TemplateEngine {
159 dialect: TemplateDialect,
160 engine: Engine,
161 literals: HashMap<String, String>,
162}
163
164impl TemplateEngine {
165 pub fn new() -> Self {
166 Self::with_dialect(TemplateDialect::default())
167 }
168
169 pub fn with_dialect(dialect: TemplateDialect) -> Self {
170 let engine = match dialect {
171 TemplateDialect::Jinja => {
172 let mut env = Environment::new();
173 env.set_unknown_method_callback(
174 minijinja_contrib::pycompat::unknown_method_callback,
175 );
176 add_to_environment(&mut env);
177
178 env.add_filter(
179 "normalize_smtp_response",
180 mod_smtp_response_normalize::normalize,
181 );
182
183 Engine::Jinja { env }
184 }
185 TemplateDialect::Static => Engine::Static,
186 TemplateDialect::Handlebars => {
187 let mut registry = Handlebars::new();
188 registry.set_recursive_lookup(true);
189 Engine::Handlebars {
190 registry,
191 globals: HashMap::new(),
192 }
193 }
194 };
195 Self {
196 dialect,
197 engine,
198 literals: HashMap::new(),
199 }
200 }
201
202 pub fn add_template<N, S>(&mut self, name: N, source: S) -> anyhow::Result<()>
210 where
211 N: Into<String>,
212 S: Into<String>,
213 {
214 let name = name.into();
215 let mut source = source.into();
216
217 if source_is_static(self.dialect, &source) {
218 source.truncate(trim_static_source(self.dialect, &source).len());
219 self.literals.insert(name, source);
220 return Ok(());
221 }
222
223 match &mut self.engine {
224 Engine::Jinja { env } => {
225 let source: Cow<'_, str> = source.into();
226
227 Ok(env
228 .add_template_owned(name, source.clone())
229 .map_err(|err| {
230 let mut reason = String::new();
231
232 if let Some(detail) = err.detail() {
233 write!(&mut reason, "{}: {}", err.kind(), detail).ok();
234 } else {
235 write!(&mut reason, "{}", err.kind()).ok();
236 }
237
238 if let Some((line_no, source_line)) = err.line().and_then(|line| {
239 source
240 .lines()
241 .nth(line - 1) .map(|source_line| (line, source_line))
243 }) {
244 let truncated_line =
245 &source_line[..source_line.ceil_char_boundary(1024)];
246
247 if let Some(name) = err.name() {
248 write!(
249 &mut reason,
250 " (in template '{name}' line {line_no}: '{truncated_line}')"
251 )
252 .ok();
253 } else {
254 write!(
255 &mut reason,
256 " (in template line {line_no}: '{truncated_line}')"
257 )
258 .ok();
259 }
260 }
261
262 anyhow::anyhow!("{reason}")
263 })?)
264 }
265 Engine::Static => anyhow::bail!("Static dialect cannot hold a dynamic template"),
266 Engine::Handlebars { registry, .. } => {
267 registry.register_template_string(&name, source)?;
268 Ok(())
269 }
270 }
271 }
272
273 pub fn get_template(&self, name: &str) -> anyhow::Result<Template<'_, '_>> {
275 if let Some(source) = self.literals.get(name) {
276 return Ok(Template::Static(source));
277 }
278 match &self.engine {
279 Engine::Jinja { env } => Ok(Template::Jinja(env.get_template(name)?)),
280 Engine::Static => anyhow::bail!("template {name} is not defined"),
281 Engine::Handlebars { registry, .. } => {
282 let template = registry
283 .get_template(name)
284 .ok_or_else(|| anyhow::anyhow!("template {name} is not defined"))?;
285
286 Ok(Template::Handlebars {
287 engine: self,
288 template,
289 })
290 }
291 }
292 }
293
294 pub fn prepare_context<CTX>(&self, context: CTX) -> anyhow::Result<PreparedContext>
297 where
298 CTX: Serialize,
299 {
300 match &self.engine {
301 Engine::Jinja { .. } => Ok(PreparedContext::Jinja(JinjaValue::from_serialize(context))),
302 Engine::Static => Ok(PreparedContext::Static),
303 Engine::Handlebars { globals, .. } => {
304 let context = serde_json::to_value(context)?;
305 let context = merge_contexts(globals, context);
306 Ok(PreparedContext::Handlebars(handlebars::Context::wraps(
307 context,
308 )?))
309 }
310 }
311 }
312
313 pub fn add_global<N, V>(&mut self, name: N, value: V) -> anyhow::Result<()>
315 where
316 N: Into<String>,
317 V: Serialize,
318 {
319 match &mut self.engine {
320 Engine::Jinja { env } => env.add_global(name.into(), JinjaValue::from_serialize(value)),
321 Engine::Static => { }
322 Engine::Handlebars { globals, .. } => {
323 globals.insert(name.into(), serde_json::to_value(value)?);
324 }
325 }
326
327 Ok(())
328 }
329
330 pub fn render<CTX>(&self, name: &str, source: &str, context: CTX) -> anyhow::Result<String>
331 where
332 CTX: serde::Serialize,
333 {
334 if source_is_static(self.dialect, source) {
335 return Ok(trim_static_source(self.dialect, source).to_string());
336 }
337 match &self.engine {
338 Engine::Jinja { env } => Ok(env.render_named_str(name, source, context)?),
339 Engine::Static => Ok(source.to_string()),
340 Engine::Handlebars { .. } => {
341 let template = HandlebarsTemplate::compile_with_name(source, name.to_string())?;
342 let template = Template::Handlebars {
343 engine: self,
344 template: &template,
345 };
346
347 template.render(context)
348 }
349 }
350 }
351}
352
353fn merge_contexts(
354 globals: &HashMap<String, serde_json::Value>,
355 over: serde_json::Value,
356) -> serde_json::Value {
357 match over {
358 serde_json::Value::Object(mut obj) => {
359 for (k, v) in globals {
360 if !obj.contains_key(k) {
361 obj.insert(k.into(), v.clone());
362 }
363 }
364 serde_json::Value::Object(obj)
365 }
366 other => other,
367 }
368}
369
370pub type TemplateList<'a> = Vec<Template<'a, 'a>>;
371
372self_cell!(
373 pub struct CompiledTemplates {
395 owner: TemplateEngine,
396 #[covariant]
397 dependent: TemplateList,
398 }
399);
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use serde_json::json;
405
406 #[test]
409 fn marker_free_source_is_static() {
410 for dialect in [
411 TemplateDialect::Jinja,
412 TemplateDialect::Handlebars,
413 TemplateDialect::Static,
414 ] {
415 let mut engine = TemplateEngine::with_dialect(dialect);
416 engine
417 .add_template("h", "In-Reply-To: <abc@example.com>")
418 .unwrap();
419 let t = engine.get_template("h").unwrap();
420 assert!(
421 t.is_static(),
422 "{dialect:?} should treat marker-free source as static"
423 );
424 k9::assert_equal!(
425 t.render(json!({"name": "ignored"})).unwrap(),
426 "In-Reply-To: <abc@example.com>".to_string()
427 );
428 }
429 }
430
431 #[test]
435 fn static_render_matches_engine_on_trailing_newline() {
436 let jinja_cases = [
439 ("text\n", "text"),
440 ("text\r\n", "text"),
441 ("text\n\n", "text\n"),
442 ("text", "text"),
443 ];
444 for (src, expected) in jinja_cases {
445 let mut engine = TemplateEngine::with_dialect(TemplateDialect::Jinja);
446 engine.add_template("t", src).unwrap();
447 let out = engine.get_template("t").unwrap().render(json!({})).unwrap();
448 k9::assert_equal!(out, expected.to_string());
449 }
450
451 let hbs_cases = [
452 ("text\n", "text\n"),
453 ("text\r\n", "text\r\n"),
454 ("text\n\n", "text\n\n"),
455 ("text", "text"),
456 ];
457 for (src, expected) in hbs_cases {
458 let mut engine = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
459 engine.add_template("t", src).unwrap();
460 let out = engine.get_template("t").unwrap().render(json!({})).unwrap();
461 k9::assert_equal!(out, expected.to_string());
462 }
463
464 let jinja2 = TemplateEngine::with_dialect(TemplateDialect::Jinja);
467 k9::assert_equal!(
468 jinja2.render("t", "text\n", json!({})).unwrap(),
469 "text".to_string()
470 );
471 }
472
473 #[test]
476 fn render_prepared_matches_render() {
477 for dialect in [
478 TemplateDialect::Jinja,
479 TemplateDialect::Handlebars,
480 TemplateDialect::Static,
481 ] {
482 let mut engine = TemplateEngine::with_dialect(dialect);
483 engine.add_template("dyn", "hello {{ name }}").unwrap();
484 engine.add_template("lit", "no markers here").unwrap();
485
486 let ctx = json!({"name": "Alice"});
487 let prepared = engine.prepare_context(&ctx).unwrap();
488 for key in ["dyn", "lit"] {
489 let template = engine.get_template(key).unwrap();
490 k9::assert_equal!(
491 template.render_prepared(&prepared).unwrap(),
492 template.render(&ctx).unwrap()
493 );
494 }
495 }
496 }
497
498 #[test]
502 fn marked_source_is_dynamic() {
503 let mut jinja = TemplateEngine::with_dialect(TemplateDialect::Jinja);
504 jinja.add_template("h", "hello {{ name }}").unwrap();
505 let t = jinja.get_template("h").unwrap();
506 assert!(!t.is_static());
507 k9::assert_equal!(
508 t.render(json!({"name": "Alice"})).unwrap(),
509 "hello Alice".to_string()
510 );
511
512 let mut hbs = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
513 hbs.add_template("h", "hello {{ name }}").unwrap();
514 assert!(!hbs.get_template("h").unwrap().is_static());
515
516 let mut j2 = TemplateEngine::with_dialect(TemplateDialect::Jinja);
518 j2.add_template("c", "{# comment #}kept").unwrap();
519 assert!(!j2.get_template("c").unwrap().is_static());
520 }
521
522 #[test]
525 fn html_escaping_applies_only_to_substitutions() {
526 let literal = "<b>bold & <i>italic</i></b>";
527
528 let mut engine = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
529 engine.add_template("static.html", literal).unwrap();
530 let static_template = engine.get_template("static.html").unwrap();
531 assert!(static_template.is_static());
532 k9::assert_equal!(
533 static_template.render(json!({})).unwrap(),
534 literal.to_string()
535 );
536
537 engine
538 .add_template("dynamic.html", "<b>{{ v }}</b>")
539 .unwrap();
540 let dynamic_template = engine.get_template("dynamic.html").unwrap();
541 assert!(!dynamic_template.is_static());
542 k9::assert_equal!(
543 dynamic_template.render(json!({"v": "a & b"})).unwrap(),
544 "<b>a & b</b>".to_string()
545 );
546 }
547}