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(Debug, 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    Handlebars {
24        registry: Handlebars<'static>,
25        globals: HashMap<String, serde_json::Value>,
26    },
27}
28
29/// Returns true when `source` contains none of the expansion markers of
30/// `dialect`, meaning the engine would render it with no substitution.
31fn source_is_static(dialect: TemplateDialect, source: &str) -> bool {
32    match dialect {
33        TemplateDialect::Static => true,
34        TemplateDialect::Handlebars => !source.contains("{{"),
35        TemplateDialect::Jinja => {
36            !(source.contains("{{") || source.contains("{%") || source.contains("{#"))
37        }
38    }
39}
40
41/// Returns the prefix of `source` that `dialect` renders for a static value:
42/// for Jinja, `source` with one trailing `\n` and then one trailing `\r`
43/// removed, matching the trim the minijinja lexer applies to every template it
44/// compiles. For the other dialects, `source` unchanged.
45fn trim_static_source(dialect: TemplateDialect, source: &str) -> &str {
46    if dialect == TemplateDialect::Jinja {
47        let s = source.strip_suffix('\n').unwrap_or(source);
48        s.strip_suffix('\r').unwrap_or(s)
49    } else {
50        source
51    }
52}
53
54pub enum Template<'env, 'source> {
55    Jinja(JinjaTemplate<'env, 'source>),
56    Static(&'env str),
57    Handlebars {
58        engine: &'env TemplateEngine,
59        template: &'env HandlebarsTemplate,
60    },
61}
62
63impl<'env, 'source> Template<'env, 'source> {
64    /// Returns true when this template renders its source verbatim, ignoring
65    /// the context.
66    pub fn is_static(&self) -> bool {
67        matches!(self, Self::Static(_))
68    }
69
70    pub fn render<S: Serialize>(&self, ctx: S) -> anyhow::Result<String> {
71        match &self {
72            Self::Jinja(t) => Ok(t.render(ctx)?),
73            Self::Static(s) => Ok(s.to_string()),
74            Self::Handlebars { .. } => {
75                let mut output: Vec<u8> = vec![];
76                self.render_to_write(&ctx, &mut output)?;
77                Ok(String::from_utf8(output)?)
78            }
79        }
80    }
81
82    pub fn render_to_write<S: Serialize, W: std::io::Write>(
83        &self,
84        ctx: S,
85        mut w: W,
86    ) -> anyhow::Result<()> {
87        match &self {
88            Self::Jinja(t) => {
89                t.render_captured_to(ctx, w)?;
90                Ok(())
91            }
92            Self::Static(s) => {
93                w.write_all(s.as_bytes())?;
94                Ok(())
95            }
96            Self::Handlebars { engine, template } => {
97                let Engine::Handlebars { registry, globals } = &engine.engine else {
98                    anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
99                };
100
101                let context = serde_json::to_value(ctx)?;
102                let context = merge_contexts(globals, context);
103                let context = handlebars::Context::wraps(context)?;
104
105                let output = render_handlebars(registry, template, &context)?;
106                w.write_all(output.as_bytes())?;
107                Ok(())
108            }
109        }
110    }
111
112    /// Renders this template against a context already converted to the native
113    /// form of the engine, returning the rendered output.
114    pub fn render_prepared(&self, ctx: &PreparedContext) -> anyhow::Result<String> {
115        match (self, ctx) {
116            (Self::Jinja(t), PreparedContext::Jinja(value)) => Ok(t.render(value)?),
117            (Self::Static(s), _) => Ok(s.to_string()),
118            (Self::Handlebars { engine, template }, PreparedContext::Handlebars(context)) => {
119                let Engine::Handlebars { registry, .. } = &engine.engine else {
120                    anyhow::bail!("impossible Handlebars Template vs. TemplateEngine state")
121                };
122                render_handlebars(registry, template, context)
123            }
124            _ => anyhow::bail!("prepared context dialect does not match template dialect"),
125        }
126    }
127}
128
129/// Renders `template` against `context` with the escaping rule the registry
130/// expects: substitutions are html-escaped only for a `.html` template name.
131fn render_handlebars(
132    registry: &Handlebars<'static>,
133    template: &HandlebarsTemplate,
134    context: &handlebars::Context,
135) -> anyhow::Result<String> {
136    let mut render_context = handlebars::RenderContext::new(None);
137    render_context.set_recursive_lookup(true);
138    let is_html = template
139        .name
140        .as_deref()
141        .map(|name| name.ends_with(".html"))
142        .unwrap_or(false);
143    render_context.set_disable_escape(!is_html);
144
145    Ok(template.renders(registry, context, &mut render_context)?)
146}
147
148/// A rendering context converted once into the native form of the active
149/// dialect. Obtained from [`TemplateEngine::prepare_context`] and rendered
150/// against with [`Template::render_prepared`].
151pub enum PreparedContext {
152    Jinja(JinjaValue),
153    Static,
154    Handlebars(handlebars::Context),
155}
156
157/// Holds a set of templates
158pub struct TemplateEngine {
159    dialect: TemplateDialect,
160    engine: Engine,
161    literals: HashMap<String, String>,
162}
163
164impl TemplateEngine {
165    pub fn new() -> Self {
166        Self::with_dialect(TemplateDialect::default())
167    }
168
169    pub fn with_dialect(dialect: TemplateDialect) -> Self {
170        let engine = match dialect {
171            TemplateDialect::Jinja => {
172                let mut env = Environment::new();
173                env.set_unknown_method_callback(
174                    minijinja_contrib::pycompat::unknown_method_callback,
175                );
176                add_to_environment(&mut env);
177
178                env.add_filter(
179                    "normalize_smtp_response",
180                    mod_smtp_response_normalize::normalize,
181                );
182
183                Engine::Jinja { env }
184            }
185            TemplateDialect::Static => Engine::Static,
186            TemplateDialect::Handlebars => {
187                let mut registry = Handlebars::new();
188                registry.set_recursive_lookup(true);
189                Engine::Handlebars {
190                    registry,
191                    globals: HashMap::new(),
192                }
193            }
194        };
195        Self {
196            dialect,
197            engine,
198            literals: HashMap::new(),
199        }
200    }
201
202    /// Add a named template with the specified source. If name ends with
203    /// `.html` then automatic escaping of html entities will be performed on
204    /// substitutions.
205    ///
206    /// A source that is free of expansion markers for the active dialect is
207    /// stored as a literal static template, bypassing the compiler and
208    /// evaluator of the engine entirely.
209    pub fn add_template<N, S>(&mut self, name: N, source: S) -> anyhow::Result<()>
210    where
211        N: Into<String>,
212        S: Into<String>,
213    {
214        let name = name.into();
215        let mut source = source.into();
216
217        if source_is_static(self.dialect, &source) {
218            source.truncate(trim_static_source(self.dialect, &source).len());
219            self.literals.insert(name, source);
220            return Ok(());
221        }
222
223        match &mut self.engine {
224            Engine::Jinja { env } => {
225                let source: Cow<'_, str> = source.into();
226
227                Ok(env
228                    .add_template_owned(name, source.clone())
229                    .map_err(|err| {
230                        let mut reason = String::new();
231
232                        if let Some(detail) = err.detail() {
233                            write!(&mut reason, "{}: {}", err.kind(), detail).ok();
234                        } else {
235                            write!(&mut reason, "{}", err.kind()).ok();
236                        }
237
238                        if let Some((line_no, source_line)) = err.line().and_then(|line| {
239                            source
240                                .lines()
241                                .nth(line - 1) // err.line() is 1-based
242                                .map(|source_line| (line, source_line))
243                        }) {
244                            let truncated_line =
245                                &source_line[..source_line.ceil_char_boundary(1024)];
246
247                            if let Some(name) = err.name() {
248                                write!(
249                                    &mut reason,
250                                    " (in template '{name}' line {line_no}: '{truncated_line}')"
251                                )
252                                .ok();
253                            } else {
254                                write!(
255                                    &mut reason,
256                                    " (in template line {line_no}: '{truncated_line}')"
257                                )
258                                .ok();
259                            }
260                        }
261
262                        anyhow::anyhow!("{reason}")
263                    })?)
264            }
265            Engine::Static => anyhow::bail!("Static dialect cannot hold a dynamic template"),
266            Engine::Handlebars { registry, .. } => {
267                registry.register_template_string(&name, source)?;
268                Ok(())
269            }
270        }
271    }
272
273    /// Get a reference to a named template
274    pub fn get_template(&self, name: &str) -> anyhow::Result<Template<'_, '_>> {
275        if let Some(source) = self.literals.get(name) {
276            return Ok(Template::Static(source));
277        }
278        match &self.engine {
279            Engine::Jinja { env } => Ok(Template::Jinja(env.get_template(name)?)),
280            Engine::Static => anyhow::bail!("template {name} is not defined"),
281            Engine::Handlebars { registry, .. } => {
282                let template = registry
283                    .get_template(name)
284                    .ok_or_else(|| anyhow::anyhow!("template {name} is not defined"))?;
285
286                Ok(Template::Handlebars {
287                    engine: self,
288                    template,
289                })
290            }
291        }
292    }
293
294    /// Converts a context into the native form of the active dialect, merging in
295    /// the globals previously added with [`TemplateEngine::add_global`].
296    pub fn prepare_context<CTX>(&self, context: CTX) -> anyhow::Result<PreparedContext>
297    where
298        CTX: Serialize,
299    {
300        match &self.engine {
301            Engine::Jinja { .. } => Ok(PreparedContext::Jinja(JinjaValue::from_serialize(context))),
302            Engine::Static => Ok(PreparedContext::Static),
303            Engine::Handlebars { globals, .. } => {
304                let context = serde_json::to_value(context)?;
305                let context = merge_contexts(globals, context);
306                Ok(PreparedContext::Handlebars(handlebars::Context::wraps(
307                    context,
308                )?))
309            }
310        }
311    }
312
313    /// Define a global value that can be reference by all templates
314    pub fn add_global<N, V>(&mut self, name: N, value: V) -> anyhow::Result<()>
315    where
316        N: Into<String>,
317        V: Serialize,
318    {
319        match &mut self.engine {
320            Engine::Jinja { env } => env.add_global(name.into(), JinjaValue::from_serialize(value)),
321            Engine::Static => { /* NOP */ }
322            Engine::Handlebars { globals, .. } => {
323                globals.insert(name.into(), serde_json::to_value(value)?);
324            }
325        }
326
327        Ok(())
328    }
329
330    pub fn render<CTX>(&self, name: &str, source: &str, context: CTX) -> anyhow::Result<String>
331    where
332        CTX: serde::Serialize,
333    {
334        if source_is_static(self.dialect, source) {
335            return Ok(trim_static_source(self.dialect, source).to_string());
336        }
337        match &self.engine {
338            Engine::Jinja { env } => Ok(env.render_named_str(name, source, context)?),
339            Engine::Static => Ok(source.to_string()),
340            Engine::Handlebars { .. } => {
341                let template = HandlebarsTemplate::compile_with_name(source, name.to_string())?;
342                let template = Template::Handlebars {
343                    engine: self,
344                    template: &template,
345                };
346
347                template.render(context)
348            }
349        }
350    }
351}
352
353fn merge_contexts(
354    globals: &HashMap<String, serde_json::Value>,
355    over: serde_json::Value,
356) -> serde_json::Value {
357    match over {
358        serde_json::Value::Object(mut obj) => {
359            for (k, v) in globals {
360                if !obj.contains_key(k) {
361                    obj.insert(k.into(), v.clone());
362                }
363            }
364            serde_json::Value::Object(obj)
365        }
366        other => other,
367    }
368}
369
370pub type TemplateList<'a> = Vec<Template<'a, 'a>>;
371
372self_cell!(
373    /// CompiledTemplates is useful when you have a set of templates
374    /// that you will expand frequently in a tight loop.
375    /// Because the underlying crate returns only references to `Template`s,
376    /// it is a covariant, self-referential structure that needs to be
377    /// constructed like this:
378    ///
379    /// ```rust
380    /// fn get_templates<'b>(
381    ///   engine: &'b TemplateEngine
382    /// ) -> anyhow::Result<TemplateList<'b>> {
383    ///   let mut templates = vec![];
384    ///   templates.push(engine.get_template("something")?);
385    ///   Ok(templates)
386    /// }
387    ///
388    /// let engine = TemplateEngine::new();
389    /// engine.add_template("something", "some text")?;
390    /// let compiled = CompiledTemplates::try_new(engine, |engine| {
391    ///   get_templates(engine)
392    /// });
393    /// ```
394    pub struct CompiledTemplates {
395        owner: TemplateEngine,
396        #[covariant]
397        dependent: TemplateList,
398    }
399);
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde_json::json;
405
406    /// A marker-free source is a static template and renders to itself across
407    /// every dialect, ignoring the supplied context.
408    #[test]
409    fn marker_free_source_is_static() {
410        for dialect in [
411            TemplateDialect::Jinja,
412            TemplateDialect::Handlebars,
413            TemplateDialect::Static,
414        ] {
415            let mut engine = TemplateEngine::with_dialect(dialect);
416            engine
417                .add_template("h", "In-Reply-To: <abc@example.com>")
418                .unwrap();
419            let t = engine.get_template("h").unwrap();
420            assert!(
421                t.is_static(),
422                "{dialect:?} should treat marker-free source as static"
423            );
424            k9::assert_equal!(
425                t.render(json!({"name": "ignored"})).unwrap(),
426                "In-Reply-To: <abc@example.com>".to_string()
427            );
428        }
429    }
430
431    /// Confirms `Template::Static` renders a marker-free source to the same
432    /// bytes the real engine would have produced for it, for Jinja (whose
433    /// lexer strips trailing bytes) and Handlebars (whose lexer does not).
434    #[test]
435    fn static_render_matches_engine_on_trailing_newline() {
436        // (source, expected output) pairs, expected being what the Jinja or
437        // Handlebars engine renders for that source.
438        let jinja_cases = [
439            ("text\n", "text"),
440            ("text\r\n", "text"),
441            ("text\n\n", "text\n"),
442            ("text", "text"),
443        ];
444        for (src, expected) in jinja_cases {
445            let mut engine = TemplateEngine::with_dialect(TemplateDialect::Jinja);
446            engine.add_template("t", src).unwrap();
447            let out = engine.get_template("t").unwrap().render(json!({})).unwrap();
448            k9::assert_equal!(out, expected.to_string());
449        }
450
451        let hbs_cases = [
452            ("text\n", "text\n"),
453            ("text\r\n", "text\r\n"),
454            ("text\n\n", "text\n\n"),
455            ("text", "text"),
456        ];
457        for (src, expected) in hbs_cases {
458            let mut engine = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
459            engine.add_template("t", src).unwrap();
460            let out = engine.get_template("t").unwrap().render(json!({})).unwrap();
461            k9::assert_equal!(out, expected.to_string());
462        }
463
464        // TemplateEngine::render, the one-shot path used when a named template
465        // is not pre-added, strips the same trailing newline.
466        let jinja2 = TemplateEngine::with_dialect(TemplateDialect::Jinja);
467        k9::assert_equal!(
468            jinja2.render("t", "text\n", json!({})).unwrap(),
469            "text".to_string()
470        );
471    }
472
473    /// A prepared context renders identically to passing the serde context
474    /// directly, for dynamic and static templates in every dialect.
475    #[test]
476    fn render_prepared_matches_render() {
477        for dialect in [
478            TemplateDialect::Jinja,
479            TemplateDialect::Handlebars,
480            TemplateDialect::Static,
481        ] {
482            let mut engine = TemplateEngine::with_dialect(dialect);
483            engine.add_template("dyn", "hello {{ name }}").unwrap();
484            engine.add_template("lit", "no markers here").unwrap();
485
486            let ctx = json!({"name": "Alice"});
487            let prepared = engine.prepare_context(&ctx).unwrap();
488            for key in ["dyn", "lit"] {
489                let template = engine.get_template(key).unwrap();
490                k9::assert_equal!(
491                    template.render_prepared(&prepared).unwrap(),
492                    template.render(&ctx).unwrap()
493                );
494            }
495        }
496    }
497
498    /// Confirms that a source containing a marker is rendered by the engine
499    /// rather than treated as static, even when the marker is a comment that
500    /// substitutes no value.
501    #[test]
502    fn marked_source_is_dynamic() {
503        let mut jinja = TemplateEngine::with_dialect(TemplateDialect::Jinja);
504        jinja.add_template("h", "hello {{ name }}").unwrap();
505        let t = jinja.get_template("h").unwrap();
506        assert!(!t.is_static());
507        k9::assert_equal!(
508            t.render(json!({"name": "Alice"})).unwrap(),
509            "hello Alice".to_string()
510        );
511
512        let mut hbs = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
513        hbs.add_template("h", "hello {{ name }}").unwrap();
514        assert!(!hbs.get_template("h").unwrap().is_static());
515
516        // Jinja statement and comment markers also keep a source dynamic.
517        let mut j2 = TemplateEngine::with_dialect(TemplateDialect::Jinja);
518        j2.add_template("c", "{# comment #}kept").unwrap();
519        assert!(!j2.get_template("c").unwrap().is_static());
520    }
521
522    /// HTML auto-escaping applies to substituted values, never to literal
523    /// markup. A marker-free source is all literal and renders unescaped.
524    #[test]
525    fn html_escaping_applies_only_to_substitutions() {
526        let literal = "<b>bold &amp; <i>italic</i></b>";
527
528        let mut engine = TemplateEngine::with_dialect(TemplateDialect::Handlebars);
529        engine.add_template("static.html", literal).unwrap();
530        let static_template = engine.get_template("static.html").unwrap();
531        assert!(static_template.is_static());
532        k9::assert_equal!(
533            static_template.render(json!({})).unwrap(),
534            literal.to_string()
535        );
536
537        engine
538            .add_template("dynamic.html", "<b>{{ v }}</b>")
539            .unwrap();
540        let dynamic_template = engine.get_template("dynamic.html").unwrap();
541        assert!(!dynamic_template.is_static());
542        k9::assert_equal!(
543            dynamic_template.render(json!({"v": "a & b"})).unwrap(),
544            "<b>a &amp; b</b>".to_string()
545        );
546    }
547}