1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
4
5#[derive(Debug, Clone)]
11pub enum RichTextNode {
12 Plain(String),
13 Bold(Vec<RichTextNode>),
14 Italic(Vec<RichTextNode>),
15 Underline(Vec<RichTextNode>),
16 Strikethrough(Vec<RichTextNode>),
17 Spoiler(Vec<RichTextNode>),
19 Code(String),
20 CodeBlock {
21 language: Option<String>,
22 code: String,
23 },
24 Blockquote(Vec<RichTextNode>),
26 Heading {
28 level: u8,
29 children: Vec<RichTextNode>,
30 },
31 Link {
32 url: String,
33 text: Vec<RichTextNode>,
34 },
35 Mention {
36 id: String,
37 name: String,
38 },
39 Emoji(String),
40 Paragraph(Vec<RichTextNode>),
41 ListItem(Vec<RichTextNode>),
42}
43
44pub struct RichText(pub Vec<RichTextNode>);
46
47impl RichTextNode {
48 fn to_plain_text(&self) -> String {
49 match self {
50 RichTextNode::Plain(s) => s.clone(),
51 RichTextNode::Bold(children)
52 | RichTextNode::Italic(children)
53 | RichTextNode::Underline(children)
54 | RichTextNode::Strikethrough(children)
55 | RichTextNode::Spoiler(children)
56 | RichTextNode::Blockquote(children)
57 | RichTextNode::Paragraph(children)
58 | RichTextNode::ListItem(children) => {
59 children.iter().map(|n| n.to_plain_text()).collect()
60 }
61 RichTextNode::Heading { children, .. } => {
62 children.iter().map(|n| n.to_plain_text()).collect()
63 }
64 RichTextNode::Code(s) => s.clone(),
65 RichTextNode::CodeBlock { code, .. } => code.clone(),
66 RichTextNode::Link { text, .. } => text.iter().map(|n| n.to_plain_text()).collect(),
67 RichTextNode::Mention { name, .. } => format!("@{}", name),
68 RichTextNode::Emoji(e) => e.clone(),
69 }
70 }
71
72 fn to_markdown(&self) -> String {
73 match self {
74 RichTextNode::Plain(s) => s.clone(),
75 RichTextNode::Bold(children) => {
76 format!(
77 "**{}**",
78 children.iter().map(|n| n.to_markdown()).collect::<String>()
79 )
80 }
81 RichTextNode::Italic(children) => {
82 format!(
83 "*{}*",
84 children.iter().map(|n| n.to_markdown()).collect::<String>()
85 )
86 }
87 RichTextNode::Underline(children) => {
88 format!(
90 "<u>{}</u>",
91 children.iter().map(|n| n.to_markdown()).collect::<String>()
92 )
93 }
94 RichTextNode::Strikethrough(children) => {
95 format!(
96 "~~{}~~",
97 children.iter().map(|n| n.to_markdown()).collect::<String>()
98 )
99 }
100 RichTextNode::Spoiler(children) => {
101 format!(
103 "||{}||",
104 children.iter().map(|n| n.to_markdown()).collect::<String>()
105 )
106 }
107 RichTextNode::Code(s) => format!("`{}`", s),
108 RichTextNode::CodeBlock { language, code } => {
109 if let Some(lang) = language {
110 format!("```{}\n{}\n```", lang, code)
111 } else {
112 format!("```\n{}\n```", code)
113 }
114 }
115 RichTextNode::Blockquote(children) => {
116 let inner: String = children.iter().map(|n| n.to_markdown()).collect();
117 inner
118 .lines()
119 .map(|line| format!("> {}", line))
120 .collect::<Vec<_>>()
121 .join("\n")
122 }
123 RichTextNode::Heading { level, children } => {
124 let hashes = "#".repeat((*level).min(6) as usize);
125 format!(
126 "{} {}",
127 hashes,
128 children.iter().map(|n| n.to_markdown()).collect::<String>()
129 )
130 }
131 RichTextNode::Link { url, text } => {
132 format!(
133 "[{}]({})",
134 text.iter().map(|n| n.to_markdown()).collect::<String>(),
135 url
136 )
137 }
138 RichTextNode::Mention { name, .. } => format!("@{}", name),
139 RichTextNode::Emoji(e) => e.clone(),
140 RichTextNode::Paragraph(children) | RichTextNode::ListItem(children) => {
141 children.iter().map(|n| n.to_markdown()).collect()
142 }
143 }
144 }
145
146 fn to_matrix_html(&self) -> String {
147 match self {
148 RichTextNode::Plain(s) => html_escape(s),
149 RichTextNode::Bold(children) => {
150 format!(
151 "<b>{}</b>",
152 children
153 .iter()
154 .map(|n| n.to_matrix_html())
155 .collect::<String>()
156 )
157 }
158 RichTextNode::Italic(children) => {
159 format!(
160 "<i>{}</i>",
161 children
162 .iter()
163 .map(|n| n.to_matrix_html())
164 .collect::<String>()
165 )
166 }
167 RichTextNode::Underline(children) => {
168 format!(
169 "<u>{}</u>",
170 children
171 .iter()
172 .map(|n| n.to_matrix_html())
173 .collect::<String>()
174 )
175 }
176 RichTextNode::Strikethrough(children) => {
177 format!(
178 "<del>{}</del>",
179 children
180 .iter()
181 .map(|n| n.to_matrix_html())
182 .collect::<String>()
183 )
184 }
185 RichTextNode::Spoiler(children) => {
186 format!(
187 "<span data-mx-spoiler>{}</span>",
188 children
189 .iter()
190 .map(|n| n.to_matrix_html())
191 .collect::<String>()
192 )
193 }
194 RichTextNode::Code(s) => format!("<code>{}</code>", html_escape(s)),
195 RichTextNode::CodeBlock { language, code } => {
196 if let Some(lang) = language {
197 format!(
198 "<pre><code class=\"language-{}\">{}</code></pre>",
199 lang,
200 html_escape(code)
201 )
202 } else {
203 format!("<pre>{}</pre>", html_escape(code))
204 }
205 }
206 RichTextNode::Blockquote(children) => {
207 format!(
208 "<blockquote>{}</blockquote>",
209 children
210 .iter()
211 .map(|n| n.to_matrix_html())
212 .collect::<String>()
213 )
214 }
215 RichTextNode::Heading { level, children } => {
216 let tag = format!("h{}", (*level).min(6));
217 format!(
218 "<{tag}>{}</{tag}>",
219 children
220 .iter()
221 .map(|n| n.to_matrix_html())
222 .collect::<String>()
223 )
224 }
225 RichTextNode::Link { url, text } => {
226 format!(
227 "<a href=\"{}\">{}</a>",
228 html_escape(url),
229 text.iter().map(|n| n.to_matrix_html()).collect::<String>()
230 )
231 }
232 RichTextNode::Mention { name, .. } => format!("@{}", html_escape(name)),
233 RichTextNode::Emoji(e) => html_escape(e),
234 RichTextNode::Paragraph(children) | RichTextNode::ListItem(children) => {
235 children.iter().map(|n| n.to_matrix_html()).collect()
236 }
237 }
238 }
239
240 fn to_irc_formatted(&self) -> String {
241 match self {
242 RichTextNode::Plain(s) => s.clone(),
243 RichTextNode::Bold(children) => {
244 format!(
245 "\x02{}\x02",
246 children
247 .iter()
248 .map(|n| n.to_irc_formatted())
249 .collect::<String>()
250 )
251 }
252 RichTextNode::Italic(children) => {
253 format!(
254 "\x1D{}\x1D",
255 children
256 .iter()
257 .map(|n| n.to_irc_formatted())
258 .collect::<String>()
259 )
260 }
261 RichTextNode::Underline(children) => {
262 format!(
264 "\x1F{}\x1F",
265 children
266 .iter()
267 .map(|n| n.to_irc_formatted())
268 .collect::<String>()
269 )
270 }
271 RichTextNode::Strikethrough(children) => {
272 children.iter().map(|n| n.to_irc_formatted()).collect()
274 }
275 RichTextNode::Spoiler(children) => {
276 format!(
278 "[spoiler: {}]",
279 children
280 .iter()
281 .map(|n| n.to_irc_formatted())
282 .collect::<String>()
283 )
284 }
285 RichTextNode::Code(s) => format!("`{}`", s),
286 RichTextNode::CodeBlock { code, .. } => code.clone(),
287 RichTextNode::Blockquote(children) => {
288 let inner: String = children.iter().map(|n| n.to_irc_formatted()).collect();
289 inner
290 .lines()
291 .map(|line| format!("| {}", line))
292 .collect::<Vec<_>>()
293 .join("\n")
294 }
295 RichTextNode::Heading { children, .. } => {
296 format!(
298 "\x02{}\x02",
299 children
300 .iter()
301 .map(|n| n.to_irc_formatted())
302 .collect::<String>()
303 )
304 }
305 RichTextNode::Link { url, text } => {
306 format!(
307 "{} ({})",
308 text.iter()
309 .map(|n| n.to_irc_formatted())
310 .collect::<String>(),
311 url
312 )
313 }
314 RichTextNode::Mention { name, .. } => format!("@{}", name),
315 RichTextNode::Emoji(e) => e.clone(),
316 RichTextNode::Paragraph(children) | RichTextNode::ListItem(children) => {
317 children.iter().map(|n| n.to_irc_formatted()).collect()
318 }
319 }
320 }
321
322 fn to_whatsapp_formatted(&self) -> String {
323 match self {
324 RichTextNode::Plain(s) => s.clone(),
325 RichTextNode::Bold(children) => {
326 format!(
327 "*{}*",
328 children
329 .iter()
330 .map(|n| n.to_whatsapp_formatted())
331 .collect::<String>()
332 )
333 }
334 RichTextNode::Italic(children) => {
335 format!(
336 "_{}_",
337 children
338 .iter()
339 .map(|n| n.to_whatsapp_formatted())
340 .collect::<String>()
341 )
342 }
343 RichTextNode::Underline(children) => {
344 children.iter().map(|n| n.to_whatsapp_formatted()).collect()
346 }
347 RichTextNode::Strikethrough(children) => {
348 format!(
349 "~{}~",
350 children
351 .iter()
352 .map(|n| n.to_whatsapp_formatted())
353 .collect::<String>()
354 )
355 }
356 RichTextNode::Spoiler(children) => {
357 children.iter().map(|n| n.to_whatsapp_formatted()).collect()
359 }
360 RichTextNode::Code(s) => format!("`{}`", s),
361 RichTextNode::CodeBlock { code, .. } => format!("```{}```", code),
362 RichTextNode::Blockquote(children) => {
363 let inner: String = children.iter().map(|n| n.to_whatsapp_formatted()).collect();
364 inner
365 .lines()
366 .map(|line| format!("> {}", line))
367 .collect::<Vec<_>>()
368 .join("\n")
369 }
370 RichTextNode::Heading { children, .. } => {
371 format!(
373 "*{}*",
374 children
375 .iter()
376 .map(|n| n.to_whatsapp_formatted())
377 .collect::<String>()
378 )
379 }
380 RichTextNode::Link { url, text } => {
381 format!(
382 "{} ({})",
383 text.iter()
384 .map(|n| n.to_whatsapp_formatted())
385 .collect::<String>(),
386 url
387 )
388 }
389 RichTextNode::Mention { name, .. } => format!("@{}", name),
390 RichTextNode::Emoji(e) => e.clone(),
391 RichTextNode::Paragraph(children) | RichTextNode::ListItem(children) => {
392 children.iter().map(|n| n.to_whatsapp_formatted()).collect()
393 }
394 }
395 }
396}
397
398fn html_escape(s: impl AsRef<str>) -> String {
399 let s = s.as_ref();
400 let mut out = String::with_capacity(s.len());
401 for ch in s.chars() {
402 match ch {
403 '&' => out.push_str("&"),
404 '<' => out.push_str("<"),
405 '>' => out.push_str(">"),
406 '"' => out.push_str("""),
407 c => out.push(c),
408 }
409 }
410 out
411}
412
413impl RichText {
414 pub fn to_plain_text(&self) -> String {
416 self.0.iter().map(|n| n.to_plain_text()).collect()
417 }
418
419 pub fn to_markdown(&self) -> String {
421 self.0.iter().map(|n| n.to_markdown()).collect()
422 }
423
424 pub fn to_discord_markdown(&self) -> String {
428 fn discord_node(node: &RichTextNode) -> String {
429 match node {
430 RichTextNode::Underline(children) => {
431 format!(
432 "__{}__",
433 children.iter().map(discord_node).collect::<String>()
434 )
435 }
436 RichTextNode::Spoiler(children) => {
437 format!(
438 "||{}||",
439 children.iter().map(discord_node).collect::<String>()
440 )
441 }
442 other => other.to_markdown(),
444 }
445 }
446 self.0.iter().map(discord_node).collect()
447 }
448
449 pub fn to_telegram_html(&self) -> String {
451 crate::markdown::markdown_to_telegram_html(self.to_markdown())
452 }
453
454 pub fn to_slack_mrkdwn(&self) -> String {
456 crate::markdown::markdown_to_slack(self.to_markdown())
457 }
458
459 pub fn to_matrix_html(&self) -> String {
461 self.0.iter().map(|n| n.to_matrix_html()).collect()
462 }
463
464 pub fn to_irc_formatted(&self) -> String {
466 self.0.iter().map(|n| n.to_irc_formatted()).collect()
467 }
468
469 pub fn to_whatsapp_formatted(&self) -> String {
471 self.0.iter().map(|n| n.to_whatsapp_formatted()).collect()
472 }
473
474 pub fn from_plain(text: impl AsRef<str>) -> Self {
476 Self(vec![RichTextNode::Plain(text.as_ref().to_string())])
477 }
478
479 pub fn from_markdown(text: impl AsRef<str>) -> Self {
481 let text = text.as_ref();
482 let mut opts = Options::empty();
483 opts.insert(Options::ENABLE_STRIKETHROUGH);
484 let parser = Parser::new_ext(text, opts);
485
486 let mut stack: Vec<Vec<RichTextNode>> = vec![vec![]];
487
488 for event in parser {
489 match event {
490 Event::Start(Tag::Strong)
491 | Event::Start(Tag::Emphasis)
492 | Event::Start(Tag::Strikethrough) => {
493 stack.push(vec![]);
494 }
495 Event::Start(Tag::BlockQuote(_)) => {
496 stack.push(vec![]);
497 }
498 Event::Start(Tag::Heading { level, .. }) => {
499 let level_u8 = match level {
501 pulldown_cmark::HeadingLevel::H1 => 1,
502 pulldown_cmark::HeadingLevel::H2 => 2,
503 pulldown_cmark::HeadingLevel::H3 => 3,
504 pulldown_cmark::HeadingLevel::H4 => 4,
505 pulldown_cmark::HeadingLevel::H5 => 5,
506 pulldown_cmark::HeadingLevel::H6 => 6,
507 };
508 stack.push(vec![RichTextNode::Plain(level_u8.to_string())]);
509 stack.push(vec![]);
510 }
511 Event::Start(Tag::Link { dest_url, .. }) => {
512 stack.push(vec![RichTextNode::Plain(dest_url.to_string())]);
513 stack.push(vec![]);
514 }
515 Event::Start(Tag::CodeBlock(kind)) => {
516 let lang = match kind {
517 pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
518 Some(lang.to_string())
519 }
520 _ => None,
521 };
522 stack.push(vec![RichTextNode::Plain(lang.unwrap_or_default())]);
523 stack.push(vec![]);
524 }
525 Event::End(TagEnd::Strong) => {
526 let children = stack.pop().unwrap_or_default();
527 if let Some(top) = stack.last_mut() {
528 top.push(RichTextNode::Bold(children));
529 }
530 }
531 Event::End(TagEnd::Emphasis) => {
532 let children = stack.pop().unwrap_or_default();
533 if let Some(top) = stack.last_mut() {
534 top.push(RichTextNode::Italic(children));
535 }
536 }
537 Event::End(TagEnd::Strikethrough) => {
538 let children = stack.pop().unwrap_or_default();
539 if let Some(top) = stack.last_mut() {
540 top.push(RichTextNode::Strikethrough(children));
541 }
542 }
543 Event::End(TagEnd::BlockQuote(_)) => {
544 let children = stack.pop().unwrap_or_default();
545 if let Some(top) = stack.last_mut() {
546 top.push(RichTextNode::Blockquote(children));
547 }
548 }
549 Event::End(TagEnd::Heading(_)) => {
550 let children = stack.pop().unwrap_or_default();
551 let level_node = stack.pop().unwrap_or_default();
552 let level: u8 =
553 if let Some(RichTextNode::Plain(l)) = level_node.into_iter().next() {
554 l.parse().unwrap_or(1)
555 } else {
556 1
557 };
558 if let Some(top) = stack.last_mut() {
559 top.push(RichTextNode::Heading { level, children });
560 }
561 }
562 Event::End(TagEnd::Link) => {
563 let link_text = stack.pop().unwrap_or_default();
564 let url_node = stack.pop().unwrap_or_default();
565 let url = if let Some(RichTextNode::Plain(u)) = url_node.into_iter().next() {
566 u
567 } else {
568 String::new()
569 };
570 if let Some(top) = stack.last_mut() {
571 top.push(RichTextNode::Link {
572 url,
573 text: link_text,
574 });
575 }
576 }
577 Event::End(TagEnd::CodeBlock) => {
578 let code_nodes = stack.pop().unwrap_or_default();
579 let lang_node = stack.pop().unwrap_or_default();
580 let lang = if let Some(RichTextNode::Plain(l)) = lang_node.into_iter().next() {
581 if l.is_empty() { None } else { Some(l) }
582 } else {
583 None
584 };
585 let code: String = code_nodes.iter().map(|n| n.to_plain_text()).collect();
586 if let Some(top) = stack.last_mut() {
587 top.push(RichTextNode::CodeBlock {
588 language: lang,
589 code,
590 });
591 }
592 }
593 Event::Code(text) => {
594 if let Some(top) = stack.last_mut() {
595 top.push(RichTextNode::Code(text.to_string()));
596 }
597 }
598 Event::Text(text) => {
599 if let Some(top) = stack.last_mut() {
600 top.push(RichTextNode::Plain(text.to_string()));
601 }
602 }
603 Event::SoftBreak | Event::HardBreak => {
604 if let Some(top) = stack.last_mut() {
605 top.push(RichTextNode::Plain("\n".into()));
606 }
607 }
608 _ => {}
609 }
610 }
611
612 Self(stack.into_iter().next().unwrap_or_default())
613 }
614}
615
616impl std::fmt::Display for RichText {
617 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618 f.write_str(&self.to_plain_text())
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn plain_text_strips_formatting() {
628 let rt = RichText(vec![
629 RichTextNode::Bold(vec![RichTextNode::Plain("hello".into())]),
630 RichTextNode::Plain(" world".into()),
631 ]);
632 assert_eq!(rt.to_plain_text(), "hello world");
633 }
634
635 #[test]
636 fn discord_bold_renders_stars() {
637 let rt = RichText(vec![RichTextNode::Bold(vec![RichTextNode::Plain(
638 "hi".into(),
639 )])]);
640 assert!(rt.to_discord_markdown().contains("**hi**"));
641 }
642
643 #[test]
644 fn matrix_bold_renders_b_tag() {
645 let rt = RichText(vec![RichTextNode::Bold(vec![RichTextNode::Plain(
646 "hi".into(),
647 )])]);
648 assert!(rt.to_matrix_html().contains("<b>hi</b>"));
649 }
650
651 #[test]
652 fn irc_bold_uses_control_char() {
653 let rt = RichText(vec![RichTextNode::Bold(vec![RichTextNode::Plain(
654 "hi".into(),
655 )])]);
656 let s = rt.to_irc_formatted();
657 assert!(s.contains('\x02'));
658 }
659
660 #[test]
661 fn whatsapp_bold_uses_stars() {
662 let rt = RichText(vec![RichTextNode::Bold(vec![RichTextNode::Plain(
663 "hi".into(),
664 )])]);
665 assert!(rt.to_whatsapp_formatted().contains("*hi*"));
666 }
667
668 #[test]
669 fn from_markdown_parses_bold() {
670 let rt = RichText::from_markdown("**bold text**");
671 assert!(rt.0.iter().any(|n| matches!(n, RichTextNode::Bold(_))));
672 }
673
674 #[test]
675 fn display_gives_plain_text() {
676 let rt = RichText(vec![RichTextNode::Plain("hello".into())]);
677 assert_eq!(rt.to_string(), "hello");
678 }
679
680 #[test]
681 fn code_block_roundtrip() {
682 let rt = RichText(vec![RichTextNode::CodeBlock {
683 language: Some("rust".into()),
684 code: "let x = 1;".into(),
685 }]);
686 let md = rt.to_markdown();
687 assert!(md.contains("```rust"));
688 assert!(md.contains("let x = 1;"));
689 }
690
691 #[test]
692 fn underline_renders_html_in_markdown() {
693 let rt = RichText(vec![RichTextNode::Underline(vec![RichTextNode::Plain(
694 "hi".into(),
695 )])]);
696 assert!(rt.to_markdown().contains("<u>hi</u>"));
697 }
698
699 #[test]
700 fn underline_renders_discord_double_underscore() {
701 let rt = RichText(vec![RichTextNode::Underline(vec![RichTextNode::Plain(
702 "hi".into(),
703 )])]);
704 assert!(rt.to_discord_markdown().contains("__hi__"));
705 }
706
707 #[test]
708 fn underline_renders_irc_control_char() {
709 let rt = RichText(vec![RichTextNode::Underline(vec![RichTextNode::Plain(
710 "hi".into(),
711 )])]);
712 let s = rt.to_irc_formatted();
713 assert!(s.contains('\x1F'));
714 }
715
716 #[test]
717 fn underline_renders_matrix_u_tag() {
718 let rt = RichText(vec![RichTextNode::Underline(vec![RichTextNode::Plain(
719 "hi".into(),
720 )])]);
721 assert!(rt.to_matrix_html().contains("<u>hi</u>"));
722 }
723
724 #[test]
725 fn spoiler_renders_discord_pipes() {
726 let rt = RichText(vec![RichTextNode::Spoiler(vec![RichTextNode::Plain(
727 "secret".into(),
728 )])]);
729 assert!(rt.to_discord_markdown().contains("||secret||"));
730 }
731
732 #[test]
733 fn spoiler_renders_matrix_span() {
734 let rt = RichText(vec![RichTextNode::Spoiler(vec![RichTextNode::Plain(
735 "secret".into(),
736 )])]);
737 assert!(
738 rt.to_matrix_html()
739 .contains("<span data-mx-spoiler>secret</span>")
740 );
741 }
742
743 #[test]
744 fn blockquote_renders_markdown() {
745 let rt = RichText(vec![RichTextNode::Blockquote(vec![RichTextNode::Plain(
746 "quoted text".into(),
747 )])]);
748 assert_eq!(rt.to_markdown(), "> quoted text");
749 }
750
751 #[test]
752 fn blockquote_renders_matrix_tag() {
753 let rt = RichText(vec![RichTextNode::Blockquote(vec![RichTextNode::Plain(
754 "quoted".into(),
755 )])]);
756 assert!(
757 rt.to_matrix_html()
758 .contains("<blockquote>quoted</blockquote>")
759 );
760 }
761
762 #[test]
763 fn heading_renders_markdown() {
764 let rt = RichText(vec![RichTextNode::Heading {
765 level: 2,
766 children: vec![RichTextNode::Plain("Title".into())],
767 }]);
768 assert_eq!(rt.to_markdown(), "## Title");
769 }
770
771 #[test]
772 fn heading_renders_matrix_html() {
773 let rt = RichText(vec![RichTextNode::Heading {
774 level: 3,
775 children: vec![RichTextNode::Plain("Title".into())],
776 }]);
777 assert!(rt.to_matrix_html().contains("<h3>Title</h3>"));
778 }
779
780 #[test]
781 fn heading_renders_irc_as_bold() {
782 let rt = RichText(vec![RichTextNode::Heading {
783 level: 1,
784 children: vec![RichTextNode::Plain("Title".into())],
785 }]);
786 let s = rt.to_irc_formatted();
787 assert!(s.contains('\x02'));
788 assert!(s.contains("Title"));
789 }
790
791 #[test]
792 fn from_markdown_parses_blockquote() {
793 let rt = RichText::from_markdown("> quoted text");
794 assert!(
795 rt.0.iter()
796 .any(|n| matches!(n, RichTextNode::Blockquote(_)))
797 );
798 }
799
800 #[test]
801 fn from_markdown_parses_heading() {
802 let rt = RichText::from_markdown("## Hello");
803 assert!(
804 rt.0.iter()
805 .any(|n| matches!(n, RichTextNode::Heading { level: 2, .. }))
806 );
807 }
808}