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, QuillValue};
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(), QuillValue::from_json(serde_json::json!("My Doc")));
38//! context.insert("body".to_string(), QuillValue::from_json(serde_json::json!("Content")));
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};
90
91use crate::value::QuillValue;
92
93/// Error types for template rendering
94#[derive(thiserror::Error, Debug)]
95pub enum TemplateError {
96 /// Template rendering error from MiniJinja
97 #[error("{0}")]
98 RenderError(#[from] minijinja::Error),
99 /// Invalid template compilation error
100 #[error("{0}")]
101 InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
102 /// Filter execution error
103 #[error("{0}")]
104 FilterError(String),
105}
106
107/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
108pub mod filter_api {
109 pub use minijinja::value::{Kwargs, Value};
110 pub use minijinja::{Error, ErrorKind, State};
111
112 /// Trait alias for closures/functions used as filters (thread-safe, 'static)
113 pub trait DynFilter: Send + Sync + 'static {}
114 impl<T> DynFilter for T where T: Send + Sync + 'static {}
115}
116
117/// Type for filter functions that can be called via function pointers
118type FilterFn = fn(
119 &filter_api::State,
120 filter_api::Value,
121 filter_api::Kwargs,
122) -> Result<filter_api::Value, MjError>;
123
124/// Glue class for template rendering - provides interface for backends to interact with templates
125pub struct Glue {
126 template: String,
127 filters: HashMap<String, FilterFn>,
128}
129
130impl Glue {
131 /// Create a new Glue instance with a template string
132 pub fn new(template: String) -> Self {
133 Self {
134 template,
135 filters: HashMap::new(),
136 }
137 }
138
139 /// Register a filter with the template environment
140 pub fn register_filter(&mut self, name: &str, func: FilterFn) {
141 self.filters.insert(name.to_string(), func);
142 }
143
144 /// Compose template with context from markdown decomposition
145 pub fn compose(
146 &mut self,
147 context: HashMap<String, QuillValue>,
148 ) -> Result<String, TemplateError> {
149 // Convert QuillValue to MiniJinja values
150 let context = convert_quillvalue_to_minijinja(context)?;
151
152 // Create a new environment for this render
153 let mut env = Environment::new();
154
155 // Register all filters
156 for (name, filter_fn) in &self.filters {
157 let filter_fn = *filter_fn; // Copy the function pointer
158 env.add_filter(name, filter_fn);
159 }
160
161 env.add_template("main", &self.template).map_err(|e| {
162 TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
163 })?;
164
165 // Render the template
166 let tmpl = env.get_template("main").map_err(|e| {
167 TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
168 })?;
169
170 let result = tmpl.render(&context)?;
171
172 // Check output size limit
173 if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
174 return Err(TemplateError::FilterError(format!(
175 "Template output too large: {} bytes (max: {} bytes)",
176 result.len(),
177 crate::error::MAX_TEMPLATE_OUTPUT
178 )));
179 }
180
181 Ok(result)
182 }
183}
184
185/// Convert QuillValue map to MiniJinja values
186fn convert_quillvalue_to_minijinja(
187 fields: HashMap<String, QuillValue>,
188) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
189 let mut result = HashMap::new();
190
191 for (key, value) in fields {
192 let minijinja_value = value.to_minijinja().map_err(|e| {
193 TemplateError::FilterError(format!("Failed to convert QuillValue to MiniJinja: {}", e))
194 })?;
195 result.insert(key, minijinja_value);
196 }
197
198 Ok(result)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::collections::HashMap;
205
206 #[test]
207 fn test_glue_creation() {
208 let _glue = Glue::new("Hello {{ name }}".to_string());
209 assert!(true);
210 }
211
212 #[test]
213 fn test_compose_simple_template() {
214 let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
215 let mut context = HashMap::new();
216 context.insert(
217 "name".to_string(),
218 QuillValue::from_json(serde_json::Value::String("World".to_string())),
219 );
220 context.insert(
221 "body".to_string(),
222 QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
223 );
224
225 let result = glue.compose(context).unwrap();
226 assert!(result.contains("Hello World!"));
227 assert!(result.contains("Body: Hello content"));
228 }
229
230 #[test]
231 fn test_field_with_dash() {
232 let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
233 let mut context = HashMap::new();
234 context.insert(
235 "letterhead_title".to_string(),
236 QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
237 );
238 context.insert(
239 "body".to_string(),
240 QuillValue::from_json(serde_json::Value::String("body".to_string())),
241 );
242
243 let result = glue.compose(context).unwrap();
244 assert!(result.contains("TEST VALUE"));
245 }
246
247 #[test]
248 fn test_compose_with_dash_in_template() {
249 // Templates must reference the exact key names provided by the context.
250 let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
251 let mut context = HashMap::new();
252 context.insert(
253 "letterhead_title".to_string(),
254 QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
255 );
256 context.insert(
257 "body".to_string(),
258 QuillValue::from_json(serde_json::Value::String("body".to_string())),
259 );
260
261 let result = glue.compose(context).unwrap();
262 assert!(result.contains("DASHED"));
263 }
264
265 #[test]
266 fn test_template_output_size_limit() {
267 // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
268 // We can't easily create 50MB+ output in a test, so we'll use a smaller test
269 // that validates the check exists
270 let template = "{{ content }}".to_string();
271 let mut glue = Glue::new(template);
272
273 let mut context = HashMap::new();
274 // Create a large string (simulate large output)
275 // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
276 // For testing purposes, we'll just ensure the mechanism works
277 context.insert(
278 "content".to_string(),
279 QuillValue::from_json(serde_json::Value::String("test".to_string())),
280 );
281
282 let result = glue.compose(context);
283 // This should succeed as it's well under the limit
284 assert!(result.is_ok());
285 }
286}