quillmark_core/
templating.rs

1//! # Templating Module
2//!
3//! MiniJinja-based template composition with stable filter API.
4//!
5//! ## Overview
6//!
7//! The `templating` module provides the [`Glue`] type for template rendering and a stable
8//! filter API for backends to register custom filters.
9//!
10//! ## Key Types
11//!
12//! - [`Glue`]: Template rendering engine wrapper
13//! - [`TemplateError`]: Template-specific error types
14//! - [`filter_api`]: Stable API for filter registration (no direct minijinja dependency)
15//!
16//! ## Examples
17//!
18//! ### Basic Template Rendering
19//!
20//! ```no_run
21//! use quillmark_core::Glue;
22//! use std::collections::HashMap;
23//!
24//! let template = r#"
25//! #set document(title: {{ title | String }})
26//!
27//! {{ body | Content }}
28//! "#;
29//!
30//! let mut glue = Glue::new(template.to_string());
31//!
32//! // Register filters (done by backends)
33//! // glue.register_filter("String", string_filter);
34//! // glue.register_filter("Content", content_filter);
35//!
36//! let mut context = HashMap::new();
37//! context.insert("title".to_string(), serde_yaml::Value::String("My Doc".into()));
38//! context.insert("body".to_string(), serde_yaml::Value::String("Content".into()));
39//!
40//! let output = glue.compose(context).unwrap();
41//! ```
42//!
43//! ### Custom Filter Implementation
44//!
45//! ```no_run
46//! use quillmark_core::templating::filter_api::{State, Value, Kwargs, Error, ErrorKind};
47//! # use quillmark_core::Glue;
48//! # let mut glue = Glue::new("template".to_string());
49//!
50//! fn uppercase_filter(
51//!     _state: &State,
52//!     value: Value,
53//!     _kwargs: Kwargs,
54//! ) -> Result<Value, Error> {
55//!     let s = value.as_str().ok_or_else(|| {
56//!         Error::new(ErrorKind::InvalidOperation, "Expected string")
57//!     })?;
58//!     Ok(Value::from(s.to_uppercase()))
59//! }
60//!
61//! // Register with glue
62//! glue.register_filter("uppercase", uppercase_filter);
63//! ```
64//!
65//! ## Filter API
66//!
67//! The [`filter_api`] module provides a stable ABI that external crates can depend on
68//! without requiring a direct minijinja dependency.
69//!
70//! ### Filter Function Signature
71//!
72//! ```rust,ignore
73//! type FilterFn = fn(
74//!     &filter_api::State,
75//!     filter_api::Value,
76//!     filter_api::Kwargs,
77//! ) -> Result<filter_api::Value, minijinja::Error>;
78//! ```
79//!
80//! ## Error Types
81//!
82//! - [`TemplateError::RenderError`]: Template rendering error from MiniJinja
83//! - [`TemplateError::InvalidTemplate`]: Template compilation failed
84//! - [`TemplateError::FilterError`]: Filter execution error
85
86use std::collections::HashMap;
87use std::error::Error as StdError;
88
89use minijinja::{Environment, Error as MjError};
90use serde_yaml;
91
92/// Error types for template rendering
93#[derive(thiserror::Error, Debug)]
94pub enum TemplateError {
95    /// Template rendering error from MiniJinja
96    #[error("{0}")]
97    RenderError(#[from] minijinja::Error),
98    /// Invalid template compilation error
99    #[error("{0}")]
100    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
101    /// Filter execution error
102    #[error("{0}")]
103    FilterError(String),
104}
105
106/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
107pub mod filter_api {
108    pub use minijinja::value::{Kwargs, Value};
109    pub use minijinja::{Error, ErrorKind, State};
110
111    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
112    pub trait DynFilter: Send + Sync + 'static {}
113    impl<T> DynFilter for T where T: Send + Sync + 'static {}
114}
115
116/// Type for filter functions that can be called via function pointers
117type FilterFn = fn(
118    &filter_api::State,
119    filter_api::Value,
120    filter_api::Kwargs,
121) -> Result<filter_api::Value, MjError>;
122
123/// Glue class for template rendering - provides interface for backends to interact with templates
124pub struct Glue {
125    template: String,
126    filters: HashMap<String, FilterFn>,
127}
128
129impl Glue {
130    /// Create a new Glue instance with a template string
131    pub fn new(template: String) -> Self {
132        Self {
133            template,
134            filters: HashMap::new(),
135        }
136    }
137
138    /// Register a filter with the template environment
139    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
140        self.filters.insert(name.to_string(), func);
141    }
142
143    /// Compose template with context from markdown decomposition
144    pub fn compose(
145        &mut self,
146        context: HashMap<String, serde_yaml::Value>,
147    ) -> Result<String, TemplateError> {
148        // Convert YAML values to MiniJinja values
149        let context = convert_yaml_to_minijinja(context)?;
150
151        // Create a new environment for this render
152        let mut env = Environment::new();
153
154        // Register all filters
155        for (name, filter_fn) in &self.filters {
156            let filter_fn = *filter_fn; // Copy the function pointer
157            env.add_filter(name, filter_fn);
158        }
159
160        env.add_template("main", &self.template).map_err(|e| {
161            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
162        })?;
163
164        // Render the template
165        let tmpl = env.get_template("main").map_err(|e| {
166            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
167        })?;
168
169        let result = tmpl.render(&context)?;
170        Ok(result)
171    }
172}
173
174/// Convert YAML values to MiniJinja values
175fn convert_yaml_to_minijinja(
176    yaml: HashMap<String, serde_yaml::Value>,
177) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
178    let mut result = HashMap::new();
179
180    for (key, value) in yaml {
181        let minijinja_value = yaml_to_minijinja_value(value)?;
182        result.insert(key, minijinja_value);
183    }
184
185    Ok(result)
186}
187
188/// Convert a single YAML value to a MiniJinja value
189fn yaml_to_minijinja_value(
190    value: serde_yaml::Value,
191) -> Result<minijinja::value::Value, TemplateError> {
192    use minijinja::value::Value as MjValue;
193    use serde_yaml::Value as YamlValue;
194
195    let result = match value {
196        YamlValue::Null => MjValue::from(()),
197        YamlValue::Bool(b) => MjValue::from(b),
198        YamlValue::Number(n) => {
199            if let Some(i) = n.as_i64() {
200                MjValue::from(i)
201            } else if let Some(u) = n.as_u64() {
202                MjValue::from(u)
203            } else if let Some(f) = n.as_f64() {
204                MjValue::from(f)
205            } else {
206                return Err(TemplateError::FilterError(
207                    "Invalid number in YAML".to_string(),
208                ));
209            }
210        }
211        YamlValue::String(s) => MjValue::from(s),
212        YamlValue::Sequence(seq) => {
213            let mut vec = Vec::new();
214            for item in seq {
215                vec.push(yaml_to_minijinja_value(item)?);
216            }
217            MjValue::from(vec)
218        }
219        YamlValue::Mapping(map) => {
220            let mut obj = std::collections::BTreeMap::new();
221            for (k, v) in map {
222                let key = match k {
223                    YamlValue::String(s) => s,
224                    _ => {
225                        return Err(TemplateError::FilterError(
226                            "Non-string key in YAML mapping".to_string(),
227                        ))
228                    }
229                };
230                obj.insert(key, yaml_to_minijinja_value(v)?);
231            }
232            MjValue::from_object(obj)
233        }
234        YamlValue::Tagged(tagged) => {
235            // For tagged values, just use the value part
236            yaml_to_minijinja_value(tagged.value)?
237        }
238    };
239
240    Ok(result)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use std::collections::HashMap;
247
248    #[test]
249    fn test_glue_creation() {
250        let _glue = Glue::new("Hello {{ name }}".to_string());
251        assert!(true);
252    }
253
254    #[test]
255    fn test_compose_simple_template() {
256        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
257        let mut context = HashMap::new();
258        context.insert(
259            "name".to_string(),
260            serde_yaml::Value::String("World".to_string()),
261        );
262        context.insert(
263            "body".to_string(),
264            serde_yaml::Value::String("Hello content".to_string()),
265        );
266
267        let result = glue.compose(context).unwrap();
268        assert!(result.contains("Hello World!"));
269        assert!(result.contains("Body: Hello content"));
270    }
271
272    #[test]
273    fn test_field_with_dash() {
274        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
275        let mut context = HashMap::new();
276        context.insert(
277            "letterhead_title".to_string(),
278            serde_yaml::Value::String("TEST VALUE".to_string()),
279        );
280        context.insert(
281            "body".to_string(),
282            serde_yaml::Value::String("body".to_string()),
283        );
284
285        let result = glue.compose(context).unwrap();
286        assert!(result.contains("TEST VALUE"));
287    }
288
289    #[test]
290    fn test_compose_with_dash_in_template() {
291        // Templates must reference the exact key names provided by the context.
292        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
293        let mut context = HashMap::new();
294        context.insert(
295            "letterhead_title".to_string(),
296            serde_yaml::Value::String("DASHED".to_string()),
297        );
298        context.insert(
299            "body".to_string(),
300            serde_yaml::Value::String("body".to_string()),
301        );
302
303        let result = glue.compose(context).unwrap();
304        assert!(result.contains("DASHED"));
305    }
306}