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        assert!(true);
348    }
349
350    #[test]
351    fn test_compose_simple_template() {
352        let mut plate = Plate::new("Hello {{ name }}! Body: {{ body }}".to_string());
353        let mut context = HashMap::new();
354        context.insert(
355            "name".to_string(),
356            QuillValue::from_json(serde_json::Value::String("World".to_string())),
357        );
358        context.insert(
359            "body".to_string(),
360            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
361        );
362
363        let result = plate.compose(context).unwrap();
364        assert!(result.contains("Hello World!"));
365        assert!(result.contains("Body: Hello content"));
366    }
367
368    #[test]
369    fn test_field_with_dash() {
370        let mut plate = Plate::new("Field: {{ letterhead_title }}".to_string());
371        let mut context = HashMap::new();
372        context.insert(
373            "letterhead_title".to_string(),
374            QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
375        );
376        context.insert(
377            "body".to_string(),
378            QuillValue::from_json(serde_json::Value::String("body".to_string())),
379        );
380
381        let result = plate.compose(context).unwrap();
382        assert!(result.contains("TEST VALUE"));
383    }
384
385    #[test]
386    fn test_compose_with_dash_in_template() {
387        // Templates must reference the exact key names provided by the context.
388        let mut plate = Plate::new("Field: {{ letterhead_title }}".to_string());
389        let mut context = HashMap::new();
390        context.insert(
391            "letterhead_title".to_string(),
392            QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
393        );
394        context.insert(
395            "body".to_string(),
396            QuillValue::from_json(serde_json::Value::String("body".to_string())),
397        );
398
399        let result = plate.compose(context).unwrap();
400        assert!(result.contains("DASHED"));
401    }
402
403    #[test]
404    fn test_template_output_size_limit() {
405        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
406        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
407        // that validates the check exists
408        let template = "{{ content }}".to_string();
409        let mut plate = Plate::new(template);
410
411        let mut context = HashMap::new();
412        // Create a large string (simulate large output)
413        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
414        // For testing purposes, we'll just ensure the mechanism works
415        context.insert(
416            "content".to_string(),
417            QuillValue::from_json(serde_json::Value::String("test".to_string())),
418        );
419
420        let result = plate.compose(context);
421        // This should succeed as it's well under the limit
422        assert!(result.is_ok());
423    }
424
425    #[test]
426    fn test_auto_plate_basic() {
427        let mut plate = Plate::new_auto();
428        let mut context = HashMap::new();
429        context.insert(
430            "name".to_string(),
431            QuillValue::from_json(serde_json::Value::String("World".to_string())),
432        );
433        context.insert(
434            "body".to_string(),
435            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
436        );
437
438        let result = plate.compose(context).unwrap();
439
440        // Parse the result as JSON to verify it's valid
441        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
442        assert_eq!(json["name"], "World");
443        assert_eq!(json["body"], "Hello content");
444    }
445
446    #[test]
447    fn test_auto_plate_with_nested_data() {
448        let mut plate = Plate::new_auto();
449        let mut context = HashMap::new();
450
451        // Add nested object
452        let nested_obj = serde_json::json!({
453            "first": "John",
454            "last": "Doe"
455        });
456        context.insert("author".to_string(), QuillValue::from_json(nested_obj));
457
458        // Add array
459        let tags = serde_json::json!(["tag1", "tag2", "tag3"]);
460        context.insert("tags".to_string(), QuillValue::from_json(tags));
461
462        let result = plate.compose(context).unwrap();
463
464        // Parse the result as JSON to verify structure
465        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
466        assert_eq!(json["author"]["first"], "John");
467        assert_eq!(json["author"]["last"], "Doe");
468        assert_eq!(json["tags"][0], "tag1");
469        assert_eq!(json["tags"].as_array().unwrap().len(), 3);
470    }
471
472    #[test]
473    fn test_auto_plate_filter_registration() {
474        // Test that filters can be registered (even though they're not used)
475        let mut plate = Plate::new_auto();
476
477        fn dummy_filter(
478            _state: &filter_api::State,
479            value: filter_api::Value,
480            _kwargs: filter_api::Kwargs,
481        ) -> Result<filter_api::Value, MjError> {
482            Ok(value)
483        }
484
485        // Should not panic
486        plate.register_filter("dummy", dummy_filter);
487
488        let mut context = HashMap::new();
489        context.insert(
490            "test".to_string(),
491            QuillValue::from_json(serde_json::Value::String("value".to_string())),
492        );
493
494        let result = plate.compose(context).unwrap();
495        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
496        assert_eq!(json["test"], "value");
497    }
498
499    #[test]
500    fn test_metadata_field_excludes_body() {
501        let template = "{% for key in __metadata__ %}{{ key }},{% endfor %}";
502        let mut plate = Plate::new(template.to_string());
503
504        let mut context = HashMap::new();
505        context.insert(
506            "title".to_string(),
507            QuillValue::from_json(serde_json::json!("Test")),
508        );
509        context.insert(
510            "author".to_string(),
511            QuillValue::from_json(serde_json::json!("John")),
512        );
513        context.insert(
514            "body".to_string(),
515            QuillValue::from_json(serde_json::json!("Body content")),
516        );
517
518        let result = plate.compose(context).unwrap();
519
520        // Should contain title and author, but not body
521        assert!(result.contains("title"));
522        assert!(result.contains("author"));
523        assert!(!result.contains("body"));
524    }
525
526    #[test]
527    fn test_metadata_field_includes_frontmatter() {
528        let template = r#"
529{%- for key in __metadata__ -%}
530{{ key }}
531{% endfor -%}
532"#;
533        let mut plate = Plate::new(template.to_string());
534
535        let mut context = HashMap::new();
536        context.insert(
537            "title".to_string(),
538            QuillValue::from_json(serde_json::json!("Test Document")),
539        );
540        context.insert(
541            "author".to_string(),
542            QuillValue::from_json(serde_json::json!("Jane Doe")),
543        );
544        context.insert(
545            "date".to_string(),
546            QuillValue::from_json(serde_json::json!("2024-01-01")),
547        );
548        context.insert(
549            "body".to_string(),
550            QuillValue::from_json(serde_json::json!("Document body")),
551        );
552
553        let result = plate.compose(context).unwrap();
554
555        // All metadata fields should be present as keys
556        assert!(result.contains("title"));
557        assert!(result.contains("author"));
558        assert!(result.contains("date"));
559        // Body should not be in metadata iteration
560        assert!(!result.contains("body"));
561    }
562
563    #[test]
564    fn test_metadata_field_empty_when_only_body() {
565        let template = "Metadata count: {{ __metadata__ | length }}";
566        let mut plate = Plate::new(template.to_string());
567
568        let mut context = HashMap::new();
569        context.insert(
570            "body".to_string(),
571            QuillValue::from_json(serde_json::json!("Only body content")),
572        );
573
574        let result = plate.compose(context).unwrap();
575
576        // Should have 0 metadata fields when only body is present
577        assert!(result.contains("Metadata count: 0"));
578    }
579
580    #[test]
581    fn test_backward_compatibility_top_level_access() {
582        let template = "Title: {{ title }}, Author: {{ author }}, Body: {{ body }}";
583        let mut plate = Plate::new(template.to_string());
584
585        let mut context = HashMap::new();
586        context.insert(
587            "title".to_string(),
588            QuillValue::from_json(serde_json::json!("My Title")),
589        );
590        context.insert(
591            "author".to_string(),
592            QuillValue::from_json(serde_json::json!("Author Name")),
593        );
594        context.insert(
595            "body".to_string(),
596            QuillValue::from_json(serde_json::json!("Body text")),
597        );
598
599        let result = plate.compose(context).unwrap();
600
601        // Top-level access should still work
602        assert!(result.contains("Title: My Title"));
603        assert!(result.contains("Author: Author Name"));
604        assert!(result.contains("Body: Body text"));
605    }
606
607    #[test]
608    fn test_metadata_iteration_in_template() {
609        let template = r#"
610{%- set metadata_count = __metadata__ | length -%}
611Metadata fields: {{ metadata_count }}
612{%- for key in __metadata__ %}
613- {{ key }}: {{ __metadata__[key] }}
614{%- endfor %}
615Body present: {{ body | length > 0 }}
616"#;
617        let mut plate = Plate::new(template.to_string());
618
619        let mut context = HashMap::new();
620        context.insert(
621            "title".to_string(),
622            QuillValue::from_json(serde_json::json!("Test")),
623        );
624        context.insert(
625            "version".to_string(),
626            QuillValue::from_json(serde_json::json!("1.0")),
627        );
628        context.insert(
629            "body".to_string(),
630            QuillValue::from_json(serde_json::json!("Content")),
631        );
632
633        let result = plate.compose(context).unwrap();
634
635        // Should have exactly 2 metadata fields
636        assert!(result.contains("Metadata fields: 2"));
637        // Body should still be accessible directly
638        assert!(result.contains("Body present: true"));
639    }
640
641    #[test]
642    fn test_auto_plate_metadata_field() {
643        let mut plate = Plate::new_auto();
644
645        let mut context = HashMap::new();
646        context.insert(
647            "title".to_string(),
648            QuillValue::from_json(serde_json::json!("Document")),
649        );
650        context.insert(
651            "author".to_string(),
652            QuillValue::from_json(serde_json::json!("Writer")),
653        );
654        context.insert(
655            "body".to_string(),
656            QuillValue::from_json(serde_json::json!("Content here")),
657        );
658
659        let result = plate.compose(context).unwrap();
660
661        // Parse as JSON
662        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
663
664        // Verify __metadata__ field exists and contains correct fields
665        assert!(json["__metadata__"].is_object());
666        assert_eq!(json["__metadata__"]["title"], "Document");
667        assert_eq!(json["__metadata__"]["author"], "Writer");
668
669        // Body should not be in metadata
670        assert!(json["__metadata__"]["body"].is_null());
671
672        // But body should be at top level
673        assert_eq!(json["body"], "Content here");
674    }
675
676    #[test]
677    fn test_metadata_with_nested_objects() {
678        let template = "{{ __metadata__.author.name }}";
679        let mut plate = Plate::new(template.to_string());
680
681        let mut context = HashMap::new();
682        context.insert(
683            "author".to_string(),
684            QuillValue::from_json(serde_json::json!({
685                "name": "John Doe",
686                "email": "john@example.com"
687            })),
688        );
689        context.insert(
690            "body".to_string(),
691            QuillValue::from_json(serde_json::json!("Text")),
692        );
693
694        let result = plate.compose(context).unwrap();
695
696        // Should access nested metadata via __metadata__
697        assert!(result.contains("John Doe"));
698    }
699
700    #[test]
701    fn test_metadata_with_arrays() {
702        let template = "Tags: {{ __metadata__.tags | length }}";
703        let mut plate = Plate::new(template.to_string());
704
705        let mut context = HashMap::new();
706        context.insert(
707            "tags".to_string(),
708            QuillValue::from_json(serde_json::json!(["rust", "markdown", "template"])),
709        );
710        context.insert(
711            "body".to_string(),
712            QuillValue::from_json(serde_json::json!("Content")),
713        );
714
715        let result = plate.compose(context).unwrap();
716
717        // Should show 3 tags
718        assert!(result.contains("Tags: 3"));
719    }
720}