AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/template/src/lib.rs

Latest change d506ed551fac416b6bd5797287d71abc9af1d250 - OLI-211: ==, != and in comparisons in template {% if %} 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
//!
//! | Syntax | Meaning |
//! |--------|---------|
//! | `{{ x }}` | HTML-escaped variable |
//! | `{{{ x }}}` | Raw (unescaped) variable |
//! | `{% if x %}…{% else %}…{% endif %}` | Conditional |
//! | `{% for v in xs %}…{% endfor %}` | Loop over array |
//! | `{% include "name" %}` | Inline another template |
//! | `{% raw %}{{ literally }}{% endraw %}` | Literal template source |
//! | `{# comment #}` | Comment (stripped) |
//! | `{{ t "key" }}` | i18n translation lookup |
//! | `{{ t "key" name=user.name }}` | Translation with placeholder substitution |
//!
//! Paths are dotted (`user.name`); loop variables shadow root keys.
//!
//! ## i18n — the `t` helper
//!
//! `{{ t "message.key" }}` looks up `__messages["message.key"]` in the render
//! context and emits the translated string (HTML-escaped).  The caller is
//! responsible for populating `__locale` (the active locale code, a `Str`) and
//! `__messages` (an `Object` of key → string) in the context before calling
//! [`Engine::render`].
//!
//! Fallback: if the key is not found in `__messages`, the key itself is emitted
//! — always safe, never panics.
//!
//! Named placeholders (`{name}`) in the translated string are filled by
//! resolving the matching `name=path` argument from the context:
//!
//! ```text
//! {{ t "greeting" name=user.name }}
//! ```
//!
//! See `site/content/docs/i18n.md` for the full catalog format and wiring guide.

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

    // ── i18n `t` helper render tests ─────────────────────────────────────────

    fn ctx_with_messages(locale: &str, pairs: Vec<(&str, &str)>) -> Value {
        let msgs = Value::Object(
            pairs
                .into_iter()
                .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
                .collect(),
        );
        obj(vec![
            ("__locale", Value::Str(locale.to_string())),
            ("__messages", msgs),
        ])
    }

    #[test]
    fn t_helper_translates_key() {
        let ctx = ctx_with_messages("is", vec![("hello", "Halló")]);
        assert_eq!(
            Engine::render_str(r#"{{ t "hello" }}"#, &ctx).unwrap(),
            "Halló"
        );
    }

    #[test]
    fn t_helper_missing_key_falls_back_to_key() {
        let ctx = ctx_with_messages("en", vec![]);
        assert_eq!(
            Engine::render_str(r#"{{ t "app.title" }}"#, &ctx).unwrap(),
            "app.title"
        );
    }

    #[test]
    fn t_helper_missing_messages_falls_back_to_key() {
        // No __messages at all in the context.
        assert_eq!(
            Engine::render_str(r#"{{ t "hello" }}"#, &obj(vec![])).unwrap(),
            "hello"
        );
    }

    #[test]
    fn t_helper_html_escapes_translation() {
        let ctx = ctx_with_messages("en", vec![("xss", "<script>alert(1)</script>")]);
        assert_eq!(
            Engine::render_str(r#"{{ t "xss" }}"#, &ctx).unwrap(),
            "&lt;script&gt;alert(1)&lt;/script&gt;"
        );
    }

    #[test]
    fn t_helper_raw_does_not_escape() {
        let ctx = ctx_with_messages("en", vec![("html", "<b>bold</b>")]);
        assert_eq!(
            Engine::render_str(r#"{{{ t "html" }}}"#, &ctx).unwrap(),
            "<b>bold</b>"
        );
    }

    #[test]
    fn t_helper_interpolates_arg_from_context() {
        let pairs = vec![
            ("__locale", Value::Str("en".into())),
            (
                "__messages",
                Value::Object(vec![(
                    "greeting".into(),
                    Value::Str("Hello, {name}!".into()),
                )]),
            ),
            (
                "user",
                Value::Object(vec![("name".into(), Value::Str("Óli".into()))]),
            ),
        ];
        let ctx = Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect());
        assert_eq!(
            Engine::render_str(r#"{{ t "greeting" name=user.name }}"#, &ctx).unwrap(),
            "Hello, Óli!"
        );
    }

    #[test]
    fn t_helper_unknown_placeholder_left_verbatim() {
        let ctx = ctx_with_messages("en", vec![("msg", "Hello, {name}!")]);
        // arg not supplied → placeholder stays as-is
        assert_eq!(
            Engine::render_str(r#"{{ t "msg" }}"#, &ctx).unwrap(),
            "Hello, {name}!"
        );
    }

    #[test]
    fn templates_without_t_are_byte_identical() {
        // Verify backward compatibility: templates not using `t` render identically.
        let ctx = obj(vec![
            ("title", Value::Str("AkurAI".into())),
            ("count", Value::Int(3)),
        ]);
        let src = "<h1>{{ title }}</h1><p>{{ count }} items</p>";
        assert_eq!(
            Engine::render_str(src, &ctx).unwrap(),
            "<h1>AkurAI</h1><p>3 items</p>"
        );
    }
    #[test]
    fn if_equality_and_inequality() {
        let ctx = obj(vec![
            ("topic", Value::Str("sales".into())),
            ("n", Value::Int(3)),
        ]);
        let r = |t: &str| Engine::render_str(t, &ctx).unwrap();
        assert_eq!(
            r(r#"{% if topic == "sales" %}Y{% else %}N{% endif %}"#),
            "Y"
        );
        assert_eq!(
            r(r#"{% if topic == "other" %}Y{% else %}N{% endif %}"#),
            "N"
        );
        assert_eq!(
            r(r#"{% if topic != "other" %}Y{% else %}N{% endif %}"#),
            "Y"
        );
        assert_eq!(r("{% if n == 3 %}Y{% else %}N{% endif %}"), "Y");
        assert_eq!(r(r#"{% if n == "3" %}Y{% else %}N{% endif %}"#), "Y");
        assert_eq!(r(r#"{% if missing == "x" %}Y{% else %}N{% endif %}"#), "N");
        assert_eq!(r(r#"{% if missing != "x" %}Y{% else %}N{% endif %}"#), "Y");
        assert_eq!(r(r#"{% if topic == "a b" %}Y{% else %}N{% endif %}"#), "N");
    }

    #[test]
    fn if_equality_between_paths_in_loops() {
        let ctx = obj(vec![
            ("sel", Value::Str("b".into())),
            (
                "opts",
                Value::Array(vec![Value::Str("a".into()), Value::Str("b".into())]),
            ),
        ]);
        let t = r#"{% for o in opts %}<option{% if o == sel %} selected{% endif %}>{{ o }}</option>{% endfor %}"#;
        assert_eq!(
            Engine::render_str(t, &ctx).unwrap(),
            "<option>a</option><option selected>b</option>"
        );
    }

    #[test]
    fn if_in_array_and_substring() {
        let ctx = obj(vec![
            (
                "tags",
                Value::Array(vec![Value::Str("x".into()), Value::Str("y".into())]),
            ),
            ("s", Value::Str("hello".into())),
        ]);
        let r = |t: &str| Engine::render_str(t, &ctx).unwrap();
        assert_eq!(r(r#"{% if "y" in tags %}Y{% else %}N{% endif %}"#), "Y");
        assert_eq!(r(r#"{% if "z" in tags %}Y{% else %}N{% endif %}"#), "N");
        assert_eq!(r(r#"{% if "ell" in s %}Y{% else %}N{% endif %}"#), "Y");
    }

    #[test]
    fn if_rejects_bad_conditions() {
        assert!(Engine::render_str("{% if a ~ b %}x{% endif %}", &obj(vec![])).is_err());
        assert!(Engine::render_str("{% if a == %}x{% endif %}", &obj(vec![])).is_err());
        assert!(Engine::render_str(r#"{% if a == "x %}x{% endif %}"#, &obj(vec![])).is_err());
    }
}