Skip to main content

chat_system/
rich_text.rs

1//! Rich text representation and format conversion.
2
3use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
4
5/// A node in a rich text tree.
6///
7/// Inspired by formatting features across Discord, Telegram, Matrix, Slack,
8/// and IRC — this enum covers the union of formatting primitives used by all
9/// major chat platforms.
10#[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 / hidden text (Discord `||text||`, Telegram `<tg-spoiler>`).
18    Spoiler(Vec<RichTextNode>),
19    Code(String),
20    CodeBlock {
21        language: Option<String>,
22        code: String,
23    },
24    /// Block quote (Markdown `>`, Discord `> text`, Telegram `<blockquote>`).
25    Blockquote(Vec<RichTextNode>),
26    /// Heading (levels 1–6).
27    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
44/// A rich text document as a sequence of nodes.
45pub 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                // CommonMark has no underline; use <u> HTML
89                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                // Discord-style spoiler tags
102                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                // IRC underline control code: \x1F
263                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                // IRC has no strikethrough; render plain
273                children.iter().map(|n| n.to_irc_formatted()).collect()
274            }
275            RichTextNode::Spoiler(children) => {
276                // IRC has no native spoiler; render as [spoiler] placeholder
277                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                // Render headings as bold on IRC
297                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                // WhatsApp has no underline; render plain
345                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                // WhatsApp has no spoiler; render plain
358                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                // WhatsApp has no heading; render as bold
372                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("&amp;"),
404            '<' => out.push_str("&lt;"),
405            '>' => out.push_str("&gt;"),
406            '"' => out.push_str("&quot;"),
407            c => out.push(c),
408        }
409    }
410    out
411}
412
413impl RichText {
414    /// Convert to plain text (strips all formatting).
415    pub fn to_plain_text(&self) -> String {
416        self.0.iter().map(|n| n.to_plain_text()).collect()
417    }
418
419    /// Convert to standard CommonMark markdown.
420    pub fn to_markdown(&self) -> String {
421        self.0.iter().map(|n| n.to_markdown()).collect()
422    }
423
424    /// Convert to Discord markdown.
425    ///
426    /// Discord extends CommonMark with `||spoiler||` and `__underline__`.
427    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                // For all other nodes, the standard markdown is correct for Discord.
443                other => other.to_markdown(),
444            }
445        }
446        self.0.iter().map(discord_node).collect()
447    }
448
449    /// Convert to Telegram HTML.
450    pub fn to_telegram_html(&self) -> String {
451        crate::markdown::markdown_to_telegram_html(self.to_markdown())
452    }
453
454    /// Convert to Slack mrkdwn.
455    pub fn to_slack_mrkdwn(&self) -> String {
456        crate::markdown::markdown_to_slack(self.to_markdown())
457    }
458
459    /// Convert to Matrix HTML.
460    pub fn to_matrix_html(&self) -> String {
461        self.0.iter().map(|n| n.to_matrix_html()).collect()
462    }
463
464    /// Convert to IRC formatted text (bold=`\x02`, italic=`\x1D`).
465    pub fn to_irc_formatted(&self) -> String {
466        self.0.iter().map(|n| n.to_irc_formatted()).collect()
467    }
468
469    /// Convert to WhatsApp formatted text.
470    pub fn to_whatsapp_formatted(&self) -> String {
471        self.0.iter().map(|n| n.to_whatsapp_formatted()).collect()
472    }
473
474    /// Create from plain text.
475    pub fn from_plain(text: impl AsRef<str>) -> Self {
476        Self(vec![RichTextNode::Plain(text.as_ref().to_string())])
477    }
478
479    /// Parse from Markdown using pulldown-cmark.
480    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                    // Push a sentinel with the heading level, then a child accumulator
500                    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}