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 [`Plate`] type for template rendering and a stable
8//! filter API for backends to register custom filters.
9//!
10//! ## Key Types
11//!
12//! - [`Plate`]: 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::{Plate, 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 plate = Plate::new(template.to_string());
31//!
32//! // Register filters (done by backends)
33//! // plate.register_filter("String", string_filter);
34//! // plate.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 = plate.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::Plate;
48//! # let mut plate = Plate::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 plate
62//! plate.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::{BTreeMap, HashMap};
87use std::error::Error as StdError;
88
89use minijinja::{Environment, Error as MjError};
90
91use crate::parse::BODY_FIELD;
92use crate::value::QuillValue;
93
94/// Error types for template rendering
95#[derive(thiserror::Error, Debug)]
96pub enum TemplateError {
97    /// Template rendering error from MiniJinja
98    #[error("{0}")]
99    RenderError(#[from] minijinja::Error),
100    /// Invalid template compilation error
101    #[error("{0}")]
102    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
103    /// Filter execution error
104    #[error("{0}")]
105    FilterError(String),
106}
107
108/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
109pub mod filter_api {
110    pub use minijinja::value::{Kwargs, Value};
111    pub use minijinja::{Error, ErrorKind, State};
112
113    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
114    pub trait DynFilter: Send + Sync + 'static {}
115    impl<T> DynFilter for T where T: Send + Sync + 'static {}
116}
117
118/// Type for filter functions that can be called via function pointers
119type FilterFn = fn(
120    &filter_api::State,
121    filter_api::Value,
122    filter_api::Kwargs,
123) -> Result<filter_api::Value, MjError>;
124
125/// Trait for plate engines that compose context into output
126pub trait PlateEngine {
127    /// Register a filter with the engine
128    fn register_filter(&mut self, name: &str, func: FilterFn);
129
130    /// Compose context from markdown decomposition into output
131    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError>;
132}
133
134/// Template-based plate engine using MiniJinja
135pub struct TemplatePlate {
136    template: String,
137    filters: HashMap<String, FilterFn>,
138}
139
140/// Auto plate engine that outputs context as JSON
141pub struct AutoPlate {
142    filters: HashMap<String, FilterFn>,
143}
144
145/// Plate type that can be either template-based or auto
146pub enum Plate {
147    /// Template-based plate using MiniJinja
148    Template(TemplatePlate),
149    /// Auto plate that outputs context as JSON
150    Auto(AutoPlate),
151}
152
153impl TemplatePlate {
154    /// Create a new TemplatePlate instance with a template string
155    pub fn new(template: String) -> Self {
156        Self {
157            template,
158            filters: HashMap::new(),
159        }
160    }
161}
162
163impl PlateEngine for TemplatePlate {
164    /// Register a filter with the template environment
165    fn register_filter(&mut self, name: &str, func: FilterFn) {
166        self.filters.insert(name.to_string(), func);
167    }
168
169    /// Compose template with context from markdown decomposition
170    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
171        // Separate metadata from body using helper function
172        let metadata_fields = separate_metadata_fields(&context);
173
174        // Convert QuillValue to MiniJinja values
175        let mut minijinja_context = convert_quillvalue_to_minijinja(context)?;
176        let metadata_minijinja = convert_quillvalue_to_minijinja(metadata_fields)?;
177
178        // Add __metadata__ field as a MiniJinja object
179        // Convert HashMap to BTreeMap for from_object
180        let metadata_btree: BTreeMap<String, minijinja::value::Value> =
181            metadata_minijinja.into_iter().collect();
182        minijinja_context.insert(
183            "__metadata__".to_string(),
184            minijinja::value::Value::from_object(metadata_btree),
185        );
186
187        // Create a new environment for this render
188        let mut env = Environment::new();
189
190        // Register all filters
191        for (name, filter_fn) in &self.filters {
192            let filter_fn = *filter_fn; // Copy the function pointer
193            env.add_filter(name, filter_fn);
194        }
195
196        env.add_template("main", &self.template).map_err(|e| {
197            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
198        })?;
199
200        // Render the template
201        let tmpl = env.get_template("main").map_err(|e| {
202            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
203        })?;
204
205        let result = tmpl.render(&minijinja_context)?;
206
207        // Check output size limit
208        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
209            return Err(TemplateError::FilterError(format!(
210                "Template output too large: {} bytes (max: {} bytes)",
211                result.len(),
212                crate::error::MAX_TEMPLATE_OUTPUT
213            )));
214        }
215
216        Ok(result)
217    }
218}
219
220impl AutoPlate {
221    /// Create a new AutoPlate instance
222    pub fn new() -> Self {
223        Self {
224            filters: HashMap::new(),
225        }
226    }
227}
228
229impl Default for AutoPlate {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235impl PlateEngine for AutoPlate {
236    /// Register a filter with the auto plate (ignored for JSON output)
237    fn register_filter(&mut self, name: &str, func: FilterFn) {
238        // Store filters even though they're not used for JSON output
239        // This maintains consistency with the trait interface
240        self.filters.insert(name.to_string(), func);
241    }
242
243    /// Compose context into JSON output
244    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
245        // Build both json_map and metadata_json in a single pass to avoid redundant iterations
246        let mut json_map = serde_json::Map::new();
247        let mut metadata_json = serde_json::Map::new();
248
249        for (key, value) in &context {
250            let json_value = value.as_json().clone();
251            json_map.insert(key.clone(), json_value.clone());
252
253            // Add to metadata if not the body field
254            if key.as_str() != BODY_FIELD {
255                metadata_json.insert(key.clone(), json_value);
256            }
257        }
258
259        // Add __metadata__ object to json_map
260        json_map.insert(
261            "__metadata__".to_string(),
262            serde_json::Value::Object(metadata_json),
263        );
264
265        let json_value = serde_json::Value::Object(json_map);
266        let result = serde_json::to_string_pretty(&json_value).map_err(|e| {
267            TemplateError::FilterError(format!("Failed to serialize to JSON: {}", e))
268        })?;
269
270        // Check output size limit
271        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
272            return Err(TemplateError::FilterError(format!(
273                "JSON output too large: {} bytes (max: {} bytes)",
274                result.len(),
275                crate::error::MAX_TEMPLATE_OUTPUT
276            )));
277        }
278
279        Ok(result)
280    }
281}
282
283impl Plate {
284    /// Create a new template-based Plate instance
285    pub fn new(template: String) -> Self {
286        Plate::Template(TemplatePlate::new(template))
287    }
288
289    /// Create a new auto plate instance
290    pub fn new_auto() -> Self {
291        Plate::Auto(AutoPlate::new())
292    }
293
294    /// Register a filter with the plate engine
295    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
296        match self {
297            Plate::Template(engine) => engine.register_filter(name, func),
298            Plate::Auto(engine) => engine.register_filter(name, func),
299        }
300    }
301
302    /// Compose context into output
303    pub fn compose(
304        &mut self,
305        context: HashMap<String, QuillValue>,
306    ) -> Result<String, TemplateError> {
307        match self {
308            Plate::Template(engine) => engine.compose(context),
309            Plate::Auto(engine) => engine.compose(context),
310        }
311    }
312}
313
314/// Separate metadata fields from body field
315fn separate_metadata_fields(context: &HashMap<String, QuillValue>) -> HashMap<String, QuillValue> {
316    context
317        .iter()
318        .filter(|(key, _)| key.as_str() != BODY_FIELD)
319        .map(|(k, v)| (k.clone(), v.clone()))
320        .collect()
321}
322
323/// Convert QuillValue map to MiniJinja values
324fn convert_quillvalue_to_minijinja(
325    fields: HashMap<String, QuillValue>,
326) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
327    let mut result = HashMap::new();
328
329    for (key, value) in fields {
330        let minijinja_value = value.to_minijinja().map_err(|e| {
331            TemplateError::FilterError(format!("Failed to convert QuillValue to MiniJinja: {}", e))
332        })?;
333        result.insert(key, minijinja_value);
334    }
335
336    Ok(result)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::collections::HashMap;
343
344    #[test]
345    fn test_plate_creation() {
346        let _plate = Plate::new("Hello {{ name }}".to_string());
347    }
348
349    #[test]
350    fn test_compose_simple_template() {
351        let mut plate = Plate::new("Hello {{ name }}! Body: {{ body }}".to_string());
352        let mut context = HashMap::new();
353        context.insert(
354            "name".to_string(),
355            QuillValue::from_json(serde_json::Value::String("World".to_string())),
356        );
357        context.insert(
358            "body".to_string(),
359            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
360        );
361
362        let result = plate.compose(context).unwrap();
363        assert!(result.contains("Hello World!"));
364        assert!(result.contains("Body: Hello content"));
365    }
366
367    #[test]
368    fn test_field_with_dash() {
369        let mut plate = Plate::new("Field: {{ letterhead_title }}".to_string());
370        let mut context = HashMap::new();
371        context.insert(
372            "letterhead_title".to_string(),
373            QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
374        );
375        context.insert(
376            "body".to_string(),
377            QuillValue::from_json(serde_json::Value::String("body".to_string())),
378        );
379
380        let result = plate.compose(context).unwrap();
381        assert!(result.contains("TEST VALUE"));
382    }
383
384    #[test]
385    fn test_compose_with_dash_in_template() {
386        // Templates must reference the exact key names provided by the context.
387        let mut plate = Plate::new("Field: {{ letterhead_title }}".to_string());
388        let mut context = HashMap::new();
389        context.insert(
390            "letterhead_title".to_string(),
391            QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
392        );
393        context.insert(
394            "body".to_string(),
395            QuillValue::from_json(serde_json::Value::String("body".to_string())),
396        );
397
398        let result = plate.compose(context).unwrap();
399        assert!(result.contains("DASHED"));
400    }
401
402    #[test]
403    fn test_template_output_size_limit() {
404        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
405        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
406        // that validates the check exists
407        let template = "{{ content }}".to_string();
408        let mut plate = Plate::new(template);
409
410        let mut context = HashMap::new();
411        // Create a large string (simulate large output)
412        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
413        // For testing purposes, we'll just ensure the mechanism works
414        context.insert(
415            "content".to_string(),
416            QuillValue::from_json(serde_json::Value::String("test".to_string())),
417        );
418
419        let result = plate.compose(context);
420        // This should succeed as it's well under the limit
421        assert!(result.is_ok());
422    }
423
424    #[test]
425    fn test_auto_plate_basic() {
426        let mut plate = Plate::new_auto();
427        let mut context = HashMap::new();
428        context.insert(
429            "name".to_string(),
430            QuillValue::from_json(serde_json::Value::String("World".to_string())),
431        );
432        context.insert(
433            "body".to_string(),
434            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
435        );
436
437        let result = plate.compose(context).unwrap();
438
439        // Parse the result as JSON to verify it's valid
440        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
441        assert_eq!(json["name"], "World");
442        assert_eq!(json["body"], "Hello content");
443    }
444
445    #[test]
446    fn test_auto_plate_with_nested_data() {
447        let mut plate = Plate::new_auto();
448        let mut context = HashMap::new();
449
450        // Add nested object
451        let nested_obj = serde_json::json!({
452            "first": "John",
453            "last": "Doe"
454        });
455        context.insert("author".to_string(), QuillValue::from_json(nested_obj));
456
457        // Add array
458        let tags = serde_json::json!(["tag1", "tag2", "tag3"]);
459        context.insert("tags".to_string(), QuillValue::from_json(tags));
460
461        let result = plate.compose(context).unwrap();
462
463        // Parse the result as JSON to verify structure
464        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
465        assert_eq!(json["author"]["first"], "John");
466        assert_eq!(json["author"]["last"], "Doe");
467        assert_eq!(json["tags"][0], "tag1");
468        assert_eq!(json["tags"].as_array().unwrap().len(), 3);
469    }
470
471    #[test]
472    fn test_auto_plate_filter_registration() {
473        // Test that filters can be registered (even though they're not used)
474        let mut plate = Plate::new_auto();
475
476        fn dummy_filter(
477            _state: &filter_api::State,
478            value: filter_api::Value,
479            _kwargs: filter_api::Kwargs,
480        ) -> Result<filter_api::Value, MjError> {
481            Ok(value)
482        }
483
484        // Should not panic
485        plate.register_filter("dummy", dummy_filter);
486
487        let mut context = HashMap::new();
488        context.insert(
489            "test".to_string(),
490            QuillValue::from_json(serde_json::Value::String("value".to_string())),
491        );
492
493        let result = plate.compose(context).unwrap();
494        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
495        assert_eq!(json["test"], "value");
496    }
497
498    #[test]
499    fn test_metadata_field_excludes_body() {
500        let template = "{% for key in __metadata__ %}{{ key }},{% endfor %}";
501        let mut plate = Plate::new(template.to_string());
502
503        let mut context = HashMap::new();
504        context.insert(
505            "title".to_string(),
506            QuillValue::from_json(serde_json::json!("Test")),
507        );
508        context.insert(
509            "author".to_string(),
510            QuillValue::from_json(serde_json::json!("John")),
511        );
512        context.insert(
513            "body".to_string(),
514            QuillValue::from_json(serde_json::json!("Body content")),
515        );
516
517        let result = plate.compose(context).unwrap();
518
519        // Should contain title and author, but not body
520        assert!(result.contains("title"));
521        assert!(result.contains("author"));
522        assert!(!result.contains("body"));
523    }
524
525    #[test]
526    fn test_metadata_field_includes_frontmatter() {
527        let template = r#"
528{%- for key in __metadata__ -%}
529{{ key }}
530{% endfor -%}
531"#;
532        let mut plate = Plate::new(template.to_string());
533
534        let mut context = HashMap::new();
535        context.insert(
536            "title".to_string(),
537            QuillValue::from_json(serde_json::json!("Test Document")),
538        );
539        context.insert(
540            "author".to_string(),
541            QuillValue::from_json(serde_json::json!("Jane Doe")),
542        );
543        context.insert(
544            "date".to_string(),
545            QuillValue::from_json(serde_json::json!("2024-01-01")),
546        );
547        context.insert(
548            "body".to_string(),
549            QuillValue::from_json(serde_json::json!("Document body")),
550        );
551
552        let result = plate.compose(context).unwrap();
553
554        // All metadata fields should be present as keys
555        assert!(result.contains("title"));
556        assert!(result.contains("author"));
557        assert!(result.contains("date"));
558        // Body should not be in metadata iteration
559        assert!(!result.contains("body"));
560    }
561
562    #[test]
563    fn test_metadata_field_empty_when_only_body() {
564        let template = "Metadata count: {{ __metadata__ | length }}";
565        let mut plate = Plate::new(template.to_string());
566
567        let mut context = HashMap::new();
568        context.insert(
569            "body".to_string(),
570            QuillValue::from_json(serde_json::json!("Only body content")),
571        );
572
573        let result = plate.compose(context).unwrap();
574
575        // Should have 0 metadata fields when only body is present
576        assert!(result.contains("Metadata count: 0"));
577    }
578
579    #[test]
580    fn test_backward_compatibility_top_level_access() {
581        let template = "Title: {{ title }}, Author: {{ author }}, Body: {{ body }}";
582        let mut plate = Plate::new(template.to_string());
583
584        let mut context = HashMap::new();
585        context.insert(
586            "title".to_string(),
587            QuillValue::from_json(serde_json::json!("My Title")),
588        );
589        context.insert(
590            "author".to_string(),
591            QuillValue::from_json(serde_json::json!("Author Name")),
592        );
593        context.insert(
594            "body".to_string(),
595            QuillValue::from_json(serde_json::json!("Body text")),
596        );
597
598        let result = plate.compose(context).unwrap();
599
600        // Top-level access should still work
601        assert!(result.contains("Title: My Title"));
602        assert!(result.contains("Author: Author Name"));
603        assert!(result.contains("Body: Body text"));
604    }
605
606    #[test]
607    fn test_metadata_iteration_in_template() {
608        let template = r#"
609{%- set metadata_count = __metadata__ | length -%}
610Metadata fields: {{ metadata_count }}
611{%- for key in __metadata__ %}
612- {{ key }}: {{ __metadata__[key] }}
613{%- endfor %}
614Body present: {{ body | length > 0 }}
615"#;
616        let mut plate = Plate::new(template.to_string());
617
618        let mut context = HashMap::new();
619        context.insert(
620            "title".to_string(),
621            QuillValue::from_json(serde_json::json!("Test")),
622        );
623        context.insert(
624            "version".to_string(),
625            QuillValue::from_json(serde_json::json!("1.0")),
626        );
627        context.insert(
628            "body".to_string(),
629            QuillValue::from_json(serde_json::json!("Content")),
630        );
631
632        let result = plate.compose(context).unwrap();
633
634        // Should have exactly 2 metadata fields
635        assert!(result.contains("Metadata fields: 2"));
636        // Body should still be accessible directly
637        assert!(result.contains("Body present: true"));
638    }
639
640    #[test]
641    fn test_auto_plate_metadata_field() {
642        let mut plate = Plate::new_auto();
643
644        let mut context = HashMap::new();
645        context.insert(
646            "title".to_string(),
647            QuillValue::from_json(serde_json::json!("Document")),
648        );
649        context.insert(
650            "author".to_string(),
651            QuillValue::from_json(serde_json::json!("Writer")),
652        );
653        context.insert(
654            "body".to_string(),
655            QuillValue::from_json(serde_json::json!("Content here")),
656        );
657
658        let result = plate.compose(context).unwrap();
659
660        // Parse as JSON
661        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
662
663        // Verify __metadata__ field exists and contains correct fields
664        assert!(json["__metadata__"].is_object());
665        assert_eq!(json["__metadata__"]["title"], "Document");
666        assert_eq!(json["__metadata__"]["author"], "Writer");
667
668        // Body should not be in metadata
669        assert!(json["__metadata__"]["body"].is_null());
670
671        // But body should be at top level
672        assert_eq!(json["body"], "Content here");
673    }
674
675    #[test]
676    fn test_metadata_with_nested_objects() {
677        let template = "{{ __metadata__.author.name }}";
678        let mut plate = Plate::new(template.to_string());
679
680        let mut context = HashMap::new();
681        context.insert(
682            "author".to_string(),
683            QuillValue::from_json(serde_json::json!({
684                "name": "John Doe",
685                "email": "john@example.com"
686            })),
687        );
688        context.insert(
689            "body".to_string(),
690            QuillValue::from_json(serde_json::json!("Text")),
691        );
692
693        let result = plate.compose(context).unwrap();
694
695        // Should access nested metadata via __metadata__
696        assert!(result.contains("John Doe"));
697    }
698
699    #[test]
700    fn test_metadata_with_arrays() {
701        let template = "Tags: {{ __metadata__.tags | length }}";
702        let mut plate = Plate::new(template.to_string());
703
704        let mut context = HashMap::new();
705        context.insert(
706            "tags".to_string(),
707            QuillValue::from_json(serde_json::json!(["rust", "markdown", "template"])),
708        );
709        context.insert(
710            "body".to_string(),
711            QuillValue::from_json(serde_json::json!("Content")),
712        );
713
714        let result = plate.compose(context).unwrap();
715
716        // Should show 3 tags
717        assert!(result.contains("Tags: 3"));
718    }
719}