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::collections::HashMap;
7
8#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum TemplateDialect {
10 #[default]
11 Jinja,
12 Static,
13 Handlebars,
14}
15
16enum Engine {
17 Jinja {
18 env: Environment<'static>,
19 },
20 Static {
21 env: HashMap<String, String>,
22 },
23 Handlebars {
24 registry: Handlebars<'static>,
25 globals: HashMap<String, serde_json::Value>,
26 },
27}
28
29pub enum Template<'env, 'source> {
30 Jinja(JinjaTemplate<'env, 'source>),
31 Static(&'env str),
32 Handlebars {
33 engine: &'env TemplateEngine,
34 template: &'env HandlebarsTemplate,
35 },
36}
37
38impl<'env, 'source> Template<'env, 'source> {
39 pub fn render<S: Serialize>(&self, ctx: S) -> anyhow::Result<String> {
40 match &self {
41 Self::Jinja(t) => Ok(t.render(ctx)?),
42 Self::Static(s) => Ok(s.to_string()),
43 Self::Handlebars { .. } => {
44 let mut output: Vec<u8> = vec![];
45 self.render_to_write(&ctx, &mut output)?;
46 Ok(String::from_utf8(output)?)
47 }
48 }
49 }
50
51 pub fn render_to_write<S: Serialize, W: std::io::Write>(
52 &self,
53 ctx: S,
54 mut w: W,
55 ) -> anyhow::Result<()> {
56 match &self {
57 Self::Jinja(t) => {
58 t.render_to_write(ctx, w)?;
59 Ok(())
60 }
61 Self::Static(s) => {
62 w.write(s.as_bytes())?;
63 Ok(())
64 }
65 Self::Handlebars { engine, template } => {
66 let Engine::Handlebars { registry, globals } = &engine.engine else {
67 anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
68 };
69
70 let context = serde_json::to_value(ctx)?;
71 let context = merge_contexts(globals, context);
72 let context = handlebars::Context::wraps(context)?;
73
74 let mut render_context = handlebars::RenderContext::new(None);
75 render_context.set_recursive_lookup(true);
76 let is_html = template
77 .name
78 .as_deref()
79 .map(|name| name.ends_with(".html"))
80 .unwrap_or(false);
81 render_context.set_disable_escape(!is_html);
82
83 let output = template.renders(registry, &context, &mut render_context)?;
84 w.write_all(output.as_bytes())?;
85 Ok(())
86 }
87 }
88 }
89}
90
91pub struct TemplateEngine {
93 engine: Engine,
94}
95
96impl TemplateEngine {
97 pub fn new() -> Self {
98 Self::with_dialect(TemplateDialect::default())
99 }
100
101 pub fn with_dialect(dialect: TemplateDialect) -> Self {
102 match dialect {
103 TemplateDialect::Jinja => {
104 let mut env = Environment::new();
105 env.set_unknown_method_callback(
106 minijinja_contrib::pycompat::unknown_method_callback,
107 );
108 add_to_environment(&mut env);
109
110 env.add_filter(
111 "normalize_smtp_response",
112 mod_smtp_response_normalize::normalize,
113 );
114
115 Self {
116 engine: Engine::Jinja { env },
117 }
118 }
119 TemplateDialect::Static => Self {
120 engine: Engine::Static {
121 env: HashMap::new(),
122 },
123 },
124 TemplateDialect::Handlebars => {
125 let mut registry = Handlebars::new();
126 registry.set_recursive_lookup(true);
127 Self {
128 engine: Engine::Handlebars {
129 registry,
130 globals: HashMap::new(),
131 },
132 }
133 }
134 }
135 }
136
137 pub fn add_template<N, S>(&mut self, name: N, source: S) -> anyhow::Result<()>
141 where
142 N: Into<String>,
143 S: Into<String>,
144 {
145 match &mut self.engine {
146 Engine::Jinja { env } => Ok(env.add_template_owned(name.into(), source.into())?),
147 Engine::Static { env } => {
148 env.insert(name.into(), source.into());
149 Ok(())
150 }
151 Engine::Handlebars { registry, .. } => {
152 registry.register_template_string(&name.into(), source.into())?;
153 Ok(())
154 }
155 }
156 }
157
158 pub fn get_template(&self, name: &str) -> anyhow::Result<Template<'_, '_>> {
160 match &self.engine {
161 Engine::Jinja { env } => Ok(Template::Jinja(env.get_template(name)?)),
162 Engine::Static { env } => {
163 Ok(Template::Static(env.get(name).ok_or_else(|| {
164 anyhow::anyhow!("template {name} is not defined")
165 })?))
166 }
167 Engine::Handlebars { registry, .. } => {
168 let template = registry
169 .get_template(name)
170 .ok_or_else(|| anyhow::anyhow!("template {name} is not defined"))?;
171
172 Ok(Template::Handlebars {
173 engine: self,
174 template,
175 })
176 }
177 }
178 }
179
180 pub fn add_global<N, V>(&mut self, name: N, value: V) -> anyhow::Result<()>
182 where
183 N: Into<String>,
184 V: Serialize,
185 {
186 match &mut self.engine {
187 Engine::Jinja { env } => env.add_global(name.into(), JinjaValue::from_serialize(value)),
188 Engine::Static { .. } => { }
189 Engine::Handlebars { globals, .. } => {
190 globals.insert(name.into(), serde_json::to_value(value)?);
191 }
192 }
193
194 Ok(())
195 }
196
197 pub fn render<CTX>(&self, name: &str, source: &str, context: CTX) -> anyhow::Result<String>
198 where
199 CTX: serde::Serialize,
200 {
201 match &self.engine {
202 Engine::Jinja { env } => Ok(env.render_named_str(name, source, context)?),
203 Engine::Static { .. } => Ok(source.to_string()),
204 Engine::Handlebars { .. } => {
205 let template = HandlebarsTemplate::compile_with_name(source, name.to_string())?;
206 let template = Template::Handlebars {
207 engine: self,
208 template: &template,
209 };
210
211 template.render(context)
212 }
213 }
214 }
215}
216
217fn merge_contexts(
218 globals: &HashMap<String, serde_json::Value>,
219 over: serde_json::Value,
220) -> serde_json::Value {
221 match over {
222 serde_json::Value::Object(mut obj) => {
223 for (k, v) in globals {
224 if !obj.contains_key(k) {
225 obj.insert(k.into(), v.clone());
226 }
227 }
228 serde_json::Value::Object(obj)
229 }
230 other => other,
231 }
232}
233
234pub type TemplateList<'a> = Vec<Template<'a, 'a>>;
235
236self_cell!(
237 pub struct CompiledTemplates {
259 owner: TemplateEngine,
260 #[covariant]
261 dependent: TemplateList,
262 }
263);