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(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 env: HashMap<String, String>,
24 },
25 Handlebars {
26 registry: Handlebars<'static>,
27 globals: HashMap<String, serde_json::Value>,
28 },
29}
30
31pub enum Template<'env, 'source> {
32 Jinja(JinjaTemplate<'env, 'source>),
33 Static(&'env str),
34 Handlebars {
35 engine: &'env TemplateEngine,
36 template: &'env HandlebarsTemplate,
37 },
38}
39
40impl<'env, 'source> Template<'env, 'source> {
41 pub fn render<S: Serialize>(&self, ctx: S) -> anyhow::Result<String> {
42 match &self {
43 Self::Jinja(t) => Ok(t.render(ctx)?),
44 Self::Static(s) => Ok(s.to_string()),
45 Self::Handlebars { .. } => {
46 let mut output: Vec<u8> = vec![];
47 self.render_to_write(&ctx, &mut output)?;
48 Ok(String::from_utf8(output)?)
49 }
50 }
51 }
52
53 pub fn render_to_write<S: Serialize, W: std::io::Write>(
54 &self,
55 ctx: S,
56 mut w: W,
57 ) -> anyhow::Result<()> {
58 match &self {
59 Self::Jinja(t) => {
60 t.render_to_write(ctx, w)?;
61 Ok(())
62 }
63 Self::Static(s) => {
64 w.write(s.as_bytes())?;
65 Ok(())
66 }
67 Self::Handlebars { engine, template } => {
68 let Engine::Handlebars { registry, globals } = &engine.engine else {
69 anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
70 };
71
72 let context = serde_json::to_value(ctx)?;
73 let context = merge_contexts(globals, context);
74 let context = handlebars::Context::wraps(context)?;
75
76 let mut render_context = handlebars::RenderContext::new(None);
77 render_context.set_recursive_lookup(true);
78 let is_html = template
79 .name
80 .as_deref()
81 .map(|name| name.ends_with(".html"))
82 .unwrap_or(false);
83 render_context.set_disable_escape(!is_html);
84
85 let output = template.renders(registry, &context, &mut render_context)?;
86 w.write_all(output.as_bytes())?;
87 Ok(())
88 }
89 }
90 }
91}
92
93pub struct TemplateEngine {
95 engine: Engine,
96}
97
98impl TemplateEngine {
99 pub fn new() -> Self {
100 Self::with_dialect(TemplateDialect::default())
101 }
102
103 pub fn with_dialect(dialect: TemplateDialect) -> Self {
104 match dialect {
105 TemplateDialect::Jinja => {
106 let mut env = Environment::new();
107 env.set_unknown_method_callback(
108 minijinja_contrib::pycompat::unknown_method_callback,
109 );
110 add_to_environment(&mut env);
111
112 env.add_filter(
113 "normalize_smtp_response",
114 mod_smtp_response_normalize::normalize,
115 );
116
117 Self {
118 engine: Engine::Jinja { env },
119 }
120 }
121 TemplateDialect::Static => Self {
122 engine: Engine::Static {
123 env: HashMap::new(),
124 },
125 },
126 TemplateDialect::Handlebars => {
127 let mut registry = Handlebars::new();
128 registry.set_recursive_lookup(true);
129 Self {
130 engine: Engine::Handlebars {
131 registry,
132 globals: HashMap::new(),
133 },
134 }
135 }
136 }
137 }
138
139 pub fn add_template<N, S>(&mut self, name: N, source: S) -> anyhow::Result<()>
143 where
144 N: Into<String>,
145 S: Into<String>,
146 {
147 match &mut self.engine {
148 Engine::Jinja { env } => {
149 let source: Cow<'_, str> = source.into().into();
150
151 Ok(env
152 .add_template_owned(name.into(), source.clone())
153 .map_err(|err| {
154 let mut reason = String::new();
155
156 if let Some(detail) = err.detail() {
157 write!(&mut reason, "{}: {}", err.kind(), detail).ok();
158 } else {
159 write!(&mut reason, "{}", err.kind()).ok();
160 }
161
162 if let Some((line_no, source_line)) = err.line().and_then(|line| {
163 source
164 .lines()
165 .nth(line - 1) .map(|source_line| (line, source_line))
167 }) {
168 let truncated_line =
169 &source_line[..source_line.ceil_char_boundary(1024)];
170
171 if let Some(name) = err.name() {
172 write!(
173 &mut reason,
174 " (in template '{name}' line {line_no}: '{truncated_line}')"
175 )
176 .ok();
177 } else {
178 write!(
179 &mut reason,
180 " (in template line {line_no}: '{truncated_line}')"
181 )
182 .ok();
183 }
184 }
185
186 anyhow::anyhow!("{reason}")
187 })?)
188 }
189 Engine::Static { env } => {
190 env.insert(name.into(), source.into());
191 Ok(())
192 }
193 Engine::Handlebars { registry, .. } => {
194 registry.register_template_string(&name.into(), source.into())?;
195 Ok(())
196 }
197 }
198 }
199
200 pub fn get_template(&self, name: &str) -> anyhow::Result<Template<'_, '_>> {
202 match &self.engine {
203 Engine::Jinja { env } => Ok(Template::Jinja(env.get_template(name)?)),
204 Engine::Static { env } => {
205 Ok(Template::Static(env.get(name).ok_or_else(|| {
206 anyhow::anyhow!("template {name} is not defined")
207 })?))
208 }
209 Engine::Handlebars { registry, .. } => {
210 let template = registry
211 .get_template(name)
212 .ok_or_else(|| anyhow::anyhow!("template {name} is not defined"))?;
213
214 Ok(Template::Handlebars {
215 engine: self,
216 template,
217 })
218 }
219 }
220 }
221
222 pub fn add_global<N, V>(&mut self, name: N, value: V) -> anyhow::Result<()>
224 where
225 N: Into<String>,
226 V: Serialize,
227 {
228 match &mut self.engine {
229 Engine::Jinja { env } => env.add_global(name.into(), JinjaValue::from_serialize(value)),
230 Engine::Static { .. } => { }
231 Engine::Handlebars { globals, .. } => {
232 globals.insert(name.into(), serde_json::to_value(value)?);
233 }
234 }
235
236 Ok(())
237 }
238
239 pub fn render<CTX>(&self, name: &str, source: &str, context: CTX) -> anyhow::Result<String>
240 where
241 CTX: serde::Serialize,
242 {
243 match &self.engine {
244 Engine::Jinja { env } => Ok(env.render_named_str(name, source, context)?),
245 Engine::Static { .. } => Ok(source.to_string()),
246 Engine::Handlebars { .. } => {
247 let template = HandlebarsTemplate::compile_with_name(source, name.to_string())?;
248 let template = Template::Handlebars {
249 engine: self,
250 template: &template,
251 };
252
253 template.render(context)
254 }
255 }
256 }
257}
258
259fn merge_contexts(
260 globals: &HashMap<String, serde_json::Value>,
261 over: serde_json::Value,
262) -> serde_json::Value {
263 match over {
264 serde_json::Value::Object(mut obj) => {
265 for (k, v) in globals {
266 if !obj.contains_key(k) {
267 obj.insert(k.into(), v.clone());
268 }
269 }
270 serde_json::Value::Object(obj)
271 }
272 other => other,
273 }
274}
275
276pub type TemplateList<'a> = Vec<Template<'a, 'a>>;
277
278self_cell!(
279 pub struct CompiledTemplates {
301 owner: TemplateEngine,
302 #[covariant]
303 dependent: TemplateList,
304 }
305);