dinja_core/
mdx.rs

1//! MDX processing and rendering logic
2//!
3//! This module handles the core MDX processing pipeline:
4//! 1. YAML frontmatter extraction
5//! 2. Markdown to HTML conversion (with JSX support)
6//! 3. TSX transformation to JavaScript
7//! 4. Component rendering via JavaScript runtime
8//!
9//! ## Pipeline Types
10//!
11//! The module supports a single rendering pipeline:
12//!
13//! - **Engine Pipeline**: For HTML, JavaScript, and Schema output formats, uses engine for rendering
14//!
15//! ## Error Handling
16//!
17//! All domain-specific errors use `MdxError`. Errors are converted to `anyhow::Error` at the
18//! service boundary for consistent error handling in HTTP handlers.
19
20use crate::error::MdxError;
21use crate::models::{
22    ComponentDefinition, OutputFormat, RenderSettings, RenderedMdx, TsxTransformConfig,
23};
24use crate::renderer::JsRenderer;
25use crate::transform::{transform_tsx_to_js_for_output, transform_tsx_to_js_with_config};
26use gray_matter::{engine::YAML, Matter};
27use markdown::{to_html_with_options, CompileOptions, Constructs, Options, ParseOptions};
28use serde_json::json;
29use std::collections::HashMap;
30
31struct RenderContext<'a> {
32    renderer: &'a JsRenderer,
33    components: Option<&'a HashMap<String, ComponentDefinition>>,
34    props_json: &'a str,
35    settings: &'a RenderSettings,
36}
37
38fn markdown_options() -> Options {
39    Options {
40        parse: ParseOptions {
41            constructs: Constructs {
42                html_flow: true, // Allow block-level HTML/JSX
43                html_text: true, // Allow inline HTML/JSX
44                ..Constructs::default()
45            },
46            ..ParseOptions::default()
47        },
48        compile: CompileOptions {
49            allow_dangerous_html: true, // Don't escape HTML tags
50            ..CompileOptions::default()
51        },
52    }
53}
54
55/// Unwraps the first Fragment wrapper from HTML output if present.
56///
57/// If the HTML starts with `<Fragment>` and ends with `</Fragment>`, this function
58/// extracts only the children content, removing the Fragment wrapper.
59///
60/// This handles the case where MDX content is wrapped in a Fragment by default,
61/// and we only want to return the actual content without the wrapper.
62///
63/// # Arguments
64/// * `html` - HTML string that may be wrapped in a Fragment
65///
66/// # Returns
67/// HTML string with Fragment wrapper removed if present, otherwise unchanged
68fn unwrap_fragment(html: &str) -> String {
69    let trimmed = html.trim();
70
71    // Check if the HTML starts with <Fragment (case-insensitive, allowing attributes)
72    let fragment_start_patterns = ["<Fragment", "<fragment"];
73    let mut fragment_start: Option<usize> = None;
74
75    for pattern in &fragment_start_patterns {
76        if let Some(pos) = trimmed.find(pattern) {
77            fragment_start = Some(pos);
78            break;
79        }
80    }
81
82    if let Some(start) = fragment_start {
83        // Find the closing > of the opening tag (handle self-closing or with attributes)
84        if let Some(tag_end) = trimmed[start..].find('>') {
85            let tag_end = start + tag_end + 1;
86            let content_start = tag_end;
87
88            // Find the closing </Fragment> tag (case-insensitive)
89            let remaining = &trimmed[content_start..];
90            let fragment_end_patterns = ["</Fragment>", "</fragment>"];
91            let mut fragment_end: Option<usize> = None;
92
93            for pattern in &fragment_end_patterns {
94                if let Some(pos) = remaining.rfind(pattern) {
95                    fragment_end = Some(pos);
96                    break;
97                }
98            }
99
100            if let Some(end_pos) = fragment_end {
101                // Extract just the content between the tags
102                return remaining[..end_pos].trim().to_string();
103            }
104        }
105    }
106
107    // No Fragment wrapper found, return as-is
108    html.to_string()
109}
110
111fn render_markdown(content: &str) -> Result<String, MdxError> {
112    let options = markdown_options();
113    to_html_with_options(content, &options).map_err(|e| MdxError::MarkdownRender(e.to_string()))
114}
115
116/// Helper function to log render errors with context
117/// Preserves the full error chain for better debugging
118fn log_render_error(e: &anyhow::Error, js_output: &str, context: &str) {
119    eprintln!("{context} render error details: {:#}", e);
120    eprintln!("JavaScript output: {js_output}");
121}
122
123/// Converts MDX content to HTML and JavaScript with frontmatter extraction
124///
125/// # Arguments
126/// * `mdx_content` - Raw MDX content with optional YAML frontmatter
127/// * `renderer` - JavaScript renderer instance for component rendering
128/// * `components` - Optional map of component definitions to inject
129/// * `settings` - Rendering settings including output format
130///
131/// # Returns
132/// A `RenderedMdx` struct containing rendered output and metadata
133pub fn mdx_to_html_with_frontmatter(
134    mdx_content: &str,
135    renderer: &JsRenderer,
136    components: Option<&HashMap<String, ComponentDefinition>>,
137    settings: &RenderSettings,
138) -> Result<RenderedMdx, MdxError> {
139    // Parse YAML frontmatter
140    let matter = Matter::<YAML>::new();
141    let parsed = matter
142        .parse::<serde_json::Value>(mdx_content)
143        .map_err(|e| MdxError::FrontmatterParse(e.to_string()))?;
144
145    let frontmatter = parsed
146        .data
147        .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::with_capacity(0)));
148
149    // Render markdown to HTML with HTML/JSX components enabled
150    let html_output = render_markdown(&parsed.content)?;
151
152    // Convert frontmatter to JSON string for props
153    let props_json = serde_json::to_string(&frontmatter)
154        .map_err(|e| MdxError::FrontmatterParse(format!("Failed to serialize frontmatter: {e}")))?;
155
156    let context = RenderContext {
157        renderer,
158        components,
159        props_json: &props_json,
160        settings,
161    };
162
163    let output = render_with_engine_pipeline(&context, &html_output)?;
164
165    Ok(RenderedMdx {
166        metadata: frontmatter,
167        output: Some(output),
168    })
169}
170
171/// Creates a fallback error response for failed MDX rendering
172///
173/// Preserves the full error chain for better debugging and error tracking.
174/// The error chain includes all underlying causes, making it easier to diagnose
175/// root causes of rendering failures.
176///
177/// # Arguments
178/// * `error` - The error that occurred during rendering
179///
180/// # Returns
181/// A `RenderedMdx` struct with error information including full error chain
182pub fn create_error_response(error: &anyhow::Error) -> RenderedMdx {
183    // Format full error chain with all context
184    let error_chain = format!("{:#}", error);
185    let error_message = error.to_string();
186
187    // Log full error chain for debugging
188    eprintln!("MDX rendering error: {error_chain}");
189
190    let error_html = format!("<p>Error rendering MDX: {error_message}</p>");
191    RenderedMdx {
192        metadata: json!({
193            "error": error_message,
194            "error_chain": error_chain
195        }),
196        output: Some(error_html),
197    }
198}
199
200fn render_with_engine_pipeline(
201    context: &RenderContext<'_>,
202    html_output: &str,
203) -> Result<String, MdxError> {
204    // HOT PATH: TSX transformation - called for every MDX file with Html/Javascript output
205    let mut transform_config = TsxTransformConfig::for_engine(false);
206
207    // For schema output, collect component names to convert function references to strings
208    if matches!(context.settings.output, OutputFormat::Schema) {
209        if let Some(components) = context.components {
210            let component_names: std::collections::HashSet<String> = components
211                .iter()
212                .map(|(key, comp_def)| {
213                    comp_def
214                        .name
215                        .as_ref()
216                        .cloned()
217                        .unwrap_or_else(|| key.clone())
218                })
219                .collect();
220            if !component_names.is_empty() {
221                transform_config.component_names = Some(component_names);
222            }
223        }
224    }
225
226    let javascript_output = transform_tsx_to_js_with_config(html_output, transform_config)
227        .map_err(|e| {
228            MdxError::TsxTransform(format!("Failed to transform TSX to JavaScript: {e}"))
229        })?;
230
231    // HOT PATH: Component rendering - executes JavaScript and renders to HTML
232    let template_output = render_template(context, &javascript_output)?;
233
234    match context.settings.output {
235        OutputFormat::Html => {
236            // Unwrap Fragment wrapper if present - only return children of first Fragment
237            Ok(unwrap_fragment(&template_output))
238        }
239        OutputFormat::Javascript => {
240            transform_tsx_to_js_for_output(&template_output, context.settings.minify).map_err(|e| {
241                MdxError::TsxTransform(format!("Failed to transform template to JavaScript: {e}"))
242            })
243        }
244        OutputFormat::Schema => {
245            // Render using core.js engine for schema output
246            render_template_to_schema(context, &javascript_output)
247        }
248    }
249}
250
251fn render_template(
252    context: &RenderContext<'_>,
253    javascript_output: &str,
254) -> Result<String, MdxError> {
255    context
256        .renderer
257        .render_transformed_component(
258            javascript_output,
259            Some(context.props_json),
260            context.components,
261        )
262        .map_err(|e| {
263            log_render_error(&e, javascript_output, "Component");
264            MdxError::TsxTransform(format!("Failed to render component template: {:#}", e))
265        })
266}
267
268fn render_template_to_schema(
269    context: &RenderContext<'_>,
270    javascript_output: &str,
271) -> Result<String, MdxError> {
272    context
273        .renderer
274        .render_transformed_component_to_schema(
275            javascript_output,
276            Some(context.props_json),
277            context.components,
278        )
279        .map_err(|e| {
280            log_render_error(&e, javascript_output, "Schema");
281            MdxError::TsxTransform(format!("Failed to render component to schema: {:#}", e))
282        })
283}