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                let is_html = template
76                    .name
77                    .as_deref()
78                    .map(|name| name.ends_with(".html"))
79                    .unwrap_or(false);
80                render_context.set_disable_escape(!is_html);
81
82                let output = template.renders(registry, &context, &mut render_context)?;
83                w.write_all(output.as_bytes())?;
84                Ok(())
85            }
86        }
87    }
88}
89
90/// Holds a set of templates
91pub struct TemplateEngine {
92    engine: Engine,
93}
94
95impl TemplateEngine {
96    pub fn new() -> Self {
97        Self::with_dialect(TemplateDialect::default())
98    }
99
100    pub fn with_dialect(dialect: TemplateDialect) -> Self {
101        match dialect {
102            TemplateDialect::Jinja => {
103                let mut env = Environment::new();
104                env.set_unknown_method_callback(
105                    minijinja_contrib::pycompat::unknown_method_callback,
106                );
107                add_to_environment(&mut env);
108
109                env.add_filter(
110                    "normalize_smtp_response",
111                    mod_smtp_response_normalize::normalize,
112                );
113
114                Self {
115                    engine: Engine::Jinja { env },
116                }
117            }
118            TemplateDialect::Static => Self {
119                engine: Engine::Static {
120                    env: HashMap::new(),
121                },
122            },
123            TemplateDialect::Handlebars => Self {
124                engine: Engine::Handlebars {
125                    registry: Handlebars::new(),
126                    globals: HashMap::new(),
127                },
128            },
129        }
130    }
131
132    /// Add a named template with the specified source.
133    /// If name ends with `.html` then automatical escaping of html entities
134    /// will be performed on substitutions.
135    pub fn add_template<N, S>(&mut self, name: N, source: S) -> anyhow::Result<()>
136    where
137        N: Into<String>,
138        S: Into<String>,
139    {
140        match &mut self.engine {
141            Engine::Jinja { env } => Ok(env.add_template_owned(name.into(), source.into())?),
142            Engine::Static { env } => {
143                env.insert(name.into(), source.into());
144                Ok(())
145            }
146            Engine::Handlebars { registry, .. } => {
147                registry.register_template_string(&name.into(), source.into())?;
148                Ok(())
149            }
150        }
151    }
152
153    /// Get a reference to a named template
154    pub fn get_template(&self, name: &str) -> anyhow::Result<Template<'_, '_>> {
155        match &self.engine {
156            Engine::Jinja { env } => Ok(Template::Jinja(env.get_template(name)?)),
157            Engine::Static { env } => {
158                Ok(Template::Static(env.get(name).ok_or_else(|| {
159                    anyhow::anyhow!("template {name} is not defined")
160                })?))
161            }
162            Engine::Handlebars { registry, .. } => {
163                let template = registry
164                    .get_template(name)
165                    .ok_or_else(|| anyhow::anyhow!("template {name} is not defined"))?;
166
167                Ok(Template::Handlebars {
168                    engine: self,
169                    template,
170                })
171            }
172        }
173    }
174
175    /// Define a global value that can be reference by all templates
176    pub fn add_global<N, V>(&mut self, name: N, value: V) -> anyhow::Result<()>
177    where
178        N: Into<String>,
179        V: Serialize,
180    {
181        match &mut self.engine {
182            Engine::Jinja { env } => env.add_global(name.into(), JinjaValue::from_serialize(value)),
183            Engine::Static { .. } => { /* NOP */ }
184            Engine::Handlebars { globals, .. } => {
185                globals.insert(name.into(), serde_json::to_value(value)?);
186            }
187        }
188
189        Ok(())
190    }
191
192    pub fn render<CTX>(&self, name: &str, source: &str, context: CTX) -> anyhow::Result<String>
193    where
194        CTX: serde::Serialize,
195    {
196        match &self.engine {
197            Engine::Jinja { env } => Ok(env.render_named_str(name, source, context)?),
198            Engine::Static { .. } => Ok(source.to_string()),
199            Engine::Handlebars { .. } => {
200                let template = HandlebarsTemplate::compile_with_name(source, name.to_string())?;
201                let template = Template::Handlebars {
202                    engine: self,
203                    template: &template,
204                };
205
206                template.render(context)
207            }
208        }
209    }
210}
211
212fn merge_contexts(
213    globals: &HashMap<String, serde_json::Value>,
214    over: serde_json::Value,
215) -> serde_json::Value {
216    match over {
217        serde_json::Value::Object(mut obj) => {
218            for (k, v) in globals {
219                if !obj.contains_key(k) {
220                    obj.insert(k.into(), v.clone());
221                }
222            }
223            serde_json::Value::Object(obj)
224        }
225        other => other,
226    }
227}
228
229pub type TemplateList<'a> = Vec<Template<'a, 'a>>;
230
231self_cell!(
232    /// CompiledTemplates is useful when you have a set of templates
233    /// that you will expand frequently in a tight loop.
234    /// Because the underlying crate returns only references to `Template`s,
235    /// it is a covariant, self-referential structure that needs to be
236    /// constructed like this:
237    ///
238    /// ```rust
239    /// fn get_templates<'b>(
240    ///   engine: &'b TemplateEngine
241    /// ) -> anyhow::Result<TemplateList<'b>> {
242    ///   let mut templates = vec![];
243    ///   templates.push(engine.get_template("something")?);
244    ///   Ok(templates)
245    /// }
246    ///
247    /// let engine = TemplateEngine::new();
248    /// engine.add_template("something", "some text")?;
249    /// let compiled = CompiledTemplates::try_new(engine, |engine| {
250    ///   get_templates(engine)
251    /// });
252    /// ```
253    pub struct CompiledTemplates {
254        owner: TemplateEngine,
255        #[covariant]
256        dependent: TemplateList,
257    }
258);