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 #[cfg(feature = "color")]
44 pub fn to_colored_string(&self) -> String {
45 self.render_with_theme(&ColorTheme::COLORED)
46 }
47
48 #[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 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); 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
261fn 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("", 1, "\n")]
348 #[case::image_with_title("", 1, "\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 #[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 #[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("", "\x1b[35m\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 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 assert_eq!(theme.heading, ColorTheme::COLORED.heading);
645 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}