Skip to main content

ullun/
template.rs

1//! Server-side HTML templates
2//!
3//! [`TemplateEngine`] is the integration point: implement it for any engine
4//! (minijinja, tera, handlebars, ...) and render with
5//! `Response::html(engine.render("page.html", &ctx)?)`.
6//!
7//! [`Templates`] is a small built-in engine without dependencies. Syntax
8//! (Mustache/Handlebars subset):
9//!
10//! | Tag | Meaning |
11//! |-----|---------|
12//! | `{{ user.name }}` | value, HTML-escaped; missing values render as empty |
13//! | `{{{ html }}}` or `{{& html }}` | value, **not** escaped |
14//! | `{{#if x}}..{{else}}..{{/if}}` | conditional; falsy: missing, `null`, `false`, `0`, `""`, `[]`, `{}` |
15//! | `{{#unless x}}..{{/unless}}` | inverted conditional |
16//! | `{{#each items}}..{{else}}..{{/each}}` | loop; `{{this}}`, `{{@index}}`, `{{@first}}`, `{{@last}}`, `{{@key}}` (objects); the `else` part renders for a missing or empty list |
17//! | `{{> partial.html}}` | include another template with the current context |
18//! | `{{! comment }}` | ignored |
19//!
20//! Names resolve against the innermost scope first, then outward, so loop
21//! bodies can still read top-level values.
22//!
23//! ```
24//! use ullun::template::Templates;
25//!
26//! let mut t = Templates::new();
27//! t.add("hello.html", "Hello {{name}}!{{#each tags}} #{{this}}{{/each}}").unwrap();
28//! let out = t
29//!     .render("hello.html", &serde_json::json!({ "name": "<Ann>", "tags": ["a", "b"] }))
30//!     .unwrap();
31//! assert_eq!(out, "Hello &lt;Ann&gt;! #a #b");
32//! ```
33
34use crate::error::{Error, Result};
35use serde::Serialize;
36use serde_json::Value;
37use std::collections::HashMap;
38use std::path::Path;
39
40const MAX_PARTIAL_DEPTH: usize = 16;
41
42/// Anything that can turn a named template plus data into text
43pub trait TemplateEngine: Send + Sync + 'static {
44    fn render(&self, name: &str, context: &Value) -> Result<String>;
45}
46
47/// Built-in template engine, see the [module docs](self) for the syntax
48#[derive(Default)]
49pub struct Templates {
50    templates: HashMap<String, Vec<Node>>,
51}
52
53#[derive(Debug, Clone, PartialEq)]
54enum Node {
55    Text(String),
56    Var {
57        path: String,
58        escape: bool,
59    },
60    If {
61        path: String,
62        negate: bool,
63        then: Vec<Node>,
64        otherwise: Vec<Node>,
65    },
66    Each {
67        path: String,
68        body: Vec<Node>,
69        otherwise: Vec<Node>,
70    },
71    Partial(String),
72}
73
74impl Templates {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Parse and register a template under `name`
80    pub fn add(&mut self, name: &str, source: &str) -> Result<&mut Self> {
81        let nodes =
82            parse(source).map_err(|e| Error::internal(format!("Template '{}': {}", name, e)))?;
83        self.templates.insert(name.to_string(), nodes);
84        Ok(self)
85    }
86
87    /// Load every `.html`, `.htm`, `.txt` and `.xml` file below `dir`
88    /// (recursively); the name is the path relative to `dir`, with `/`
89    /// separators, e.g. `layouts/base.html`.
90    pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self> {
91        let mut templates = Templates::new();
92        templates.load_dir(dir.as_ref(), dir.as_ref())?;
93        Ok(templates)
94    }
95
96    fn load_dir(&mut self, root: &Path, dir: &Path) -> Result<()> {
97        let entries = std::fs::read_dir(dir)
98            .map_err(|e| Error::internal(format!("Template dir {}: {}", dir.display(), e)))?;
99        for entry in entries {
100            let path = entry
101                .map_err(|e| Error::internal(format!("Template dir: {}", e)))?
102                .path();
103            if path.is_dir() {
104                self.load_dir(root, &path)?;
105                continue;
106            }
107            let known = matches!(
108                path.extension().and_then(|e| e.to_str()),
109                Some("html" | "htm" | "txt" | "xml")
110            );
111            if !known {
112                continue;
113            }
114            let name = path
115                .strip_prefix(root)
116                .unwrap_or(&path)
117                .components()
118                .map(|c| c.as_os_str().to_string_lossy())
119                .collect::<Vec<_>>()
120                .join("/");
121            let source = std::fs::read_to_string(&path)
122                .map_err(|e| Error::internal(format!("Template {}: {}", path.display(), e)))?;
123            self.add(&name, &source)?;
124        }
125        Ok(())
126    }
127
128    pub fn contains(&self, name: &str) -> bool {
129        self.templates.contains_key(name)
130    }
131
132    /// Render with any serializable context
133    pub fn render<T: Serialize>(&self, name: &str, context: &T) -> Result<String> {
134        let ctx = serde_json::to_value(context)
135            .map_err(|e| Error::internal(format!("Template context: {}", e)))?;
136        TemplateEngine::render(self, name, &ctx)
137    }
138}
139
140impl TemplateEngine for Templates {
141    fn render(&self, name: &str, context: &Value) -> Result<String> {
142        let mut out = String::new();
143        let mut scope = vec![Frame {
144            value: context,
145            index: None,
146            key: None,
147            last: false,
148        }];
149        self.render_nodes(name, &mut scope, &mut out, 0)?;
150        Ok(out)
151    }
152}
153
154struct Frame<'a> {
155    value: &'a Value,
156    index: Option<usize>,
157    key: Option<&'a str>,
158    last: bool,
159}
160
161impl Templates {
162    fn render_nodes<'a>(
163        &'a self,
164        name: &str,
165        scope: &mut Vec<Frame<'a>>,
166        out: &mut String,
167        depth: usize,
168    ) -> Result<()> {
169        let nodes = self
170            .templates
171            .get(name)
172            .ok_or_else(|| Error::internal(format!("Template '{}' not found", name)))?;
173        self.render_list(nodes, scope, out, depth)
174    }
175
176    fn render_list<'a>(
177        &'a self,
178        nodes: &'a [Node],
179        scope: &mut Vec<Frame<'a>>,
180        out: &mut String,
181        depth: usize,
182    ) -> Result<()> {
183        for node in nodes {
184            match node {
185                Node::Text(t) => out.push_str(t),
186                Node::Var { path, escape } => {
187                    let text = match special(path, scope) {
188                        Some(s) => s,
189                        None => lookup(path, scope).map(display).unwrap_or_default(),
190                    };
191                    if *escape {
192                        escape_html(&text, out);
193                    } else {
194                        out.push_str(&text);
195                    }
196                }
197                Node::If {
198                    path,
199                    negate,
200                    then,
201                    otherwise,
202                } => {
203                    let truthy = match special(path, scope) {
204                        Some(s) => s == "true",
205                        None => lookup(path, scope).map(is_truthy).unwrap_or(false),
206                    };
207                    let branch = if truthy != *negate { then } else { otherwise };
208                    self.render_list(branch, scope, out, depth)?;
209                }
210                Node::Each {
211                    path,
212                    body,
213                    otherwise,
214                } => {
215                    // The looked-up value borrows from the render context ('a),
216                    // not from the scope stack, so it can be pushed as a frame.
217                    let items = lookup(path, scope);
218                    match items {
219                        Some(Value::Array(list)) if !list.is_empty() => {
220                            for (i, item) in list.iter().enumerate() {
221                                scope.push(Frame {
222                                    value: item,
223                                    index: Some(i),
224                                    key: None,
225                                    last: i + 1 == list.len(),
226                                });
227                                let r = self.render_list(body, scope, out, depth);
228                                scope.pop();
229                                r?;
230                            }
231                        }
232                        Some(Value::Object(map)) if !map.is_empty() => {
233                            let len = map.len();
234                            for (i, (k, v)) in map.iter().enumerate() {
235                                scope.push(Frame {
236                                    value: v,
237                                    index: Some(i),
238                                    key: Some(k.as_str()),
239                                    last: i + 1 == len,
240                                });
241                                let r = self.render_list(body, scope, out, depth);
242                                scope.pop();
243                                r?;
244                            }
245                        }
246                        _ => self.render_list(otherwise, scope, out, depth)?,
247                    }
248                }
249                Node::Partial(name) => {
250                    if depth >= MAX_PARTIAL_DEPTH {
251                        return Err(Error::internal(format!(
252                            "Partial '{}' nested deeper than {} levels",
253                            name, MAX_PARTIAL_DEPTH
254                        )));
255                    }
256                    self.render_nodes(name, scope, out, depth + 1)?;
257                }
258            }
259        }
260        Ok(())
261    }
262}
263
264/// `@index`, `@first`, `@last`, `@key` of the innermost loop frame
265fn special(path: &str, scope: &[Frame<'_>]) -> Option<String> {
266    let name = path.strip_prefix('@')?;
267    let frame = scope.iter().rev().find(|f| f.index.is_some())?;
268    match name {
269        "index" => frame.index.map(|i| i.to_string()),
270        "first" => frame.index.map(|i| (i == 0).to_string()),
271        "last" => Some(frame.last.to_string()),
272        "key" => Some(frame.key.unwrap_or("").to_string()),
273        _ => None,
274    }
275}
276
277fn lookup<'a>(path: &str, scope: &[Frame<'a>]) -> Option<&'a Value> {
278    if path == "this" || path == "." {
279        return scope.last().map(|f| f.value);
280    }
281    let mut parts = path.split('.');
282    let first = parts.next()?;
283    let mut current = scope.iter().rev().find_map(|f| member(f.value, first))?;
284    for part in parts {
285        current = member(current, part)?;
286    }
287    Some(current)
288}
289
290fn member<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
291    match value {
292        Value::Object(map) => map.get(key),
293        Value::Array(list) => key.parse::<usize>().ok().and_then(|i| list.get(i)),
294        _ => None,
295    }
296}
297
298fn is_truthy(v: &Value) -> bool {
299    match v {
300        Value::Null => false,
301        Value::Bool(b) => *b,
302        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
303        Value::String(s) => !s.is_empty(),
304        Value::Array(a) => !a.is_empty(),
305        Value::Object(o) => !o.is_empty(),
306    }
307}
308
309fn display(v: &Value) -> String {
310    match v {
311        Value::Null => String::new(),
312        Value::String(s) => s.clone(),
313        other => other.to_string(),
314    }
315}
316
317fn escape_html(s: &str, out: &mut String) {
318    for c in s.chars() {
319        match c {
320            '&' => out.push_str("&amp;"),
321            '<' => out.push_str("&lt;"),
322            '>' => out.push_str("&gt;"),
323            '"' => out.push_str("&quot;"),
324            '\'' => out.push_str("&#39;"),
325            c => out.push(c),
326        }
327    }
328}
329
330// ---- parser ----
331
332fn parse(source: &str) -> std::result::Result<Vec<Node>, String> {
333    enum Open {
334        If {
335            path: String,
336            negate: bool,
337            then: Vec<Node>,
338            in_else: bool,
339            otherwise: Vec<Node>,
340        },
341        Each {
342            path: String,
343            in_else: bool,
344            then: Vec<Node>,
345        },
346    }
347    // (kind, nodes collected for the open block's current branch)
348    let mut stack: Vec<(Open, Vec<Node>)> = Vec::new();
349    let mut current: Vec<Node> = Vec::new();
350    let mut rest = source;
351
352    while let Some(start) = rest.find("{{") {
353        if start > 0 {
354            current.push(Node::Text(rest[..start].to_string()));
355        }
356        let after = &rest[start + 2..];
357        let (tag, consumed) = if let Some(inner) = after.strip_prefix('{') {
358            let end = inner.find("}}}").ok_or("unclosed '{{{'")?;
359            (format!("{{{}", inner[..end].trim()), 1 + end + 3)
360        } else {
361            let end = after.find("}}").ok_or("unclosed '{{'")?;
362            (after[..end].trim().to_string(), end + 2)
363        };
364        rest = &after[consumed..];
365
366        if let Some(raw) = tag.strip_prefix('{') {
367            current.push(Node::Var {
368                path: raw.trim().to_string(),
369                escape: false,
370            });
371        } else if tag.starts_with('!') {
372            // comment
373        } else if let Some(raw) = tag.strip_prefix('&') {
374            current.push(Node::Var {
375                path: raw.trim().to_string(),
376                escape: false,
377            });
378        } else if let Some(name) = tag.strip_prefix('>') {
379            current.push(Node::Partial(name.trim().to_string()));
380        } else if let Some(path) = tag.strip_prefix("#if ") {
381            stack.push((
382                Open::If {
383                    path: path.trim().to_string(),
384                    negate: false,
385                    then: Vec::new(),
386                    in_else: false,
387                    otherwise: Vec::new(),
388                },
389                std::mem::take(&mut current),
390            ));
391        } else if let Some(path) = tag.strip_prefix("#unless ") {
392            stack.push((
393                Open::If {
394                    path: path.trim().to_string(),
395                    negate: true,
396                    then: Vec::new(),
397                    in_else: false,
398                    otherwise: Vec::new(),
399                },
400                std::mem::take(&mut current),
401            ));
402        } else if let Some(path) = tag.strip_prefix("#each ") {
403            stack.push((
404                Open::Each {
405                    path: path.trim().to_string(),
406                    in_else: false,
407                    then: Vec::new(),
408                },
409                std::mem::take(&mut current),
410            ));
411        } else if tag == "else" {
412            match stack.last_mut() {
413                Some((Open::If { then, in_else, .. }, _)) if !*in_else => {
414                    *then = std::mem::take(&mut current);
415                    *in_else = true;
416                }
417                Some((Open::Each { then, in_else, .. }, _)) if !*in_else => {
418                    *then = std::mem::take(&mut current);
419                    *in_else = true;
420                }
421                _ => return Err("'{{else}}' outside of #if/#unless/#each, or used twice".into()),
422            }
423        } else if tag == "/if" || tag == "/unless" || tag == "/each" {
424            let (open, parent) = stack
425                .pop()
426                .ok_or_else(|| format!("unexpected '{{{{{}}}}}'", tag))?;
427            let body = std::mem::replace(&mut current, parent);
428            let node = match (open, tag.as_str()) {
429                (
430                    Open::If {
431                        path,
432                        negate,
433                        then,
434                        in_else,
435                        mut otherwise,
436                    },
437                    "/if" | "/unless",
438                ) => {
439                    let (then, otherwise) = if in_else {
440                        otherwise = body;
441                        (then, otherwise)
442                    } else {
443                        (body, otherwise)
444                    };
445                    Node::If {
446                        path,
447                        negate,
448                        then,
449                        otherwise,
450                    }
451                }
452                (
453                    Open::Each {
454                        path,
455                        in_else,
456                        then,
457                    },
458                    "/each",
459                ) => {
460                    if in_else {
461                        Node::Each {
462                            path,
463                            body: then,
464                            otherwise: body,
465                        }
466                    } else {
467                        Node::Each {
468                            path,
469                            body,
470                            otherwise: Vec::new(),
471                        }
472                    }
473                }
474                _ => return Err(format!("mismatched closing tag '{{{{{}}}}}'", tag)),
475            };
476            current.push(node);
477        } else if tag.is_empty() {
478            return Err("empty tag '{{}}'".into());
479        } else if tag.starts_with('#') || tag.starts_with('/') {
480            return Err(format!("unknown block tag '{{{{{}}}}}'", tag));
481        } else {
482            current.push(Node::Var {
483                path: tag,
484                escape: true,
485            });
486        }
487    }
488    if !rest.is_empty() {
489        current.push(Node::Text(rest.to_string()));
490    }
491    if !stack.is_empty() {
492        return Err("unclosed block (missing '{{/if}}', '{{/unless}}' or '{{/each}}')".into());
493    }
494    Ok(current)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use serde_json::json;
501
502    fn render(src: &str, ctx: Value) -> String {
503        let mut t = Templates::new();
504        t.add("t", src).unwrap();
505        t.render("t", &ctx).unwrap()
506    }
507
508    #[test]
509    fn variables_are_escaped_unless_raw() {
510        let ctx = json!({ "x": "<b>\"a\" & 'b'</b>", "n": 3, "ok": true, "nothing": null });
511        assert_eq!(
512            render("{{x}}", ctx.clone()),
513            "&lt;b&gt;&quot;a&quot; &amp; &#39;b&#39;&lt;/b&gt;"
514        );
515        assert_eq!(
516            render("{{{x}}}|{{& x}}", ctx.clone()),
517            "<b>\"a\" & 'b'</b>|<b>\"a\" & 'b'</b>"
518        );
519        assert_eq!(
520            render("{{n}} {{ok}} [{{nothing}}] [{{missing}}]", ctx),
521            "3 true [] []"
522        );
523    }
524
525    #[test]
526    fn nested_paths_and_array_index() {
527        let ctx = json!({ "user": { "name": "Ann", "tags": ["a", "b"] } });
528        assert_eq!(
529            render("{{user.name}} {{user.tags.1}} [{{user.nope.deep}}]", ctx),
530            "Ann b []"
531        );
532    }
533
534    #[test]
535    fn conditionals() {
536        let t = "{{#if a}}A{{else}}no A{{/if}}|{{#unless a}}U{{/unless}}|{{#if b}}B{{/if}}";
537        assert_eq!(render(t, json!({ "a": "x", "b": 0 })), "A||");
538        assert_eq!(render(t, json!({ "a": [], "b": [1] })), "no A|U|B");
539        assert_eq!(render(t, json!({})), "no A|U|");
540    }
541
542    #[test]
543    fn each_over_arrays_with_specials_and_outer_scope() {
544        let ctx = json!({ "site": "S", "items": [{ "n": "x" }, { "n": "y" }, { "n": "z" }] });
545        let t = "{{#each items}}{{@index}}:{{n}}@{{site}}{{#if @first}}!{{/if}}{{#if @last}}.{{/if}};{{/each}}";
546        assert_eq!(render(t, ctx), "0:x@S!;1:y@S;2:z@S.;");
547        assert_eq!(
548            render("{{#each l}}[{{this}}]{{/each}}", json!({ "l": [1, "a"] })),
549            "[1][a]"
550        );
551        assert_eq!(render("{{#each l}}x{{/each}}", json!({ "l": [] })), "");
552    }
553
554    #[test]
555    fn each_else_renders_for_missing_or_empty_lists() {
556        let t = "{{#each l}}[{{this}}]{{else}}nothing{{/each}}";
557        assert_eq!(render(t, json!({ "l": [1, 2] })), "[1][2]");
558        assert_eq!(render(t, json!({ "l": [] })), "nothing");
559        assert_eq!(render(t, json!({})), "nothing");
560        assert_eq!(render(t, json!({ "l": {} })), "nothing");
561        assert_eq!(render(t, json!({ "l": { "a": 1 } })), "[1]");
562        // nested: the inner else belongs to the inner loop
563        let nested =
564            "{{#each rows}}{{#each cells}}{{this}}{{else}}-{{/each}};{{else}}no rows{{/each}}";
565        assert_eq!(
566            render(
567                nested,
568                json!({ "rows": [{ "cells": [1, 2] }, { "cells": [] }] })
569            ),
570            "12;-;"
571        );
572        assert_eq!(render(nested, json!({ "rows": [] })), "no rows");
573        // a second else is an error
574        assert!(Templates::new()
575            .add("x", "{{#each a}}1{{else}}2{{else}}3{{/each}}")
576            .is_err());
577    }
578
579    #[test]
580    fn each_over_objects_exposes_key() {
581        let out = render(
582            "{{#each m}}{{@key}}={{this}},{{/each}}",
583            json!({ "m": { "a": 1, "b": 2 } }),
584        );
585        assert_eq!(out, "a=1,b=2,");
586    }
587
588    #[test]
589    fn nested_blocks_and_else_inside_each() {
590        let t = "{{#each u}}{{#if admin}}{{name}}*{{else}}{{name}}{{/if}} {{/each}}";
591        let ctx = json!({ "u": [{ "name": "a", "admin": true }, { "name": "b" }] });
592        assert_eq!(render(t, ctx), "a* b ");
593    }
594
595    #[test]
596    fn partials_and_comments() {
597        let mut t = Templates::new();
598        t.add("p", "<{{name}}>").unwrap();
599        t.add("page", "{{! hidden }}A{{> p}}B").unwrap();
600        assert_eq!(t.render("page", &json!({ "name": "n" })).unwrap(), "A<n>B");
601    }
602
603    #[test]
604    fn recursive_partials_are_bounded() {
605        let mut t = Templates::new();
606        t.add("loop", "{{> loop}}").unwrap();
607        let err = t.render("loop", &json!({})).unwrap_err();
608        assert_eq!(err.status_code(), 500);
609        assert!(err.message().contains("nested deeper"));
610    }
611
612    #[test]
613    fn errors_are_reported_with_template_name() {
614        let mut t = Templates::new();
615        for bad in [
616            "{{#if a}}",
617            "{{/if}}",
618            "{{x",
619            "{{{x}}",
620            "{{}}",
621            "{{#foo a}}{{/foo}}",
622            "{{else}}",
623            "{{#each a}}{{/if}}",
624        ] {
625            let e = t
626                .add("bad", bad)
627                .err()
628                .unwrap_or_else(|| panic!("accepted {bad}"));
629            assert!(
630                e.message().contains("Template 'bad'"),
631                "{bad}: {}",
632                e.message()
633            );
634        }
635        assert!(t.render("missing", &json!({})).is_err());
636        assert!(!t.contains("missing"));
637    }
638
639    #[test]
640    fn text_only_and_serializable_contexts() {
641        #[derive(Serialize)]
642        struct Ctx {
643            title: &'static str,
644        }
645        let mut t = Templates::new();
646        t.add("a", "plain {text} with } braces").unwrap();
647        t.add("b", "{{title}}").unwrap();
648        assert_eq!(
649            t.render("a", &json!({})).unwrap(),
650            "plain {text} with } braces"
651        );
652        assert_eq!(t.render("b", &Ctx { title: "T" }).unwrap(), "T");
653    }
654
655    #[test]
656    fn from_dir_loads_nested_files() {
657        let dir = std::env::temp_dir().join(format!("ullun-tpl-{}", std::process::id()));
658        std::fs::create_dir_all(dir.join("layouts")).unwrap();
659        std::fs::write(dir.join("index.html"), "{{> layouts/base.html}}!").unwrap();
660        std::fs::write(dir.join("layouts/base.html"), "<h1>{{t}}</h1>").unwrap();
661        std::fs::write(dir.join("ignored.bin"), "x").unwrap();
662
663        let t = Templates::from_dir(&dir).unwrap();
664        assert!(t.contains("layouts/base.html") && !t.contains("ignored.bin"));
665        assert_eq!(
666            t.render("index.html", &json!({ "t": "Hi" })).unwrap(),
667            "<h1>Hi</h1>!"
668        );
669        std::fs::remove_dir_all(dir).ok();
670        assert!(Templates::from_dir("/definitely/not/here").is_err());
671    }
672}