kumo_template/
lib.rs

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
93/// Holds a set of templates
94pub 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    /// Add a named template with the specified source.
140    /// If name ends with `.html` then automatical escaping of html entities
141    /// will be performed on substitutions.
142    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) // err.line() is 1-based
166                                .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    /// Get a reference to a named template
201    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    /// Define a global value that can be reference by all templates
223    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 { .. } => { /* NOP */ }
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    /// CompiledTemplates is useful when you have a set of templates
280    /// that you will expand frequently in a tight loop.
281    /// Because the underlying crate returns only references to `Template`s,
282    /// it is a covariant, self-referential structure that needs to be
283    /// constructed like this:
284    ///
285    /// ```rust
286    /// fn get_templates<'b>(
287    ///   engine: &'b TemplateEngine
288    /// ) -> anyhow::Result<TemplateList<'b>> {
289    ///   let mut templates = vec![];
290    ///   templates.push(engine.get_template("something")?);
291    ///   Ok(templates)
292    /// }
293    ///
294    /// let engine = TemplateEngine::new();
295    /// engine.add_template("something", "some text")?;
296    /// let compiled = CompiledTemplates::try_new(engine, |engine| {
297    ///   get_templates(engine)
298    /// });
299    /// ```
300    pub struct CompiledTemplates {
301        owner: TemplateEngine,
302        #[covariant]
303        dependent: TemplateList,
304    }
305);