AkurAI Build
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(),
            "[&lt;b&gt;&amp;&quot;hi&quot;]"
        );
    }

    #[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());
    }
}