Skip to main content

agentbin_core/
render.rs

1use comrak::{markdown_to_html, Options};
2use syntect::highlighting::ThemeSet;
3use syntect::html::highlighted_html_for_string;
4use syntect::parsing::SyntaxSet;
5
6use crate::error::CoreError;
7
8/// Convert Markdown to HTML using comrak with GFM extensions enabled.
9pub fn render_markdown(input: &str) -> Result<String, CoreError> {
10    let mut options = Options::default();
11    options.extension.strikethrough = true;
12    options.extension.table = true;
13    options.extension.autolink = true;
14    options.extension.tasklist = true;
15    options.extension.tagfilter = true;
16
17    Ok(markdown_to_html(input, &options))
18}
19
20/// Syntax highlight code using syntect, returning an HTML string.
21///
22/// Falls back to plain text if the extension is not recognized.
23/// The returned HTML is wrapped in `<pre><code>` if not already wrapped.
24pub fn highlight_code(code: &str, extension: &str) -> Result<String, CoreError> {
25    let ss = SyntaxSet::load_defaults_newlines();
26    let ts = ThemeSet::load_defaults();
27
28    let syntax = ss
29        .find_syntax_by_extension(extension)
30        .unwrap_or_else(|| ss.find_syntax_plain_text());
31
32    let theme = ts
33        .themes
34        .get("InspiredGitHub")
35        .or_else(|| ts.themes.get("base16-ocean.dark"))
36        .ok_or_else(|| CoreError::RenderError("No suitable theme found".to_string()))?;
37
38    let html = highlighted_html_for_string(code, &ss, syntax, theme)
39        .map_err(|e| CoreError::RenderError(e.to_string()))?;
40
41    if html.trim_start().starts_with("<pre") {
42        Ok(html)
43    } else {
44        Ok(format!("<pre><code>{html}</code></pre>"))
45    }
46}
47
48/// Wrap plain text in HTML-safe `<pre><code>` tags.
49///
50/// HTML special characters are escaped before wrapping.
51pub fn wrap_plain_text(text: &str) -> String {
52    let escaped = html_escape(text);
53    format!("<pre><code>{escaped}</code></pre>")
54}
55
56fn html_escape(text: &str) -> String {
57    let mut result = String::with_capacity(text.len());
58    for ch in text.chars() {
59        match ch {
60            '&' => result.push_str("&amp;"),
61            '<' => result.push_str("&lt;"),
62            '>' => result.push_str("&gt;"),
63            '"' => result.push_str("&quot;"),
64            '\'' => result.push_str("&#x27;"),
65            c => result.push(c),
66        }
67    }
68    result
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn test_render_markdown() {
77        let result = render_markdown("# Hello").unwrap();
78        assert!(result.contains("<h1>Hello</h1>"));
79    }
80
81    #[test]
82    fn test_render_markdown_gfm_table() {
83        let input = "| Col1 | Col2 |\n|------|------|\n| A    | B    |";
84        let result = render_markdown(input).unwrap();
85        assert!(result.contains("<table>"));
86        assert!(result.contains("<th>"));
87    }
88
89    #[test]
90    fn test_highlight_code() {
91        let code = r#"{"key": "value"}"#;
92        let result = highlight_code(code, "json").unwrap();
93        // syntect returns highlighted HTML with spans or pre tags
94        assert!(result.contains("<pre") || result.contains("<span"));
95    }
96
97    #[test]
98    fn test_wrap_plain_text() {
99        let result = wrap_plain_text("Hello <world> & \"things\"");
100        assert!(result.starts_with("<pre><code>"));
101        assert!(result.ends_with("</code></pre>"));
102        assert!(result.contains("&lt;world&gt;"));
103        assert!(result.contains("&amp;"));
104        assert!(result.contains("&quot;"));
105    }
106
107    #[test]
108    fn test_html_escape() {
109        let result = html_escape("a & b < c > d \"e\" 'f'");
110        assert!(result.contains("&amp;"));
111        assert!(result.contains("&lt;"));
112        assert!(result.contains("&gt;"));
113        assert!(result.contains("&quot;"));
114        assert!(result.contains("&#x27;"));
115        assert!(!result.contains('&') || result.contains("&amp;"));
116    }
117}