Skip to main content

mq_markdown/
markdown.rs

1#[cfg(feature = "html-to-markdown")]
2use crate::html_to_markdown;
3#[cfg(feature = "html-to-markdown")]
4use crate::html_to_markdown::ConversionOptions;
5use crate::node::{ColorTheme, Node, Position, RenderOptions, TableAlign, TableCell, render_values};
6use markdown::{CompileOptions, Constructs, Options, ParseOptions};
7use miette::miette;
8use std::{fmt, str::FromStr};
9
10#[derive(Debug, Clone)]
11pub struct Markdown {
12    pub nodes: Vec<Node>,
13    pub options: RenderOptions,
14}
15
16impl FromStr for Markdown {
17    type Err = miette::Error;
18
19    fn from_str(content: &str) -> Result<Self, Self::Err> {
20        Self::from_markdown_str(content)
21    }
22}
23
24impl fmt::Display for Markdown {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}", self.render_with_theme(&ColorTheme::PLAIN))
27    }
28}
29
30impl Markdown {
31    pub fn new(nodes: Vec<Node>) -> Self {
32        Self {
33            nodes,
34            options: RenderOptions::default(),
35        }
36    }
37
38    pub fn set_options(&mut self, options: RenderOptions) {
39        self.options = options;
40    }
41
42    /// Returns a colored string representation of the markdown using ANSI escape codes.
43    #[cfg(feature = "color")]
44    pub fn to_colored_string(&self) -> String {
45        self.render_with_theme(&ColorTheme::COLORED)
46    }
47
48    /// Returns a colored string representation using the given color theme.
49    #[cfg(feature = "color")]
50    pub fn to_colored_string_with_theme(&self, theme: &ColorTheme<'_>) -> String {
51        self.render_with_theme(theme)
52    }
53
54    fn render_with_theme(&self, theme: &ColorTheme<'_>) -> String {
55        let mut pre_position: Option<Position> = None;
56        let mut is_first = true;
57        let mut current_table_row: Option<usize> = None;
58        let mut in_table = false;
59
60        let mut buffer = String::with_capacity(self.nodes.len() * 50);
61
62        for (i, node) in self.nodes.iter().enumerate() {
63            if let Node::TableCell(TableCell { row, values, .. }) = node {
64                let value = render_values(values, &self.options, theme);
65
66                let is_new_row = current_table_row != Some(*row);
67
68                if is_new_row {
69                    if current_table_row.is_some() {
70                        buffer.push_str("|\n");
71                    } else if !in_table && let Some(pos) = node.position() {
72                        // Insert newlines before the first row of a table
73                        let new_line_count = pre_position
74                            .as_ref()
75                            .map(|p| pos.start.line.saturating_sub(p.end.line))
76                            .unwrap_or_else(|| if is_first { 0 } else { 1 })
77                            .min(2);
78                        for _ in 0..new_line_count {
79                            buffer.push('\n');
80                        }
81                    }
82                    current_table_row = Some(*row);
83                }
84
85                buffer.push('|');
86                buffer.push_str(&value);
87
88                let next_node = self.nodes.get(i + 1);
89                let next_is_different_row = next_node.is_none_or(
90                    |next| !matches!(next, Node::TableCell(TableCell { row: next_row, .. }) if *next_row == *row),
91                );
92
93                if next_is_different_row {
94                    buffer.push_str("|\n");
95                    current_table_row = None;
96                }
97
98                pre_position = node.position();
99                is_first = false;
100                in_table = true;
101                continue;
102            }
103
104            if let Node::TableAlign(TableAlign { align, .. }) = node {
105                use itertools::Itertools;
106                buffer.push('|');
107                buffer.push_str(&align.iter().map(|a| a.to_string()).join("|"));
108                buffer.push_str("|\n");
109                pre_position = node.position();
110                is_first = false;
111                in_table = true;
112                continue;
113            }
114
115            current_table_row = None;
116            in_table = false;
117
118            let value = node.render_with_theme(&self.options, theme);
119
120            if value.is_empty() || value == "\n" {
121                pre_position = None;
122                continue;
123            }
124
125            if let Some(pos) = node.position() {
126                let new_line_count = pre_position
127                    .as_ref()
128                    .map(|p| pos.start.line.saturating_sub(p.end.line))
129                    .unwrap_or_else(|| if is_first { 0 } else { 1 })
130                    .min(2);
131
132                pre_position = Some(pos.clone());
133
134                for _ in 0..new_line_count {
135                    buffer.push('\n');
136                }
137                buffer.push_str(&value);
138            } else {
139                if !is_first {
140                    buffer.push('\n');
141                }
142                pre_position = None;
143                buffer.push_str(&value);
144            }
145
146            if is_first {
147                is_first = false;
148            }
149        }
150
151        if buffer.is_empty() || buffer.ends_with('\n') {
152            buffer
153        } else {
154            buffer.push('\n');
155            buffer
156        }
157    }
158
159    pub fn from_mdx_str(content: &str) -> miette::Result<Self> {
160        let root = markdown::to_mdast(content, &markdown::ParseOptions::mdx()).map_err(|e| miette!(e.reason))?;
161        let nodes = Node::from_mdast_node(root);
162
163        Ok(Self {
164            nodes,
165            options: RenderOptions::default(),
166        })
167    }
168
169    pub fn to_html(&self) -> String {
170        let md_str = self.to_string();
171        markdown::to_html_with_options(&md_str, &html_options()).unwrap_or_else(|_| markdown::to_html(&md_str))
172    }
173
174    pub fn to_text(&self) -> String {
175        let mut result = String::with_capacity(self.nodes.len() * 20); // Reasonable estimate
176        for node in &self.nodes {
177            result.push_str(&node.value());
178            result.push('\n');
179        }
180        result
181    }
182
183    #[cfg(feature = "json")]
184    pub fn to_json(&self) -> miette::Result<String> {
185        let nodes = self
186            .nodes
187            .iter()
188            .filter(|node| !node.is_empty() && !node.is_empty_fragment())
189            .collect::<Vec<_>>();
190        serde_json::to_string_pretty(&nodes).map_err(|e| miette!("Failed to serialize to JSON: {}", e))
191    }
192
193    #[cfg(feature = "html-to-markdown")]
194    pub fn from_html_str(content: &str) -> miette::Result<Self> {
195        Self::from_html_str_with_options(content, ConversionOptions::default())
196    }
197
198    #[cfg(feature = "html-to-markdown")]
199    pub fn from_html_str_with_options(content: &str, options: ConversionOptions) -> miette::Result<Self> {
200        html_to_markdown::convert_html_to_markdown(content, options)
201            .map_err(|e| miette!(e))
202            .and_then(|md_string| Self::from_markdown_str(&md_string))
203    }
204
205    pub fn from_markdown_str(content: &str) -> miette::Result<Self> {
206        let root = markdown::to_mdast(
207            content,
208            &markdown::ParseOptions {
209                gfm_strikethrough_single_tilde: true,
210                math_text_single_dollar: true,
211                mdx_expression_parse: None,
212                mdx_esm_parse: None,
213                constructs: Constructs {
214                    attention: true,
215                    autolink: true,
216                    block_quote: true,
217                    character_escape: true,
218                    character_reference: true,
219                    code_indented: true,
220                    code_fenced: true,
221                    code_text: true,
222                    definition: true,
223                    frontmatter: true,
224                    gfm_autolink_literal: true,
225                    gfm_label_start_footnote: true,
226                    gfm_footnote_definition: true,
227                    gfm_strikethrough: true,
228                    gfm_table: true,
229                    gfm_task_list_item: true,
230                    hard_break_escape: true,
231                    hard_break_trailing: true,
232                    heading_atx: true,
233                    heading_setext: true,
234                    html_flow: true,
235                    html_text: true,
236                    label_start_image: true,
237                    label_start_link: true,
238                    label_end: true,
239                    list_item: true,
240                    math_flow: true,
241                    math_text: true,
242                    mdx_esm: false,
243                    mdx_expression_flow: false,
244                    mdx_expression_text: false,
245                    mdx_jsx_flow: false,
246                    mdx_jsx_text: false,
247                    thematic_break: true,
248                },
249            },
250        )
251        .map_err(|e| miette!(e.reason))?;
252        let nodes = Node::from_mdast_node(root);
253
254        Ok(Self {
255            nodes,
256            options: RenderOptions::default(),
257        })
258    }
259}
260
261/// Returns the shared `Options` used for both `Markdown::to_html` and the
262/// standalone `to_html` helper.  The options mirror the constructs that are
263/// enabled during parsing (see `from_markdown_str`) so that every feature
264/// that can be *parsed* is also correctly *rendered* to HTML, including:
265///
266/// - GFM tables → `<table>`
267/// - GFM task-list items → `<input type="checkbox">`
268/// - GFM strikethrough → `<del>`
269/// - GFM footnotes
270/// - GFM autolink literals
271/// - Math (flow and inline) via `<code class="language-math …">`
272/// - YAML / TOML frontmatter (stripped from HTML output)
273fn html_options() -> Options {
274    Options {
275        parse: ParseOptions {
276            gfm_strikethrough_single_tilde: true,
277            math_text_single_dollar: true,
278            constructs: Constructs {
279                attention: true,
280                autolink: true,
281                block_quote: true,
282                character_escape: true,
283                character_reference: true,
284                code_indented: true,
285                code_fenced: true,
286                code_text: true,
287                definition: true,
288                frontmatter: true,
289                gfm_autolink_literal: true,
290                gfm_label_start_footnote: true,
291                gfm_footnote_definition: true,
292                gfm_strikethrough: true,
293                gfm_table: true,
294                gfm_task_list_item: true,
295                hard_break_escape: true,
296                hard_break_trailing: true,
297                heading_atx: true,
298                heading_setext: true,
299                html_flow: true,
300                html_text: true,
301                label_start_image: true,
302                label_start_link: true,
303                label_end: true,
304                list_item: true,
305                math_flow: true,
306                math_text: true,
307                thematic_break: true,
308                ..Constructs::default()
309            },
310            ..ParseOptions::default()
311        },
312        compile: CompileOptions {
313            allow_dangerous_html: true,
314            ..CompileOptions::default()
315        },
316    }
317}
318
319pub fn to_html(s: &str) -> String {
320    markdown::to_html_with_options(s, &html_options()).unwrap_or_else(|_| markdown::to_html(s))
321}
322
323#[cfg(test)]
324mod tests {
325    use rstest::rstest;
326
327    use crate::{ListStyle, TitleSurroundStyle, UrlSurroundStyle};
328
329    use super::*;
330
331    #[rstest]
332    #[case::header("# Title", 1, "# Title\n")]
333    #[case::header("# Title\nParagraph", 2, "# Title\nParagraph\n")]
334    #[case::header("# Title\n\nParagraph", 2, "# Title\n\nParagraph\n")]
335    #[case::list("- Item 1\n- Item 2", 2, "- Item 1\n- Item 2\n")]
336    #[case::quote("> Quote\n>Second line", 1, "> Quote\n> Second line\n")]
337    #[case::code("```rust\nlet x = 1;\n```", 1, "```rust\nlet x = 1;\n```\n")]
338    #[case::toml("+++\n[test]\ntest = 1\n+++", 1, "+++\n[test]\ntest = 1\n+++\n")]
339    #[case::code_inline("`inline`", 1, "`inline`\n")]
340    #[case::math_inline("$math$", 1, "$math$\n")]
341    #[case::math("$$\nmath\n$$", 1, "$$\nmath\n$$\n")]
342    #[case::html("<div>test</div>", 1, "<div>test</div>\n")]
343    #[case::footnote("[^a]: b", 1, "[^a]: b\n")]
344    #[case::definition("[a]: b", 1, "[a]: b\n")]
345    #[case::footnote("[^a]: b", 1, "[^a]: b\n")]
346    #[case::footnote_ref("[^a]: b\n\n[^a]", 2, "[^a]: b\n[^a]\n")]
347    #[case::image("![a](b)", 1, "![a](b)\n")]
348    #[case::image_with_title("![a](b \"c\")", 1, "![a](b \"c\")\n")]
349    #[case::image_ref("[a]: b\n\n ![c][a]", 2, "[a]: b\n\n![c][a]\n")]
350    #[case::yaml(
351        "---\ntitle: Test\ndescription: YAML front matter\n---\n",
352        1,
353        "---\ntitle: Test\ndescription: YAML front matter\n---\n"
354    )]
355    #[case::link("[a](b)", 1, "[a](b)\n")]
356    #[case::link_ref("[a]: b\n\n[c][a]", 2, "[a]: b\n\n[c][a]\n")]
357    #[case::break_("a\\b", 1, "a\\b\n")]
358    #[case::delete("~~a~~", 1, "~~a~~\n")]
359    #[case::emphasis("*a*", 1, "*a*\n")]
360    #[case::horizontal_rule("---", 1, "---\n")]
361    #[case::table(
362        "| Column1 | Column2 | Column3 |\n|:--------|:--------:|---------:|\n| Left    | Center  | Right   |\n",
363        7,
364        "|Column1|Column2|Column3|\n|:---|:---:|---:|\n|Left|Center|Right|\n"
365    )]
366    #[case::table_after_paragraph(
367        "Paragraph\n\n| A | B |\n|---|---|\n| 1 | 2 |\n",
368        6,
369        "Paragraph\n\n|A|B|\n|---|---|\n|1|2|\n"
370    )]
371    #[case::table_after_heading(
372        "# Title\n\n| A | B |\n|---|---|\n| 1 | 2 |\n",
373        6,
374        "# Title\n\n|A|B|\n|---|---|\n|1|2|\n"
375    )]
376    #[case::excessive_blank_lines("# Title\n\n\n\nParagraph", 2, "# Title\n\nParagraph\n")]
377    #[case::three_blank_lines("Para 1\n\n\n\n\nPara 2", 2, "Para 1\n\nPara 2\n")]
378    // GFM autolink literal: link text is the same URL — must not nest on round-trip
379    #[case::link_url_as_text(
380        "[https://example.com](https://example.com)",
381        1,
382        "[https://example.com](https://example.com)\n"
383    )]
384    fn test_markdown_from_str(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) {
385        let md = input.parse::<Markdown>().unwrap();
386        assert_eq!(md.nodes.len(), expected_nodes);
387        assert_eq!(md.to_string(), expected_output);
388    }
389
390    #[rstest]
391    #[case::mdx("{test}", 1, "{test}\n")]
392    #[case::mdx("<a />", 1, "<a />\n")]
393    #[case::mdx("<MyComponent {...props}/>", 1, "<MyComponent {...props} />\n")]
394    #[case::mdx("text<MyComponent {...props}/>text", 3, "text<MyComponent {...props} />text\n")]
395    #[case::mdx(
396        "<Chart color=\"#fcb32c\" year={year} />",
397        1,
398        "<Chart color=\"#fcb32c\" year={year} />\n"
399    )]
400    fn test_markdown_from_mdx_str(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) {
401        let md = Markdown::from_mdx_str(input).unwrap();
402        assert_eq!(md.nodes.len(), expected_nodes);
403        assert_eq!(md.to_string(), expected_output);
404    }
405
406    /// Round-tripping a link whose text equals its URL must be idempotent.
407    /// Previously, GFM autolink literal parsing caused the URL text to be
408    /// wrapped in a second Link node, producing `[[url](url)](url)` after
409    /// the first serialisation and deeper nesting on every subsequent pass.
410    #[test]
411    fn test_link_url_as_text_is_idempotent() {
412        let input = "[https://example.com](https://example.com)";
413        let first = input.parse::<Markdown>().unwrap().to_string();
414        let second = first.parse::<Markdown>().unwrap().to_string();
415        assert_eq!(first, second, "round-trip must be idempotent");
416        assert_eq!(first, "[https://example.com](https://example.com)\n");
417    }
418
419    #[test]
420    fn test_markdown_to_html() {
421        let md = "# Hello".parse::<Markdown>().unwrap();
422        let html = md.to_html();
423        assert_eq!(html, "<h1>Hello</h1>\n");
424    }
425
426    #[test]
427    fn test_to_html_gfm_table() {
428        let md = "| A | B |\n|---|---|\n| 1 | 2 |".parse::<Markdown>().unwrap();
429        let html = md.to_html();
430        assert!(html.contains("<table>"), "expected <table> tag in: {html}");
431        assert!(html.contains("<thead>"), "expected <thead> in: {html}");
432        assert!(html.contains("<tbody>"), "expected <tbody> in: {html}");
433        assert!(html.contains("<th>A</th>"), "expected <th>A</th> in: {html}");
434        assert!(html.contains("<td>1</td>"), "expected <td>1</td> in: {html}");
435    }
436
437    #[test]
438    fn test_to_html_gfm_table_alignment() {
439        let md = "| L | C | R |\n|:---|:---:|---:|\n| a | b | c |"
440            .parse::<Markdown>()
441            .unwrap();
442        let html = md.to_html();
443        assert!(
444            html.contains("align=\"left\"") || html.contains("style=\"text-align:left\"") || html.contains("<table>"),
445            "table with alignment rendered: {html}"
446        );
447    }
448
449    #[test]
450    fn test_to_html_gfm_strikethrough() {
451        let md = "~~deleted~~".parse::<Markdown>().unwrap();
452        let html = md.to_html();
453        assert!(html.contains("<del>deleted</del>"), "expected <del> in: {html}");
454    }
455
456    #[test]
457    fn test_to_html_gfm_task_list() {
458        let md = "- [ ] Unchecked\n- [x] Checked".parse::<Markdown>().unwrap();
459        let html = md.to_html();
460        assert!(html.contains("<input"), "expected <input> in: {html}");
461        assert!(html.contains("type=\"checkbox\""), "expected checkbox in: {html}");
462        assert!(html.contains("checked"), "expected checked attribute in: {html}");
463    }
464
465    #[test]
466    fn test_to_html_math_inline() {
467        let md = "Inline math: $E = mc^2$".parse::<Markdown>().unwrap();
468        let html = md.to_html();
469        assert!(
470            html.contains("math") && html.contains("E = mc^2"),
471            "expected math content in: {html}"
472        );
473    }
474
475    #[test]
476    fn test_to_html_math_block() {
477        let md = "$$\n\\frac{1}{2}\n$$".parse::<Markdown>().unwrap();
478        let html = md.to_html();
479        assert!(
480            html.contains("math") && html.contains("\\frac{1}{2}"),
481            "expected math block in: {html}"
482        );
483    }
484
485    #[test]
486    fn test_to_html_footnote() {
487        let md = "Footnote[^1]\n\n[^1]: Definition".parse::<Markdown>().unwrap();
488        let html = md.to_html();
489        assert!(
490            html.contains("footnote") || html.contains("fn"),
491            "expected footnote in: {html}"
492        );
493    }
494
495    #[test]
496    fn test_to_html_html_passthrough() {
497        let md = "<kbd>Ctrl</kbd>+<kbd>C</kbd>".parse::<Markdown>().unwrap();
498        let html = md.to_html();
499        assert!(
500            html.contains("<kbd>Ctrl</kbd>"),
501            "expected <kbd> passthrough in: {html}"
502        );
503    }
504
505    #[test]
506    fn test_to_html_standalone_gfm_table() {
507        let input = "| H1 | H2 |\n|---|---|\n| a | b |";
508        let html = to_html(input);
509        assert!(
510            html.contains("<table>"),
511            "expected <table> from standalone to_html: {html}"
512        );
513    }
514
515    #[test]
516    fn test_markdown_to_text() {
517        let md = "# Hello\n\nWorld".parse::<Markdown>().unwrap();
518        let text = md.to_text();
519        assert_eq!(text, "Hello\nWorld\n");
520    }
521
522    #[test]
523    fn test_render_options() {
524        let mut md = "- Item 1\n- Item 2".parse::<Markdown>().unwrap();
525        assert_eq!(md.options, RenderOptions::default());
526
527        md.set_options(RenderOptions {
528            list_style: ListStyle::Plus,
529            ..RenderOptions::default()
530        });
531        assert_eq!(md.options.list_style, ListStyle::Plus);
532
533        let pretty = md.to_string();
534        assert!(pretty.contains("+ Item 1"));
535    }
536
537    #[test]
538    fn test_display_simple() {
539        let md = "# Header\nParagraph".parse::<Markdown>().unwrap();
540        assert_eq!(md.to_string(), "# Header\nParagraph\n");
541    }
542
543    #[test]
544    fn test_display_with_empty_nodes() {
545        let md = "# Header\nContent".parse::<Markdown>().unwrap();
546        assert_eq!(md.to_string(), "# Header\nContent\n");
547    }
548
549    #[test]
550    fn test_display_with_newlines() {
551        let md = "# Header\n\nParagraph 1\n\nParagraph 2".parse::<Markdown>().unwrap();
552        assert_eq!(md.to_string(), "# Header\n\nParagraph 1\n\nParagraph 2\n");
553    }
554
555    #[test]
556    fn test_display_format_lists() {
557        let md = "- Item 1\n- Item 2\n- Item 3".parse::<Markdown>().unwrap();
558        assert_eq!(md.to_string(), "- Item 1\n- Item 2\n- Item 3\n");
559    }
560
561    #[test]
562    fn test_display_with_different_list_styles() {
563        let mut md = "- Item 1\n- Item 2".parse::<Markdown>().unwrap();
564
565        md.set_options(RenderOptions {
566            list_style: ListStyle::Star,
567            link_title_style: TitleSurroundStyle::default(),
568            link_url_style: UrlSurroundStyle::default(),
569        });
570
571        let formatted = md.to_string();
572        assert!(formatted.contains("* Item 1"));
573        assert!(formatted.contains("* Item 2"));
574    }
575
576    #[test]
577    fn test_display_with_ordered_list() {
578        let md = "1. Item 1\n2. Item 2\n\n3. Item 2".parse::<Markdown>().unwrap();
579        let formatted = md.to_string();
580
581        assert!(formatted.contains("1. Item 1"));
582        assert!(formatted.contains("2. Item 2"));
583        assert!(formatted.contains("3. Item 2"));
584    }
585}
586
587#[cfg(test)]
588#[cfg(feature = "color")]
589mod color_tests {
590    use rstest::rstest;
591
592    use super::*;
593
594    #[rstest]
595    #[case::heading("# Title", "\x1b[1m\x1b[36m# Title\x1b[0m\n")]
596    #[case::emphasis("*italic*", "\x1b[3m\x1b[33m*italic*\x1b[0m\n")]
597    #[case::strong("**bold**", "\x1b[1m**bold**\x1b[0m\n")]
598    #[case::code_inline("`code`", "\x1b[32m`code`\x1b[0m\n")]
599    #[case::code_block("```rust\nlet x = 1;\n```", "\x1b[32m```rust\nlet x = 1;\n```\x1b[0m\n")]
600    #[case::link("[text](url)", "\x1b[4m\x1b[34m[text](url)\x1b[0m\n")]
601    #[case::image("![alt](url)", "\x1b[35m![alt](url)\x1b[0m\n")]
602    #[case::delete("~~deleted~~", "\x1b[31m\x1b[2m~~deleted~~\x1b[0m\n")]
603    #[case::horizontal_rule("---", "\x1b[2m---\x1b[0m\n")]
604    #[case::blockquote("> quote", "\x1b[2m> \x1b[0mquote\n")]
605    #[case::math_inline("$x^2$", "\x1b[32m$x^2$\x1b[0m\n")]
606    #[case::list("- item", "\x1b[33m-\x1b[0m item\n")]
607    fn test_to_colored_string(#[case] input: &str, #[case] expected: &str) {
608        let md = input.parse::<Markdown>().unwrap();
609        assert_eq!(md.to_colored_string(), expected);
610    }
611
612    #[test]
613    fn test_colored_output_contains_ansi_codes() {
614        let md = "# Hello\n\n**bold** and *italic*".parse::<Markdown>().unwrap();
615        let colored = md.to_colored_string();
616
617        assert!(colored.contains("\x1b["));
618        assert!(colored.contains("\x1b[0m"));
619    }
620
621    #[test]
622    fn test_plain_output_has_no_ansi_codes() {
623        let md = "# Hello\n\n**bold** and *italic*".parse::<Markdown>().unwrap();
624        let plain = md.to_string();
625
626        assert!(!plain.contains("\x1b["));
627    }
628
629    #[test]
630    fn test_parse_colors_overrides_specified_keys() {
631        let theme = ColorTheme::parse_colors("heading=1;31:code=34");
632        assert_eq!(theme.heading.0, "\x1b[1;31m");
633        assert_eq!(theme.heading.1, "\x1b[0m");
634        assert_eq!(theme.code.0, "\x1b[34m");
635        assert_eq!(theme.code.1, "\x1b[0m");
636        // Unspecified keys remain default
637        assert_eq!(theme.emphasis, ColorTheme::COLORED.emphasis);
638    }
639
640    #[test]
641    fn test_parse_colors_ignores_invalid_entries() {
642        let theme = ColorTheme::parse_colors("heading=abc:code=32:=:badformat");
643        // Invalid "abc" is skipped, heading stays default
644        assert_eq!(theme.heading, ColorTheme::COLORED.heading);
645        // Valid "32" is applied
646        assert_eq!(theme.code.0, "\x1b[32m");
647        assert_eq!(theme.code.1, "\x1b[0m");
648    }
649
650    #[test]
651    fn test_parse_colors_ignores_unknown_keys() {
652        let theme = ColorTheme::parse_colors("unknown=31:heading=33");
653        assert_eq!(theme.heading.0, "\x1b[33m");
654        assert_eq!(theme.heading.1, "\x1b[0m");
655    }
656
657    #[test]
658    fn test_parse_colors_all_keys() {
659        let theme = ColorTheme::parse_colors(
660            "heading=1:code=2:code_inline=3:emphasis=4:strong=5:link=6:link_url=7:\
661             image=8:blockquote=9:delete=10:hr=11:html=12:frontmatter=13:list=14:\
662             table=15:math=16",
663        );
664        assert_eq!(theme.heading.0, "\x1b[1m");
665        assert_eq!(theme.code.0, "\x1b[2m");
666        assert_eq!(theme.code_inline.0, "\x1b[3m");
667        assert_eq!(theme.emphasis.0, "\x1b[4m");
668        assert_eq!(theme.strong.0, "\x1b[5m");
669        assert_eq!(theme.link.0, "\x1b[6m");
670        assert_eq!(theme.link_url.0, "\x1b[7m");
671        assert_eq!(theme.image.0, "\x1b[8m");
672        assert_eq!(theme.blockquote_marker.0, "\x1b[9m");
673        assert_eq!(theme.delete.0, "\x1b[10m");
674        assert_eq!(theme.horizontal_rule.0, "\x1b[11m");
675        assert_eq!(theme.html.0, "\x1b[12m");
676        assert_eq!(theme.frontmatter.0, "\x1b[13m");
677        assert_eq!(theme.list_marker.0, "\x1b[14m");
678        assert_eq!(theme.table_separator.0, "\x1b[15m");
679        assert_eq!(theme.math.0, "\x1b[16m");
680    }
681
682    #[test]
683    fn test_parse_colors_empty_string() {
684        let theme = ColorTheme::parse_colors("");
685        assert_eq!(theme.heading, ColorTheme::COLORED.heading);
686    }
687
688    #[test]
689    fn test_colored_string_with_custom_theme() {
690        let theme = ColorTheme::parse_colors("heading=1;31");
691        let md = "# Title".parse::<Markdown>().unwrap();
692        let colored = md.to_colored_string_with_theme(&theme);
693        assert_eq!(colored, "\x1b[1;31m# Title\x1b[0m\n");
694    }
695}
696
697#[cfg(test)]
698#[cfg(feature = "json")]
699mod json_tests {
700    use rstest::rstest;
701
702    use super::*;
703
704    #[test]
705    fn test_to_json_simple() {
706        let md = "# Hello".parse::<Markdown>().unwrap();
707        let json = md.to_json().unwrap();
708        assert!(json.contains("\"type\": \"Heading\""));
709        assert!(json.contains("\"depth\": 1"));
710        assert!(json.contains("\"values\":"));
711    }
712
713    #[test]
714    fn test_to_json_complex() {
715        let md = "# Header\n\n- Item 1\n- Item 2\n\n*Emphasis* and **Strong**"
716            .parse::<Markdown>()
717            .unwrap();
718        let json = md.to_json().unwrap();
719
720        assert!(json.contains("\"type\": \"Heading\""));
721        assert!(json.contains("\"type\": \"List\""));
722        assert!(json.contains("\"type\": \"Strong\""));
723        assert!(json.contains("\"type\": \"Emphasis\""));
724    }
725
726    #[test]
727    fn test_to_json_code_blocks() {
728        let md = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```"
729            .parse::<Markdown>()
730            .unwrap();
731        let json = md.to_json().unwrap();
732
733        assert!(json.contains("\"type\": \"Code\""));
734        assert!(json.contains("\"lang\": \"rust\""));
735        assert!(json.contains("\"value\": \"fn main() {\\n    println!(\\\"Hello\\\");\\n}\""));
736    }
737
738    #[test]
739    fn test_to_json_table() {
740        let md = "| A | B |\n|---|---|\n| 1 | 2 |".parse::<Markdown>().unwrap();
741        let json = md.to_json().unwrap();
742
743        assert!(json.contains("\"type\": \"TableCell\""));
744    }
745
746    #[rstest]
747    #[case("<h1>Hello</h1>", 1, "# Hello\n")]
748    #[case("<p>Paragraph</p>", 1, "Paragraph\n")]
749    #[case("<ul><li>Item 1</li><li>Item 2</li></ul>", 2, "- Item 1\n- Item 2\n")]
750    #[case("<ol><li>First</li><li>Second</li></ol>", 2, "1. First\n2. Second\n")]
751    #[case("<blockquote>Quote</blockquote>", 1, "> Quote\n")]
752    #[case("<code>inline</code>", 1, "`inline`\n")]
753    #[case("<pre><code>block</code></pre>", 1, "```\nblock\n```\n")]
754    #[case("<table><tr><td>A</td><td>B</td></tr></table>", 3, "|A|B|\n|---|---|\n")]
755    #[cfg(feature = "html-to-markdown")]
756    fn test_markdown_from_html(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) {
757        let md = Markdown::from_html_str(input).unwrap();
758        assert_eq!(md.nodes.len(), expected_nodes);
759        assert_eq!(md.to_string(), expected_output);
760    }
761}