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::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
91/// Holds a set of templates
92pub 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    /// Add a named template with the specified source.
138    /// If name ends with `.html` then automatical escaping of html entities
139    /// will be performed on substitutions.
140    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    /// Get a reference to a named template
159    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    /// Define a global value that can be reference by all templates
181    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 { .. } => { /* NOP */ }
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    /// CompiledTemplates is useful when you have a set of templates
238    /// that you will expand frequently in a tight loop.
239    /// Because the underlying crate returns only references to `Template`s,
240    /// it is a covariant, self-referential structure that needs to be
241    /// constructed like this:
242    ///
243    /// ```rust
244    /// fn get_templates<'b>(
245    ///   engine: &'b TemplateEngine
246    /// ) -> anyhow::Result<TemplateList<'b>> {
247    ///   let mut templates = vec![];
248    ///   templates.push(engine.get_template("something")?);
249    ///   Ok(templates)
250    /// }
251    ///
252    /// let engine = TemplateEngine::new();
253    /// engine.add_template("something", "some text")?;
254    /// let compiled = CompiledTemplates::try_new(engine, |engine| {
255    ///   get_templates(engine)
256    /// });
257    /// ```
258    pub struct CompiledTemplates {
259        owner: TemplateEngine,
260        #[covariant]
261        dependent: TemplateList,
262    }
263);