Menu
Public repository / 3a1d4dc0df9425a910df82402300a2aeb40318ae
AkurAI-Framework / crates/template/src/lib.rs
Latest change efd5eb833824dca5c86104004c11bc6d39ff1a72 - feat(template): pure-std SSR template engine + component showcase by Ólafur Búi Ólafsson
//! AkurAI-Framework template engine — pure `std` (+ internal `akurai-json`).
//!
//! A small server-side HTML templating language that renders against a JSON
//! [`Value`] context — the framework's SSR/“dynamic content” layer.
//!
//! ```
//! use akurai_template::Engine;
//! use akurai_json::Value;
//!
//! let mut engine = Engine::new();
//! engine.register("hi", "Hello, {{ name }}!").unwrap();
//! let ctx = Value::Object(vec![("name".into(), Value::Str("Óli".into()))]);
//! assert_eq!(engine.render("hi", &ctx).unwrap(), "Hello, Óli!");
//! ```
//!
//! Syntax: `{{ x }}` (HTML-escaped), `{{{ x }}}` (raw), `{% if x %}…{% else %}…
//! {% endif %}`, `{% for v in xs %}…{% endfor %}`, `{% include "name" %}`,
//! `{# comment #}`. Paths are dotted (`user.name`); loop variables shadow root.
#![forbid(unsafe_code)]
mod ast;
mod error;
mod parse;
mod render;
pub use ast::Node;
pub use error::Error;
use akurai_json::Value;
use render::Renderer;
use std::collections::HashMap;
/// Holds parsed, named templates and renders them. Names let templates
/// `{% include %}` one another (e.g. a shared header/footer).
#[derive(Default)]
pub struct Engine {
templates: HashMap<String, Vec<Node>>,
}
impl Engine {
pub fn new() -> Engine {
Engine::default()
}
/// Parse `source` and store it under `name`. Returns a parse error without
/// mutating the engine.
pub fn register(&mut self, name: &str, source: &str) -> Result<(), Error> {
let nodes = parse::parse(source)?;
self.templates.insert(name.to_string(), nodes);
Ok(())
}
/// Render a registered template against `context`.
pub fn render(&self, name: &str, context: &Value) -> Result<String, Error> {
let nodes = self
.templates
.get(name)
.ok_or_else(|| Error::new(format!("unknown template '{name}'")))?;
let mut out = String::new();
Renderer {
templates: &self.templates,
}
.render(nodes, context, &mut out)?;
Ok(out)
}
/// Render a one-off template string with no registration (no includes).
pub fn render_str(source: &str, context: &Value) -> Result<String, Error> {
let mut engine = Engine::new();
engine.register("__inline__", source)?;
engine.render("__inline__", context)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn obj(pairs: Vec<(&str, Value)>) -> Value {
Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
#[test]
fn interpolates_and_escapes() {
let ctx = obj(vec![("x", Value::Str("<b>&\"hi\"".into()))]);
assert_eq!(
Engine::render_str("[{{ x }}]", &ctx).unwrap(),
"[<b>&"hi"]"
);
}
#[test]
fn raw_does_not_escape() {
let ctx = obj(vec![("x", Value::Str("<b>".into()))]);
assert_eq!(Engine::render_str("{{{ x }}}", &ctx).unwrap(), "<b>");
}
#[test]
fn if_else_branches() {
let t = "{% if on %}ON{% else %}OFF{% endif %}";
assert_eq!(
Engine::render_str(t, &obj(vec![("on", Value::Bool(true))])).unwrap(),
"ON"
);
assert_eq!(
Engine::render_str(t, &obj(vec![("on", Value::Bool(false))])).unwrap(),
"OFF"
);
// missing key is falsy
assert_eq!(Engine::render_str(t, &obj(vec![])).unwrap(), "OFF");
}
#[test]
fn loops_over_array_with_fields() {
let ctx = obj(vec![(
"items",
Value::Array(vec![
obj(vec![("name", Value::Str("a".into()))]),
obj(vec![("name", Value::Str("b".into()))]),
]),
)]);
let out = Engine::render_str(
"{% for it in items %}<li>{{ it.name }}</li>{% endfor %}",
&ctx,
)
.unwrap();
assert_eq!(out, "<li>a</li><li>b</li>");
}
#[test]
fn includes_share_context() {
let mut engine = Engine::new();
engine.register("head", "<h1>{{ title }}</h1>").unwrap();
engine
.register("page", "{% include \"head\" %}<p>body</p>")
.unwrap();
let ctx = obj(vec![("title", Value::Str("Hi".into()))]);
assert_eq!(
engine.render("page", &ctx).unwrap(),
"<h1>Hi</h1><p>body</p>"
);
}
#[test]
fn nested_loop_and_conditional() {
let ctx = obj(vec![(
"rows",
Value::Array(vec![
obj(vec![("v", Value::Int(1)), ("hot", Value::Bool(true))]),
obj(vec![("v", Value::Int(2)), ("hot", Value::Bool(false))]),
]),
)]);
let t = "{% for r in rows %}{{ r.v }}{% if r.hot %}!{% endif %} {% endfor %}";
assert_eq!(Engine::render_str(t, &ctx).unwrap(), "1! 2 ");
}
#[test]
fn unknown_template_errors() {
let engine = Engine::new();
assert!(engine.render("nope", &Value::Null).is_err());
}
}