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 },
70 Partial(String),
71}
72
73impl Templates {
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 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 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 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 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
259fn 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("&"),
316 '<' => out.push_str("<"),
317 '>' => out.push_str(">"),
318 '"' => out.push_str("""),
319 '\'' => out.push_str("'"),
320 c => out.push(c),
321 }
322 }
323}
324
325fn 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 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 } 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 "<b>"a" & 'b'</b>"
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}