mdx/
renderer.rs

1use crate::component::ComponentRegistry;
2use crate::config::Config;
3use crate::error::Error;
4use crate::parser::{Alignment, InlineNode, Node, ParsedDocument};
5use crate::theme::Theme;
6#[cfg(feature = "minify")]
7use minify_html::{minify, Cfg};
8use std::collections::HashMap;
9use syntect::highlighting::ThemeSet;
10use syntect::html::{ClassStyle, ClassedHTMLGenerator};
11use syntect::parsing::SyntaxSet;
12
13/// Options for HTML rendering
14#[derive(Debug, Clone)]
15pub struct RenderOptions {
16    /// Title for the HTML document
17    pub title: String,
18    /// Whether to include default CSS
19    pub include_default_css: bool,
20    /// Whether to minify HTML output
21    pub minify: bool,
22    /// Whether to generate a table of contents
23    pub toc: bool,
24    /// Whether to apply syntax highlighting to code blocks
25    pub syntax_highlight: bool,
26    /// Whether to add copy buttons to code blocks
27    pub code_copy_button: bool,
28    /// Theme for syntax highlighting
29    pub highlight_theme: String,
30}
31
32impl Default for RenderOptions {
33    fn default() -> Self {
34        Self {
35            title: "Markrust Document".to_string(),
36            include_default_css: true,
37            minify: false,
38            toc: false,
39            syntax_highlight: true,
40            code_copy_button: true,
41            highlight_theme: "InspiredGitHub".to_string(),
42        }
43    }
44}
45
46impl From<&Config> for RenderOptions {
47    fn from(config: &Config) -> Self {
48        Self {
49            title: config.renderer.title.clone(),
50            include_default_css: config.renderer.include_default_css,
51            minify: config.renderer.minify,
52            toc: config.renderer.toc,
53            syntax_highlight: config.renderer.syntax_highlight,
54            code_copy_button: config.renderer.code_copy_button,
55            highlight_theme: config.renderer.highlight_theme.clone(),
56        }
57    }
58}
59
60/// Render an AST to HTML
61pub fn render(
62    doc: &ParsedDocument,
63    registry: &ComponentRegistry,
64    config: &Config,
65) -> Result<String, Error> {
66    let render_options = RenderOptions::from(config);
67    let theme = Theme::new(&config.theme.name);
68
69    // Generate table of contents if requested
70    let toc = if render_options.toc {
71        generate_toc(&doc.ast)
72    } else {
73        String::new()
74    };
75
76    // Render the document body
77    let content = render_nodes(&doc.ast, registry, &render_options)?;
78
79    // Combine CSS from theme and components
80    let mut css = String::new();
81    if render_options.include_default_css {
82        css.push_str(&theme.get_css());
83    }
84    css.push_str(&registry.get_all_css());
85
86    // Add syntax highlighting CSS if enabled
87    if render_options.syntax_highlight {
88        let highlight_css = get_syntax_highlight_css(&render_options.highlight_theme)?;
89        css.push_str(&highlight_css);
90    }
91
92    // Get title from frontmatter or use default
93    let title = if let Some(ref frontmatter) = doc.frontmatter {
94        frontmatter
95            .get("title")
96            .and_then(|v| v.as_str())
97            .unwrap_or(&render_options.title)
98    } else {
99        &render_options.title
100    };
101
102    // Generate the final HTML document
103    let html = format!(
104        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{title}</title>\n    <style>\n{css}\n    </style>\n</head>\n<body>\n    <div class=\"markrust-container\">\n        {toc}\n        <div class=\"markrust-content\">\n{content}\n        </div>\n    </div>\n    {scripts}\n</body>\n</html>",
105        title = title,
106        css = css,
107        toc = toc,
108        content = content,
109        scripts = get_scripts(render_options.code_copy_button)
110    );
111
112    // Process component directives in HTML
113    let processed_html = process_component_directives(&html, registry)?;
114
115    // Minify HTML if requested
116    let final_html = if render_options.minify {
117        minify_html(&processed_html)?
118    } else {
119        processed_html
120    };
121
122    Ok(final_html)
123}
124
125// Generate a table of contents from the AST
126fn generate_toc(nodes: &[Node]) -> String {
127    let mut toc = String::from("<div class=\"markrust-toc\">\n  <div class=\"markrust-toc-header\">Table of Contents</div>\n  <ul>\n");
128
129    for node in nodes {
130        if let Node::Heading { level, content, id } = node {
131            if *level <= 3 {
132                let indent = "    ".repeat(*level as usize);
133                toc.push_str(&format!(
134                    "{}<li><a href=\"#{id}\">{content}</a></li>\n",
135                    indent
136                ));
137            }
138        }
139    }
140
141    toc.push_str("  </ul>\n</div>");
142    toc
143}
144
145// Render AST nodes to HTML
146fn render_nodes(
147    nodes: &[Node],
148    registry: &ComponentRegistry,
149    options: &RenderOptions,
150) -> Result<String, Error> {
151    let mut html = String::new();
152
153    for node in nodes {
154        html.push_str(&render_node(node, registry, options)?);
155    }
156
157    Ok(html)
158}
159
160// Render a single AST node to HTML
161fn render_node(
162    node: &Node,
163    registry: &ComponentRegistry,
164    options: &RenderOptions,
165) -> Result<String, Error> {
166    match node {
167        Node::Heading { level, content, id } => Ok(format!(
168            "<h{level} id=\"{id}\">{content}</h{level}>\n",
169            level = level,
170            id = id,
171            content = content
172        )),
173        Node::Paragraph(inline_nodes) => {
174            let content = render_inline_nodes(inline_nodes)?;
175            Ok(format!("<p>{}</p>\n", content))
176        }
177        Node::BlockQuote(nodes) => {
178            let content = render_nodes(nodes, registry, options)?;
179            Ok(format!("<blockquote>\n{}</blockquote>\n", content))
180        }
181        Node::CodeBlock {
182            language,
183            content,
184            attributes,
185        } => render_code_block(language, content, attributes, options),
186        Node::List { ordered, items } => {
187            let tag = if *ordered { "ol" } else { "ul" };
188            let mut html = format!("<{tag}>\n");
189
190            for item in items {
191                let item_content = render_nodes(item, registry, options)?;
192                html.push_str(&format!("  <li>{}</li>\n", item_content));
193            }
194
195            html.push_str(&format!("</{tag}>\n"));
196            Ok(html)
197        }
198        Node::ThematicBreak => Ok("<hr>\n".to_string()),
199        Node::Component {
200            name,
201            attributes,
202            children,
203        } => {
204            if let Some(component) = registry.get(name) {
205                component.render(attributes, children)
206            } else {
207                Err(Error::ComponentError(format!(
208                    "Component not found: {}",
209                    name
210                )))
211            }
212        }
213        Node::Html(html) => Ok(format!("{}\n", html)),
214        Node::Table {
215            headers,
216            rows,
217            alignments,
218        } => table_to_html(headers, rows, alignments),
219    }
220}
221
222// Render inline nodes to HTML
223fn render_inline_nodes(nodes: &[InlineNode]) -> Result<String, Error> {
224    let mut html = String::new();
225
226    for node in nodes {
227        html.push_str(&render_inline_node(node)?);
228    }
229
230    Ok(html)
231}
232
233// Render a single inline node to HTML
234fn render_inline_node(node: &InlineNode) -> Result<String, Error> {
235    match node {
236        InlineNode::Text(text) => Ok(text.clone()),
237        InlineNode::Emphasis(nodes) => {
238            let content = render_inline_nodes(nodes)?;
239            Ok(format!("<em>{}</em>", content))
240        }
241        InlineNode::Strong(nodes) => {
242            let content = render_inline_nodes(nodes)?;
243            Ok(format!("<strong>{}</strong>", content))
244        }
245        InlineNode::Strikethrough(nodes) => {
246            let content = render_inline_nodes(nodes)?;
247            Ok(format!("<del>{}</del>", content))
248        }
249        InlineNode::Link { text, url, title } => {
250            let content = render_inline_nodes(text)?;
251            let title_attr = if let Some(title) = title {
252                format!(" title=\"{}\"", title)
253            } else {
254                String::new()
255            };
256            Ok(format!(
257                "<a href=\"{}\"{title_attr}>{}</a>",
258                url,
259                content,
260                title_attr = title_attr
261            ))
262        }
263        InlineNode::Image { alt, url, title } => {
264            let title_attr = if let Some(title) = title {
265                format!(" title=\"{}\"", title)
266            } else {
267                String::new()
268            };
269            Ok(format!(
270                "<img src=\"{}\" alt=\"{}\"{title_attr}>",
271                url,
272                alt,
273                title_attr = title_attr
274            ))
275        }
276        InlineNode::Code(code) => Ok(format!("<code>{}</code>", code)),
277        InlineNode::LineBreak => Ok("<br>".to_string()),
278        InlineNode::Html(html) => Ok(html.clone()),
279    }
280}
281
282// Render a code block with optional syntax highlighting
283fn render_code_block(
284    language: &Option<String>,
285    content: &str,
286    _attributes: &HashMap<String, String>,
287    options: &RenderOptions,
288) -> Result<String, Error> {
289    let lang_class = if let Some(lang) = language {
290        format!(" class=\"language-{}\"", lang)
291    } else {
292        String::new()
293    };
294
295    let code_content = if options.syntax_highlight && language.is_some() {
296        highlight_code(
297            content,
298            language.as_ref().unwrap(),
299            &options.highlight_theme,
300        )?
301    } else {
302        content.to_string()
303    };
304
305    let copy_button = if options.code_copy_button {
306        "<button class=\"markrust-copy-button\" data-clipboard-target=\"#code-block \">\n      <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n        <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect>\n        <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path>\n      </svg>\n    </button>"
307    } else {
308        ""
309    };
310
311    Ok(format!(
312        "<div class=\"markrust-code-block\">\n  <pre{lang_class}><code>{code_content}</code></pre>\n  {copy_button}\n</div>\n",
313        lang_class = lang_class,
314        code_content = code_content,
315        copy_button = copy_button
316    ))
317}
318
319// Render a table to HTML
320fn table_to_html(
321    headers: &[Vec<InlineNode>],
322    rows: &[Vec<Vec<InlineNode>>],
323    alignments: &[Alignment],
324) -> Result<String, Error> {
325    let mut html = String::from("<table class=\"markrust-table\">\n");
326
327    // Render table header
328    if !headers.is_empty() {
329        html.push_str("  <thead>\n    <tr>\n");
330
331        for (i, header) in headers.iter().enumerate() {
332            let align_class =
333                get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
334            let content = render_inline_nodes(header)?;
335            html.push_str(&format!(
336                "      <th{align_class}>{content}</th>\n",
337                align_class = align_class,
338                content = content
339            ));
340        }
341
342        html.push_str("    </tr>\n  </thead>\n");
343    }
344
345    // Render table body
346    if !rows.is_empty() {
347        html.push_str("  <tbody>\n");
348
349        for row in rows {
350            html.push_str("    <tr>\n");
351
352            for (i, cell) in row.iter().enumerate() {
353                let align_class =
354                    get_alignment_class(alignments.get(i).copied().unwrap_or(Alignment::None));
355                let content = render_inline_nodes(cell)?;
356                html.push_str(&format!(
357                    "      <td{align_class}>{content}</td>\n",
358                    align_class = align_class,
359                    content = content
360                ));
361            }
362
363            html.push_str("    </tr>\n");
364        }
365
366        html.push_str("  </tbody>\n");
367    }
368
369    html.push_str("</table>\n");
370    Ok(html)
371}
372
373// Get the alignment class for a table cell
374fn get_alignment_class(alignment: Alignment) -> String {
375    match alignment {
376        Alignment::None => String::new(),
377        Alignment::Left => " class=\"align-left\"".to_string(),
378        Alignment::Center => " class=\"align-center\"".to_string(),
379        Alignment::Right => " class=\"align-right\"".to_string(),
380    }
381}
382
383// Highlight code using syntect
384fn highlight_code(code: &str, language: &str, theme_name: &str) -> Result<String, Error> {
385    let syntax_set = SyntaxSet::load_defaults_newlines();
386    let theme_set = ThemeSet::load_defaults();
387
388    let syntax = syntax_set
389        .find_syntax_by_token(language)
390        .or_else(|| syntax_set.find_syntax_by_extension(language))
391        .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
392
393    let _theme = theme_set
394        .themes
395        .get(theme_name)
396        .ok_or_else(|| Error::RenderError(format!("Theme not found: {}", theme_name)))?;
397
398    let mut html_generator =
399        ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced);
400
401    for line in code.lines() {
402        // Use a newer method that doesn't cause errors
403        let newline = '\n';
404        let line_with_newline = format!("{}{}", line, newline);
405        html_generator
406            .parse_html_for_line_which_includes_newline(&line_with_newline)
407            .map_err(|e| Error::RenderError(format!("Failed to highlight code: {}", e)))?;
408    }
409
410    Ok(html_generator.finalize())
411}
412
413// Get syntax highlighting CSS
414fn get_syntax_highlight_css(_theme_name: &str) -> Result<String, Error> {
415    // In a real implementation, this would generate CSS based on the selected theme
416    Ok("
417/* Syntax highlighting styles */
418.hljs-keyword { color: #0000ff; font-weight: bold; }
419.hljs-string { color: #a31515; }
420.hljs-comment { color: #008000; }
421.hljs-function { color: #795e26; }
422.hljs-number { color: #098658; }
423    "
424    .to_string())
425}
426
427// Get scripts for the HTML document
428fn get_scripts(include_copy_button: bool) -> String {
429    if include_copy_button {
430        "<script>\n      // Code copy button functionality\n      document.addEventListener(\"DOMContentLoaded\", function() {\n        const copyButtons = document.querySelectorAll(\".markrust-copy-button\");\n        \n        copyButtons.forEach(button => {\n          button.addEventListener(\"click\", function() {\n            const codeBlock = this.previousElementSibling.querySelector(\"code\");\n            const textToCopy = codeBlock.innerText;\n            \n            navigator.clipboard.writeText(textToCopy).then(() => {\n              // Show copied feedback\n              const originalLabel = this.getAttribute(\"aria-label\");\n              this.setAttribute(\"aria-label\", \"Copied!\");\n              \n              setTimeout(() => {\n                this.setAttribute(\"aria-label\", originalLabel);\n              }, 2000);\n            });\n          });\n        });\n        \n        // Tab functionality\n        const tabButtons = document.querySelectorAll(\".markrust-tab-button\");\n        \n        tabButtons.forEach(button => {\n          button.addEventListener(\"click\", function() {\n            const tabs = this.closest(\".markrust-tabs\");\n            const tabId = this.getAttribute(\"data-tab\");\n            \n            // Deactivate all tabs\n            tabs.querySelectorAll(\".markrust-tab-button\").forEach(btn => btn.classList.remove(\"active\"));\n            tabs.querySelectorAll(\".markrust-tab-panel\").forEach(panel => panel.classList.remove(\"active\"));\n            \n            // Activate selected tab\n            this.classList.add(\"active\");\n            tabs.querySelector(\"#\" + tabId).classList.add(\"active\");\n          });\n        });\n      });\n    </script>".to_string()
431    } else {
432        String::new()
433    }
434}
435
436#[cfg(feature = "minify")]
437fn minify_html_impl(html: &str) -> Result<String, Error> {
438    let mut cfg = Cfg::new();
439    cfg.do_not_minify_doctype = true;
440    cfg.ensure_spec_compliant_unquoted_attribute_values = true;
441    cfg.keep_closing_tags = true;
442
443    let bytes = html.as_bytes();
444    let minified = minify(bytes, &cfg);
445
446    Ok(String::from_utf8_lossy(&minified).to_string())
447}
448
449#[cfg(not(feature = "minify"))]
450fn minify_html_impl(html: &str) -> Result<String, Error> {
451    Ok(html.to_string())
452}
453
454// Minify HTML
455fn minify_html(html: &str) -> Result<String, Error> {
456    minify_html_impl(html)
457}
458
459// Process component directives in HTML
460fn process_component_directives(html: &str, registry: &ComponentRegistry) -> Result<String, Error> {
461    use regex::Regex;
462    use std::collections::HashMap;
463
464    // Define regex patterns for component directives
465    let component_start_regex =
466        Regex::new(r"<!-- component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
467    let component_end_regex = Regex::new(r"<!-- component_end -->").unwrap();
468    let nested_start_regex =
469        Regex::new(r"<!-- nested_component_start:([a-zA-Z0-9_-]+):(.*?) -->").unwrap();
470    let nested_end_regex = Regex::new(r"<!-- nested_component_end -->").unwrap();
471
472    let mut result = html.to_string();
473
474    // First, process nested components
475    let mut start_positions = Vec::new();
476    let mut component_data = Vec::new();
477
478    for cap in nested_start_regex.captures_iter(html) {
479        let start_match = cap.get(0).unwrap();
480        let component_name = cap[1].to_string();
481        let attributes_str = cap[2].to_string();
482
483        // Parse attributes
484        let mut attributes = HashMap::new();
485        for attr_pair in attributes_str.split_whitespace() {
486            if let Some(equals_pos) = attr_pair.find('=') {
487                let key = attr_pair[..equals_pos].trim();
488                let mut value = attr_pair[equals_pos + 1..].trim();
489
490                // Remove quotes from value if present
491                if (value.starts_with('"') && value.ends_with('"'))
492                    || (value.starts_with('\'') && value.ends_with('\''))
493                {
494                    value = &value[1..value.len() - 1];
495                }
496
497                attributes.insert(key.to_string(), value.to_string());
498            }
499        }
500
501        start_positions.push(start_match.start());
502        component_data.push((component_name, attributes, start_match.end()));
503    }
504
505    // Find matching end tags and process components
506    let mut replacements = Vec::new();
507
508    for (i, &start_pos) in start_positions.iter().enumerate() {
509        let (component_name, attributes, content_start) = &component_data[i];
510
511        if let Some(end_match) = nested_end_regex.find_at(&result, *content_start) {
512            let _content = &result[*content_start..end_match.start()];
513
514            // If the component exists in the registry, render it
515            if let Some(component) = registry.get(component_name) {
516                if let Ok(rendered) = component.render(attributes, &Vec::new()) {
517                    replacements.push((start_pos, end_match.end(), rendered));
518                }
519            }
520        }
521    }
522
523    // Apply replacements in reverse order to preserve positions
524    replacements.sort_by(|a, b| b.0.cmp(&a.0));
525    for (start, end, replacement) in replacements {
526        result.replace_range(start..end, &replacement);
527    }
528
529    // Then, process top-level components
530    let mut start_positions = Vec::new();
531    let mut component_data = Vec::new();
532
533    for cap in component_start_regex.captures_iter(&result) {
534        let start_match = cap.get(0).unwrap();
535        let component_name = cap[1].to_string();
536        let attributes_str = cap[2].to_string();
537
538        // Parse attributes
539        let mut attributes = HashMap::new();
540        for attr_pair in attributes_str.split_whitespace() {
541            if let Some(equals_pos) = attr_pair.find('=') {
542                let key = attr_pair[..equals_pos].trim();
543                let mut value = attr_pair[equals_pos + 1..].trim();
544
545                // Remove quotes from value if present
546                if (value.starts_with('"') && value.ends_with('"'))
547                    || (value.starts_with('\'') && value.ends_with('\''))
548                {
549                    value = &value[1..value.len() - 1];
550                }
551
552                attributes.insert(key.to_string(), value.to_string());
553            }
554        }
555
556        start_positions.push(start_match.start());
557        component_data.push((component_name, attributes, start_match.end()));
558    }
559
560    // Find matching end tags and process components
561    let mut replacements = Vec::new();
562
563    for (i, &start_pos) in start_positions.iter().enumerate() {
564        let (component_name, attributes, content_start) = &component_data[i];
565
566        if let Some(end_match) = component_end_regex.find_at(&result, *content_start) {
567            let _content = &result[*content_start..end_match.start()];
568
569            // If the component exists in the registry, render it
570            if let Some(component) = registry.get(component_name) {
571                if let Ok(rendered) = component.render(attributes, &Vec::new()) {
572                    replacements.push((start_pos, end_match.end(), rendered));
573                }
574            }
575        }
576    }
577
578    // Apply replacements in reverse order to preserve positions
579    replacements.sort_by(|a, b| b.0.cmp(&a.0));
580    for (start, end, replacement) in replacements {
581        result.replace_range(start..end, &replacement);
582    }
583
584    Ok(result)
585}