1use 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
42pub trait TemplateEngine: Send + Sync + 'static {
44 fn render(&self, name: &str, context: &Value) -> Result<String>;
45}
46
47#[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 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 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 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 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
264fn 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("&"),
321 '<' => out.push_str("<"),
322 '>' => out.push_str(">"),
323 '"' => out.push_str("""),
324 '\'' => out.push_str("'"),
325 c => out.push(c),
326 }
327 }
328}
329
330fn 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 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 } 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 "<b>"a" & 'b'</b>"
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 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 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}