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