Skip to main content

standout_render/template/
engine.rs

1//! Template engine abstraction.
2//!
3//! This module defines the [`TemplateEngine`] trait which allows standout-render
4//! to work with different template backends. The default implementation is
5//! [`MiniJinjaEngine`], which provides full template functionality.
6
7use minijinja::{Environment, Value};
8
9use std::collections::HashMap;
10
11use crate::error::RenderError;
12
13/// A template engine that can render templates with data.
14///
15/// This trait abstracts over the template rendering backend, allowing
16/// different implementations (e.g., MiniJinja, simple string substitution).
17///
18/// Template engines handle:
19/// - Template compilation and caching
20/// - Variable substitution
21/// - Template logic (loops, conditionals) - if supported
22/// - Custom filters and functions - if supported
23pub trait TemplateEngine: Send + Sync {
24    /// Renders a template string with the given data.
25    ///
26    /// This compiles and renders the template in one step. For repeated
27    /// rendering of the same template, use [`add_template`](Self::add_template)
28    /// and [`render_named`](Self::render_named).
29    fn render_template(
30        &self,
31        template: &str,
32        data: &serde_json::Value,
33    ) -> Result<String, RenderError>;
34
35    /// Adds a named template to the engine.
36    ///
37    /// The template is compiled and cached for later use via [`render_named`](Self::render_named).
38    fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError>;
39
40    /// Renders a previously registered template.
41    ///
42    /// The template must have been added via [`add_template`](Self::add_template).
43    fn render_named(&self, name: &str, data: &serde_json::Value) -> Result<String, RenderError>;
44
45    /// Checks if a template with the given name exists.
46    fn has_template(&self, name: &str) -> bool;
47
48    /// Renders a template with additional context values merged in.
49    ///
50    /// The `context` values are merged with the serialized `data`. If there are
51    /// key conflicts, `data` takes precedence.
52    fn render_with_context(
53        &self,
54        template: &str,
55        data: &serde_json::Value,
56        context: HashMap<String, serde_json::Value>,
57    ) -> Result<String, RenderError>;
58
59    /// Whether this engine supports template includes (`{% include %}`).
60    fn supports_includes(&self) -> bool;
61
62    /// Whether this engine supports filters (`{{ value | filter }}`).
63    fn supports_filters(&self) -> bool;
64
65    /// Whether this engine supports control flow (`{% for %}`, `{% if %}`).
66    fn supports_control_flow(&self) -> bool;
67}
68
69/// MiniJinja-based template engine.
70///
71/// This is the default template engine, providing full template functionality:
72/// - Jinja2-compatible syntax
73/// - Loops, conditionals, macros
74/// - Custom filters and functions
75/// - Template includes
76///
77/// # Example
78///
79/// ```rust
80/// use standout_render::template::MiniJinjaEngine;
81/// use standout_render::template::TemplateEngine;
82/// use serde::Serialize;
83/// use serde_json::json;
84///
85/// #[derive(Serialize)]
86/// struct Data { name: String }
87///
88/// let engine = MiniJinjaEngine::new();
89/// let data = Data { name: "World".into() };
90/// let data_value = serde_json::to_value(&data).unwrap();
91///
92/// let output = engine.render_template(
93///     "Hello, {{ name }}!",
94///     &data_value,
95/// ).unwrap();
96/// assert_eq!(output, "Hello, World!");
97/// ```
98pub struct MiniJinjaEngine {
99    env: Environment<'static>,
100}
101
102impl MiniJinjaEngine {
103    /// Creates a new MiniJinja engine with default filters registered.
104    pub fn new() -> Self {
105        let mut env = Environment::new();
106        register_filters(&mut env);
107        Self { env }
108    }
109
110    /// Returns a reference to the underlying MiniJinja environment.
111    ///
112    /// This allows advanced users to register custom filters, functions,
113    /// or configure the environment directly.
114    pub fn environment(&self) -> &Environment<'static> {
115        &self.env
116    }
117
118    /// Returns a mutable reference to the underlying MiniJinja environment.
119    ///
120    /// This allows advanced users to register custom filters, functions,
121    /// or configure the environment directly.
122    pub fn environment_mut(&mut self) -> &mut Environment<'static> {
123        &mut self.env
124    }
125}
126
127impl Default for MiniJinjaEngine {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl TemplateEngine for MiniJinjaEngine {
134    fn render_template(
135        &self,
136        template: &str,
137        data: &serde_json::Value,
138    ) -> Result<String, RenderError> {
139        let value = Value::from_serialize(data);
140        Ok(self.env.render_str(template, value)?)
141    }
142
143    fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> {
144        self.env
145            .add_template_owned(name.to_string(), source.to_string())?;
146        Ok(())
147    }
148
149    fn render_named(&self, name: &str, data: &serde_json::Value) -> Result<String, RenderError> {
150        let tmpl = self.env.get_template(name)?;
151        let value = Value::from_serialize(data);
152        Ok(tmpl.render(value)?)
153    }
154
155    fn has_template(&self, name: &str) -> bool {
156        self.env.get_template(name).is_ok()
157    }
158
159    fn render_with_context(
160        &self,
161        template: &str,
162        data: &serde_json::Value,
163        context: HashMap<String, serde_json::Value>,
164    ) -> Result<String, RenderError> {
165        // Merge data into context (data takes precedence)
166        let mut combined = HashMap::new();
167        for (key, value) in context {
168            combined.insert(key, Value::from_serialize(value));
169        }
170
171        if let serde_json::Value::Object(map) = data {
172            for (key, value) in map {
173                combined.insert(key.clone(), Value::from_serialize(value));
174            }
175        }
176
177        Ok(self.env.render_str(template, &combined)?)
178    }
179
180    fn supports_includes(&self) -> bool {
181        true
182    }
183
184    fn supports_filters(&self) -> bool {
185        true
186    }
187
188    fn supports_control_flow(&self) -> bool {
189        true
190    }
191}
192
193/// Registers standout's custom filters with a MiniJinja environment.
194///
195/// This is called automatically by [`MiniJinjaEngine::new`]. If you're using
196/// the environment directly, call this to get standout's filters.
197pub fn register_filters(env: &mut Environment<'static>) {
198    use minijinja::{Error, ErrorKind};
199
200    // Newline filter
201    env.add_filter("nl", |value: Value| -> String { format!("{}\n", value) });
202
203    // Deprecated style filter with helpful error message
204    env.add_filter(
205        "style",
206        |_value: Value, _name: String| -> Result<String, Error> {
207            Err(Error::new(
208                ErrorKind::InvalidOperation,
209                "The `style()` filter was removed in Standout 1.0. \
210                 Use tag syntax instead: [stylename]{{ value }}[/stylename]",
211            ))
212        },
213    );
214
215    // Register tabular filters
216    crate::tabular::filters::register_tabular_filters(env);
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde::Serialize;
223
224    #[derive(Serialize)]
225    struct TestData {
226        name: String,
227        count: usize,
228    }
229
230    #[test]
231    fn test_minijinja_engine_simple() {
232        let engine = MiniJinjaEngine::new();
233        let data = TestData {
234            name: "World".into(),
235            count: 42,
236        };
237        let data_value = serde_json::to_value(&data).unwrap();
238        let output = engine
239            .render_template("Hello, {{ name }}!", &data_value)
240            .unwrap();
241        assert_eq!(output, "Hello, World!");
242    }
243
244    #[test]
245    fn test_minijinja_engine_with_loop() {
246        let engine = MiniJinjaEngine::new();
247
248        #[derive(Serialize)]
249        struct ListData {
250            items: Vec<String>,
251        }
252
253        let data = ListData {
254            items: vec!["a".into(), "b".into(), "c".into()],
255        };
256        let data_value = serde_json::to_value(&data).unwrap();
257        let output = engine
258            .render_template(
259                "{% for item in items %}{{ item }},{% endfor %}",
260                &data_value,
261            )
262            .unwrap();
263        assert_eq!(output, "a,b,c,");
264    }
265
266    #[test]
267    fn test_minijinja_engine_named_template() {
268        let mut engine = MiniJinjaEngine::new();
269        engine
270            .add_template("greeting", "Hello, {{ name }}!")
271            .unwrap();
272
273        let data = TestData {
274            name: "World".into(),
275            count: 0,
276        };
277        let data_value = serde_json::to_value(&data).unwrap();
278        let output = engine.render_named("greeting", &data_value).unwrap();
279        assert_eq!(output, "Hello, World!");
280    }
281
282    #[test]
283    fn test_minijinja_engine_template_error() {
284        let engine = MiniJinjaEngine::new();
285        let result = engine.render_template("{{ unclosed", &serde_json::Value::Null);
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_minijinja_engine_with_context() {
291        let engine = MiniJinjaEngine::new();
292
293        #[derive(Serialize)]
294        struct Data {
295            name: String,
296        }
297
298        let mut context = HashMap::new();
299        context.insert(
300            "version".to_string(),
301            serde_json::Value::String("1.0.0".into()),
302        );
303
304        let data = Data {
305            name: "Test".into(),
306        };
307        let data_value = serde_json::to_value(&data).unwrap();
308        let output = engine
309            .render_with_context("{{ name }} v{{ version }}", &data_value, context)
310            .unwrap();
311        assert_eq!(output, "Test v1.0.0");
312    }
313
314    #[test]
315    fn test_minijinja_engine_supports_features() {
316        let engine = MiniJinjaEngine::new();
317        assert!(engine.supports_includes());
318        assert!(engine.supports_filters());
319        assert!(engine.supports_control_flow());
320    }
321}