agpm_cli/templating/
renderer.rs

1//! Template rendering engine with Tera.
2//!
3//! This module provides the TemplateRenderer struct that wraps Tera with
4//! AGPM-specific configuration, custom filters, and literal block handling.
5
6use anyhow::Result;
7use regex::Regex;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use strsim::levenshtein;
11use tera::{Context as TeraContext, Tera};
12
13use super::content::NON_TEMPLATED_LITERAL_GUARD_END;
14use super::content::NON_TEMPLATED_LITERAL_GUARD_START;
15use super::error::{ErrorLocation, TemplateError};
16use super::filters;
17use crate::core::ResourceType;
18
19/// Context information about the current rendering operation
20#[derive(Debug, Clone)]
21pub struct RenderingMetadata {
22    /// The resource currently being rendered
23    pub resource_name: String,
24    /// The type of resource (agent, command, snippet, etc.)
25    pub resource_type: ResourceType,
26    /// Full dependency chain from root to current resource
27    pub dependency_chain: Vec<DependencyChainEntry>,
28    /// Source file path if available
29    pub source_path: Option<PathBuf>,
30    /// Current rendering depth (for content filter recursion)
31    pub depth: usize,
32}
33
34#[derive(Debug, Clone)]
35pub struct DependencyChainEntry {
36    pub resource_type: ResourceType,
37    pub name: String,
38    pub path: Option<String>,
39}
40
41/// Template renderer with Tera engine and custom functions.
42///
43/// This struct wraps a Tera instance with AGPM-specific configuration,
44/// custom functions, and filters. It provides a safe, sandboxed environment
45/// for rendering Markdown templates.
46///
47/// # Security
48///
49/// The renderer is configured with security restrictions:
50/// - No file system access via includes/extends (except content filter)
51/// - No network access
52/// - Sandboxed template execution
53/// - Custom functions are carefully vetted
54/// - Project file access restricted to project directory with validation
55pub struct TemplateRenderer {
56    /// The underlying Tera template engine
57    tera: Tera,
58    /// Whether templating is enabled globally
59    enabled: bool,
60}
61
62impl TemplateRenderer {
63    /// Create a new template renderer with AGPM-specific configuration.
64    ///
65    /// # Arguments
66    ///
67    /// * `enabled` - Whether templating is enabled globally
68    /// * `project_dir` - Project root directory for content filter validation
69    /// * `max_content_file_size` - Maximum file size in bytes for content filter (None for no limit)
70    ///
71    /// # Returns
72    ///
73    /// Returns a configured `TemplateRenderer` instance with custom filters registered.
74    ///
75    /// # Filters
76    ///
77    /// The following custom filters are registered:
78    /// - `content`: Read project-specific files with path validation and size limits
79    pub fn new(
80        enabled: bool,
81        project_dir: PathBuf,
82        max_content_file_size: Option<u64>,
83    ) -> Result<Self> {
84        let mut tera = Tera::default();
85
86        // Register custom filters
87        tera.register_filter(
88            "content",
89            filters::create_content_filter(project_dir.clone(), max_content_file_size),
90        );
91
92        Ok(Self {
93            tera,
94            enabled,
95        })
96    }
97
98    /// Protect literal blocks from template rendering by replacing them with placeholders.
99    ///
100    /// This method scans for ```literal fenced code blocks and replaces them with
101    /// unique placeholders that won't be affected by template rendering. The original
102    /// content is stored in a HashMap that can be used to restore the blocks later.
103    ///
104    /// # Arguments
105    ///
106    /// * `content` - The content to process
107    ///
108    /// # Returns
109    ///
110    /// Returns a tuple of:
111    /// - Modified content with placeholders instead of literal blocks
112    /// - HashMap mapping placeholder IDs to original content
113    ///
114    /// # Examples
115    ///
116    /// ````markdown
117    /// # Documentation Example
118    ///
119    /// Use this syntax in templates:
120    ///
121    /// ```literal
122    /// {{ agpm.deps.snippets.example.content }}
123    /// ```
124    /// ````
125    ///
126    /// The content inside the literal block will be protected from rendering.
127    pub(crate) fn protect_literal_blocks(
128        &self,
129        content: &str,
130    ) -> (String, HashMap<String, String>) {
131        let mut placeholders = HashMap::new();
132        let mut counter = 0;
133        let mut result = String::with_capacity(content.len());
134
135        // Split content by lines to find both ```literal fences and RAW guards
136        let mut in_literal_fence = false;
137        let mut in_raw_guard = false;
138        let mut current_block = String::new();
139        let lines = content.lines();
140
141        for line in lines {
142            let trimmed = line.trim();
143
144            if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
145                // Start of RAW guard block
146                in_raw_guard = true;
147                current_block.clear();
148                tracing::debug!("Found start of RAW guard block");
149                // Skip the guard line
150            } else if in_raw_guard && trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
151                // End of RAW guard block
152                in_raw_guard = false;
153
154                // Generate unique placeholder
155                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
156                counter += 1;
157
158                // Store original content (keep the guards for later processing)
159                let guarded_content = format!(
160                    "{}\n{}\n{}",
161                    NON_TEMPLATED_LITERAL_GUARD_START,
162                    current_block,
163                    NON_TEMPLATED_LITERAL_GUARD_END
164                );
165                placeholders.insert(placeholder_id.clone(), guarded_content);
166
167                // Insert placeholder
168                result.push_str(&placeholder_id);
169                result.push('\n');
170
171                tracing::debug!(
172                    "Protected RAW guard block with placeholder {} ({} bytes)",
173                    placeholder_id,
174                    current_block.len()
175                );
176
177                current_block.clear();
178                // Skip the guard line
179            } else if in_raw_guard {
180                // Inside RAW guard - accumulate content
181                if !current_block.is_empty() {
182                    current_block.push('\n');
183                }
184                current_block.push_str(line);
185            } else if trimmed.starts_with("```literal") {
186                // Start of ```literal fence
187                in_literal_fence = true;
188                current_block.clear();
189                tracing::debug!("Found start of literal fence");
190                // Skip the fence line
191            } else if in_literal_fence && trimmed.starts_with("```") {
192                // End of ```literal fence
193                in_literal_fence = false;
194
195                // Generate unique placeholder
196                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
197                counter += 1;
198
199                // Store original content
200                placeholders.insert(placeholder_id.clone(), current_block.clone());
201
202                // Insert placeholder
203                result.push_str(&placeholder_id);
204                result.push('\n');
205
206                tracing::debug!(
207                    "Protected literal fence with placeholder {} ({} bytes)",
208                    placeholder_id,
209                    current_block.len()
210                );
211
212                current_block.clear();
213                // Skip the fence line
214            } else if in_literal_fence {
215                // Inside ```literal fence - accumulate content
216                if !current_block.is_empty() {
217                    current_block.push('\n');
218                }
219                current_block.push_str(line);
220            } else {
221                // Regular content - pass through
222                result.push_str(line);
223                result.push('\n');
224            }
225        }
226
227        // Handle unclosed blocks (add back as-is)
228        if in_literal_fence {
229            tracing::warn!("Unclosed literal fence found - treating as regular content");
230            result.push_str("```literal\n");
231            result.push_str(&current_block);
232        }
233        if in_raw_guard {
234            tracing::warn!("Unclosed RAW guard found - treating as regular content");
235            result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
236            result.push('\n');
237            result.push_str(&current_block);
238        }
239
240        // Remove trailing newline if original didn't have one
241        if !content.ends_with('\n') && result.ends_with('\n') {
242            result.pop();
243        }
244
245        tracing::debug!("Protected {} literal block(s)", placeholders.len());
246        (result, placeholders)
247    }
248
249    /// Restore literal blocks by replacing placeholders with original content.
250    ///
251    /// This method takes rendered content and restores any literal blocks that were
252    /// protected during the rendering process.
253    ///
254    /// # Arguments
255    ///
256    /// * `content` - The rendered content containing placeholders
257    /// * `placeholders` - HashMap mapping placeholder IDs to original content
258    ///
259    /// # Returns
260    ///
261    /// Returns the content with placeholders replaced by original literal blocks,
262    /// wrapped in markdown code fences for proper display.
263    pub(crate) fn restore_literal_blocks(
264        &self,
265        content: &str,
266        placeholders: HashMap<String, String>,
267    ) -> String {
268        let mut result = content.to_string();
269
270        for (placeholder_id, original_content) in placeholders {
271            if original_content.starts_with(NON_TEMPLATED_LITERAL_GUARD_START) {
272                result = result.replace(&placeholder_id, &original_content);
273            } else {
274                // Wrap in markdown code fence for display
275                let replacement = format!("```\n{}\n```", original_content);
276                result = result.replace(&placeholder_id, &replacement);
277            }
278
279            tracing::debug!(
280                "Restored literal block {} ({} bytes)",
281                placeholder_id,
282                original_content.len()
283            );
284        }
285
286        result
287    }
288
289    /// Collapse literal fences that were injected to protect non-templated dependency content.
290    ///
291    /// Any block that starts with ```literal, contains the sentinel marker on its first line,
292    /// and ends with ``` will be replaced by the inner content without the sentinel or fences.
293    fn collapse_non_templated_literal_guards(content: String) -> String {
294        let mut result = String::with_capacity(content.len());
295        let mut in_guard = false;
296
297        for chunk in content.split_inclusive('\n') {
298            let trimmed = chunk.trim_end_matches(['\r', '\n']);
299
300            if !in_guard {
301                if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
302                    in_guard = true;
303                } else {
304                    result.push_str(chunk);
305                }
306            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
307                in_guard = false;
308            } else {
309                result.push_str(chunk);
310            }
311        }
312
313        // If guard never closed, re-append the start marker and captured content to avoid dropping data.
314        if in_guard {
315            result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
316        }
317
318        result
319    }
320
321    /// Render a Markdown template with the given context.
322    ///
323    /// This method supports recursive template rendering where project files
324    /// can reference other project files using the `content` filter.
325    /// Rendering continues up to [`filters::MAX_RENDER_DEPTH`] levels deep.
326    ///
327    /// # Arguments
328    ///
329    /// * `template_content` - The raw Markdown template content
330    /// * `context` - The template context containing variables
331    ///
332    /// # Returns
333    ///
334    /// Returns the rendered Markdown content.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if:
339    /// - Template syntax is invalid
340    /// - Context variables are missing
341    /// - Custom functions/filters fail
342    /// - Recursive rendering exceeds maximum depth (10 levels)
343    ///
344    /// # Literal Blocks
345    ///
346    /// Content wrapped in ```literal fences will be protected from
347    /// template rendering and displayed literally:
348    ///
349    /// ````markdown
350    /// ```literal
351    /// {{ agpm.deps.snippets.example.content }}
352    /// ```
353    /// ````
354    ///
355    /// This is useful for documentation that shows template syntax examples.
356    ///
357    /// # Recursive Rendering
358    ///
359    /// When a template contains `content` filter references, those files
360    /// may themselves contain template syntax. The renderer automatically
361    /// detects this and performs multiple rendering passes until either:
362    /// - No template syntax remains in the output
363    /// - Maximum depth is reached (error)
364    ///
365    /// Example recursive template chain:
366    /// ```markdown
367    /// # Main Agent
368    /// {{ 'docs/guide.md' | content }}
369    /// ```
370    ///
371    /// Where `docs/guide.md` contains:
372    /// ```markdown
373    /// # Guide
374    /// {{ 'docs/common.md' | content }}
375    /// ```
376    ///
377    /// This will render up to 10 levels deep.
378    pub fn render_template(
379        &mut self,
380        template_content: &str,
381        context: &TeraContext,
382        metadata: Option<&RenderingMetadata>,
383    ) -> Result<String, TemplateError> {
384        tracing::debug!("render_template called, enabled={}", self.enabled);
385
386        if !self.enabled {
387            // If templating is disabled, return content as-is
388            tracing::debug!("Templating disabled, returning content as-is");
389            return Ok(template_content.to_string());
390        }
391
392        // Step 1: Protect literal blocks before any rendering
393        let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
394
395        // Check if content contains template syntax (after protecting literals)
396        if !self.contains_template_syntax(&protected_content) {
397            // No template syntax found, restore literals and return
398            tracing::debug!(
399                "No template syntax found after protecting literals, returning content"
400            );
401            return Ok(self.restore_literal_blocks(&protected_content, placeholders));
402        }
403
404        // Log the template context for debugging
405        tracing::debug!("Rendering template with context");
406        Self::log_context_as_kv(context);
407
408        // Step 2: Multi-pass rendering for recursive templates
409        // This allows project files to reference other project files
410        let mut current_content = protected_content;
411        let mut depth = 0;
412        let max_depth = filters::MAX_RENDER_DEPTH;
413
414        let rendered = loop {
415            depth += 1;
416
417            // Check depth limit
418            if depth > max_depth {
419                return Err(TemplateError::SyntaxError {
420                    message: format!(
421                        "Template rendering exceeded maximum recursion depth of {}. \
422                         This usually indicates circular dependencies between project files. \
423                         Please check your content filter references for cycles.",
424                        max_depth
425                    ),
426                    location: Box::new(Self::build_error_location(metadata, None)),
427                });
428            }
429
430            tracing::debug!("Rendering pass {} of max {}", depth, max_depth);
431
432            // Render the current content
433            let rendered = self.tera.render_str(&current_content, context).map_err(|e| {
434                // Parse into structured error
435                Self::parse_tera_error(&e, context, metadata)
436            })?;
437
438            // Check if the rendered output still contains template syntax OUTSIDE code fences
439            // This prevents re-rendering template syntax that was embedded as code examples
440            if !self.contains_template_syntax_outside_fences(&rendered) {
441                // No more template syntax outside fences - we're done with rendering
442                tracing::debug!("Template rendering complete after {} pass(es)", depth);
443                break rendered;
444            }
445
446            // More template syntax found outside fences - prepare for next iteration
447            tracing::debug!("Template syntax detected in output, continuing to pass {}", depth + 1);
448            current_content = rendered;
449        };
450
451        // Step 3: Restore literal blocks after all rendering is complete
452        let restored = self.restore_literal_blocks(&rendered, placeholders);
453
454        // Step 4: Collapse any literal guards that were added for non-templated dependencies
455        Ok(Self::collapse_non_templated_literal_guards(restored))
456    }
457
458    /// Parse a Tera error into a structured TemplateError
459    fn parse_tera_error(
460        error: &tera::Error,
461        context: &TeraContext,
462        metadata: Option<&RenderingMetadata>,
463    ) -> TemplateError {
464        // Extract error type (error_msg is no longer needed as we use format_tera_error)
465
466        // Try to extract more specific error information based on the error kind
467        match &error.kind {
468            tera::ErrorKind::Msg(msg) => {
469                // Check if this is an undefined variable error in disguise
470                if msg.contains("Variable") && msg.contains("not found") {
471                    // Try to extract variable name
472                    if let Some(name) = Self::extract_variable_name(msg) {
473                        let available_variables = Self::extract_available_variables(context);
474                        let suggestions = Self::find_similar_variables(&name, &available_variables);
475                        return TemplateError::VariableNotFound {
476                            variable: name.clone(),
477                            available_variables: Box::new(available_variables),
478                            suggestions: Box::new(suggestions),
479                            location: Box::new(Self::build_error_location(metadata, None)),
480                        };
481                    }
482                }
483
484                // For other message types, use the format_tera_error function to clean them up
485                TemplateError::SyntaxError {
486                    message: Self::format_tera_error(error),
487                    location: Box::new(Self::build_error_location(metadata, None)),
488                }
489            }
490            _ => {
491                // Fallback to syntax error with detailed error formatting
492                TemplateError::SyntaxError {
493                    message: Self::format_tera_error(error),
494                    location: Box::new(Self::build_error_location(metadata, None)),
495                }
496            }
497        }
498    }
499
500    /// Extract variable name from "Variable `foo` not found" message
501    fn extract_variable_name(error_msg: &str) -> Option<String> {
502        // Pattern: "Variable `<name>` not found"
503        let re = Regex::new(r"Variable `([^`]+)` not found").ok()?;
504        if let Some(caps) = re.captures(error_msg) {
505            if let Some(m) = caps.get(1) {
506                return Some(m.as_str().to_string());
507            }
508        }
509
510        // Try other patterns if needed
511        // Pattern: "Unknown variable `foo`"
512        let re2 = Regex::new(r"Unknown variable `([^`]+)`").ok()?;
513        if let Some(caps) = re2.captures(error_msg) {
514            if let Some(m) = caps.get(1) {
515                return Some(m.as_str().to_string());
516            }
517        }
518
519        None
520    }
521
522    /// Extract available variables from Tera context
523    fn extract_available_variables(context: &TeraContext) -> Vec<String> {
524        // Tera context doesn't implement Serialize directly
525        // We need to access its internal data structure
526        let mut vars = Vec::new();
527
528        // Get the context as a Value by using Tera's internal data access
529        // TeraContext stores data as a tera::Value internally
530        if let Some(_data) = context.get("agpm") {
531            // This is a simplified version - we should walk the actual structure
532            vars.push("agpm.resource.name".to_string());
533            vars.push("agpm.resource.path".to_string());
534            vars.push("agpm.resource.install_path".to_string());
535        }
536
537        // Add common project variables if they exist
538        if context.contains_key("project") {
539            vars.push("project.language".to_string());
540            vars.push("project.framework".to_string());
541        }
542
543        // Add dependency variables (simplified check)
544        if context.contains_key("deps") {
545            vars.push("agpm.deps.*".to_string());
546        }
547
548        vars
549    }
550
551    /// Find similar variable names using Levenshtein distance
552    fn find_similar_variables(target: &str, available: &[String]) -> Vec<String> {
553        let mut scored: Vec<_> = available
554            .iter()
555            .map(|var| {
556                let distance = levenshtein(target, var);
557                (var.clone(), distance)
558            })
559            .collect();
560
561        // Sort by distance (closest first)
562        scored.sort_by_key(|(_, dist)| *dist);
563
564        // Return top 3 suggestions within reasonable distance
565        scored
566            .into_iter()
567            .filter(|(_, dist)| *dist <= target.len() / 2) // 50% similarity threshold
568            .take(3)
569            .map(|(var, _)| var)
570            .collect()
571    }
572
573    /// Build ErrorLocation from metadata
574    fn build_error_location(
575        metadata: Option<&RenderingMetadata>,
576        line_number: Option<usize>,
577    ) -> ErrorLocation {
578        let meta = metadata.cloned().unwrap_or_else(|| RenderingMetadata {
579            resource_name: "unknown".to_string(),
580            resource_type: ResourceType::Snippet, // Default to snippet
581            dependency_chain: vec![],
582            source_path: None,
583            depth: 0,
584        });
585
586        ErrorLocation {
587            resource_name: meta.resource_name,
588            resource_type: meta.resource_type,
589            dependency_chain: meta.dependency_chain,
590            file_path: meta.source_path,
591            line_number,
592        }
593    }
594
595    /// Format a Tera error with detailed information about what went wrong.
596    ///
597    /// Tera errors can contain various types of issues:
598    /// - Missing variables (e.g., "Variable `foo` not found")
599    /// - Syntax errors (e.g., "Unexpected end of template")
600    /// - Filter/function errors (e.g., "Filter `unknown` not found")
601    ///
602    /// This function extracts the root cause and formats it in a user-friendly way,
603    /// filtering out unhelpful internal template names like '__tera_one_off'.
604    ///
605    /// # Arguments
606    ///
607    /// * `error` - The Tera error to format
608    pub fn format_tera_error(error: &tera::Error) -> String {
609        use std::error::Error;
610
611        let mut messages = Vec::new();
612
613        // Walk the entire error chain and collect all messages
614        let mut all_messages = vec![error.to_string()];
615        let mut current_error: Option<&dyn Error> = error.source();
616        while let Some(err) = current_error {
617            all_messages.push(err.to_string());
618            current_error = err.source();
619        }
620
621        // Process messages to extract useful information
622        for msg in all_messages {
623            // Clean up the message by removing internal template names
624            let cleaned = msg
625                .replace("while rendering '__tera_one_off'", "")
626                .replace("Failed to render '__tera_one_off'", "Template rendering failed")
627                .replace("Failed to parse '__tera_one_off'", "Template syntax error")
628                .replace("'__tera_one_off'", "template")
629                .trim()
630                .to_string();
631
632            // Only keep non-empty, useful messages
633            if !cleaned.is_empty()
634                && cleaned != "Template rendering failed"
635                && cleaned != "Template syntax error"
636            {
637                messages.push(cleaned);
638            }
639        }
640
641        // If we got useful messages, return them
642        if !messages.is_empty() {
643            messages.join("\n  → ")
644        } else {
645            // Fallback: extract just the error kind
646            "Template syntax error (see details above)".to_string()
647        }
648    }
649
650    /// Format the template context as a string for error messages.
651    ///
652    /// # Arguments
653    ///
654    /// * `context` - The Tera context to format
655    fn format_context_as_string(context: &TeraContext) -> String {
656        let context_clone = context.clone();
657        let json_value = context_clone.into_json();
658        let mut output = String::new();
659
660        // Recursively format the JSON structure with indentation
661        fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
662            let prefix = "  ".repeat(indent);
663            let mut lines = Vec::new();
664
665            match value {
666                serde_json::Value::Object(map) => {
667                    lines.push(format!("{}{}:", prefix, key));
668                    for (k, v) in map {
669                        lines.extend(format_value(k, v, indent + 1));
670                    }
671                }
672                serde_json::Value::Array(arr) => {
673                    lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
674                    // Only show first few items to avoid spam
675                    for (i, item) in arr.iter().take(3).enumerate() {
676                        lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
677                    }
678                    if arr.len() > 3 {
679                        lines.push(format!("{}  ... {} more items", prefix, arr.len() - 3));
680                    }
681                }
682                serde_json::Value::String(s) => {
683                    // Truncate long strings
684                    if s.len() > 100 {
685                        lines.push(format!(
686                            "{}{}: \"{}...\" ({} chars)",
687                            prefix,
688                            key,
689                            &s[..97],
690                            s.len()
691                        ));
692                    } else {
693                        lines.push(format!("{}{}: \"{}\"", prefix, key, s));
694                    }
695                }
696                serde_json::Value::Number(n) => {
697                    lines.push(format!("{}{}: {}", prefix, key, n));
698                }
699                serde_json::Value::Bool(b) => {
700                    lines.push(format!("{}{}: {}", prefix, key, b));
701                }
702                serde_json::Value::Null => {
703                    lines.push(format!("{}{}: null", prefix, key));
704                }
705            }
706            lines
707        }
708
709        if let serde_json::Value::Object(map) = &json_value {
710            for (key, value) in map {
711                output.push_str(&format_value(key, value, 1).join("\n"));
712                output.push('\n');
713            }
714        }
715
716        output
717    }
718
719    /// Log the template context as key-value pairs at debug level.
720    ///
721    /// # Arguments
722    ///
723    /// * `context` - The Tera context to log
724    fn log_context_as_kv(context: &TeraContext) {
725        let formatted = Self::format_context_as_string(context);
726        for line in formatted.lines() {
727            tracing::debug!("{}", line);
728        }
729    }
730
731    /// Check if content contains Tera template syntax.
732    ///
733    /// # Arguments
734    ///
735    /// * `content` - The content to check
736    ///
737    /// # Returns
738    ///
739    /// Returns `true` if the content contains template delimiters.
740    pub(crate) fn contains_template_syntax(&self, content: &str) -> bool {
741        let has_vars = content.contains("{{");
742        let has_tags = content.contains("{%");
743        let has_comments = content.contains("{#");
744        let result = has_vars || has_tags || has_comments;
745        tracing::debug!(
746            "Template syntax check: vars={}, tags={}, comments={}, result={}",
747            has_vars,
748            has_tags,
749            has_comments,
750            result
751        );
752        result
753    }
754
755    /// Check if content contains template syntax outside of code fences.
756    ///
757    /// This is used after rendering to determine if another pass is needed.
758    /// It ignores template syntax inside code fences to prevent re-rendering
759    /// content that has already been processed (like embedded dependency content).
760    pub(crate) fn contains_template_syntax_outside_fences(&self, content: &str) -> bool {
761        let mut in_code_fence = false;
762        let mut in_guard = 0usize;
763
764        for line in content.lines() {
765            let trimmed = line.trim();
766
767            if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
768                in_guard = in_guard.saturating_add(1);
769                continue;
770            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
771                in_guard = in_guard.saturating_sub(1);
772                continue;
773            }
774
775            if in_guard > 0 {
776                continue;
777            }
778
779            // Track code fence boundaries
780            if trimmed.starts_with("```") {
781                in_code_fence = !in_code_fence;
782                continue;
783            }
784
785            // Skip lines inside code fences
786            if in_code_fence {
787                continue;
788            }
789
790            // Check for template syntax in non-fenced content
791            if line.contains("{{") || line.contains("{%") || line.contains("{#") {
792                tracing::debug!(
793                    "Template syntax found outside code fences: {:?}",
794                    &line[..line.len().min(80)]
795                );
796                return true;
797            }
798        }
799
800        tracing::debug!("No template syntax found outside code fences");
801        false
802    }
803}