Skip to main content

slack_blocks_render/
html.rs

1use html_escape::encode_text;
2use serde_json::Value;
3use slack_morphism::prelude::*;
4
5use crate::{
6    references::SlackReferences,
7    visitor::{
8        visit_slack_block_image_element, visit_slack_block_mark_down_text,
9        visit_slack_block_plain_text, visit_slack_context_block, visit_slack_divider_block,
10        visit_slack_header_block, visit_slack_image_block, visit_slack_markdown_block,
11        visit_slack_section_block, visit_slack_video_block, SlackRichTextBlock, Visitor,
12    },
13};
14
15/// Render Slack's mrkdwn-formatted text directly as HTML.
16/// Use this for raw Slack text (e.g., attachment text, plain text fallback)
17/// that uses Slack's mrkdwn syntax but is not structured as blocks.
18pub fn render_slack_mrkdwn_text_as_html(
19    text: &str,
20    slack_references: &SlackReferences,
21    default_style_class: &str,
22    highlight_style_class: &str,
23) -> String {
24    let renderer = HtmlRenderer::new(
25        slack_references.clone(),
26        default_style_class.to_string(),
27        highlight_style_class.to_string(),
28    );
29    render_slack_mrkdwn_as_html(text, &renderer)
30}
31
32pub fn render_blocks_as_html(
33    blocks: Vec<SlackBlock>,
34    slack_references: SlackReferences,
35    default_style_class: &str,
36    highlight_style_class: &str,
37) -> String {
38    let mut block_renderer = HtmlRenderer::new(
39        slack_references,
40        default_style_class.to_string(),
41        highlight_style_class.to_string(),
42    );
43    for block in blocks {
44        block_renderer.visit_slack_block(&block);
45    }
46    block_renderer.sub_texts.join("")
47}
48
49struct HtmlRenderer {
50    pub sub_texts: Vec<String>,
51    pub slack_references: SlackReferences,
52    pub default_style_class: String,
53    pub highlight_style_class: String,
54}
55
56impl HtmlRenderer {
57    pub fn new(
58        slack_references: SlackReferences,
59        default_style_class: String,
60        highlight_style_class: String,
61    ) -> Self {
62        HtmlRenderer {
63            sub_texts: vec![],
64            slack_references,
65            default_style_class,
66            highlight_style_class,
67        }
68    }
69}
70
71impl Visitor for HtmlRenderer {
72    fn visit_slack_section_block(&mut self, slack_section_block: &SlackSectionBlock) {
73        let mut section_renderer = HtmlRenderer::new(
74            self.slack_references.clone(),
75            self.default_style_class.clone(),
76            self.highlight_style_class.clone(),
77        );
78        visit_slack_section_block(&mut section_renderer, slack_section_block);
79        let content = section_renderer.sub_texts.join("");
80        if !content.is_empty() {
81            self.sub_texts.push(format!("<p>{content}</p>\n"));
82        }
83    }
84
85    fn visit_slack_block_plain_text(&mut self, slack_block_plain_text: &SlackBlockPlainText) {
86        self.sub_texts
87            .push(encode_text(&slack_block_plain_text.text).to_string());
88        visit_slack_block_plain_text(self, slack_block_plain_text);
89    }
90
91    fn visit_slack_header_block(&mut self, slack_header_block: &SlackHeaderBlock) {
92        let mut header_renderer = HtmlRenderer::new(
93            self.slack_references.clone(),
94            self.default_style_class.clone(),
95            self.highlight_style_class.clone(),
96        );
97        visit_slack_header_block(&mut header_renderer, slack_header_block);
98        self.sub_texts
99            .push(format!("<h2>{}</h2>\n", header_renderer.sub_texts.join("")));
100    }
101
102    fn visit_slack_divider_block(&mut self, slack_divider_block: &SlackDividerBlock) {
103        self.sub_texts.push("<hr />\n".to_string());
104        visit_slack_divider_block(self, slack_divider_block);
105    }
106
107    fn visit_slack_image_block(&mut self, slack_image_block: &SlackImageBlock) {
108        if let Some(image_url) = slack_image_block.image_url_or_file.image_url() {
109            self.sub_texts.push(format!(
110                "<p><img src=\"{image_url}\" alt=\"{}\" /></p>\n",
111                encode_text(&slack_image_block.alt_text)
112            ));
113        }
114        visit_slack_image_block(self, slack_image_block);
115    }
116
117    fn visit_slack_block_image_element(
118        &mut self,
119        slack_block_image_element: &SlackBlockImageElement,
120    ) {
121        if let Some(image_url) = slack_block_image_element.image_url_or_file.image_url() {
122            self.sub_texts.push(format!(
123                "<img src=\"{image_url}\" alt=\"{}\" />",
124                encode_text(&slack_block_image_element.alt_text)
125            ));
126        }
127        visit_slack_block_image_element(self, slack_block_image_element);
128    }
129
130    fn visit_slack_block_mark_down_text(
131        &mut self,
132        slack_block_mark_down_text: &SlackBlockMarkDownText,
133    ) {
134        self.sub_texts.push(render_slack_mrkdwn_as_html(
135            &slack_block_mark_down_text.text,
136            self,
137        ));
138        visit_slack_block_mark_down_text(self, slack_block_mark_down_text);
139    }
140
141    fn visit_slack_context_block(&mut self, slack_context_block: &SlackContextBlock) {
142        let mut section_renderer = HtmlRenderer::new(
143            self.slack_references.clone(),
144            self.default_style_class.clone(),
145            self.highlight_style_class.clone(),
146        );
147        visit_slack_context_block(&mut section_renderer, slack_context_block);
148        let content = section_renderer.sub_texts.join("");
149        if !content.is_empty() {
150            self.sub_texts.push(format!("<p>{content}</p>\n"));
151        }
152    }
153
154    fn visit_slack_rich_text_block(&mut self, slack_rich_text_block: &SlackRichTextBlock) {
155        self.sub_texts.push(render_rich_text_block_as_html(
156            slack_rich_text_block.json_value.clone(),
157            self,
158        ));
159    }
160
161    fn visit_slack_video_block(&mut self, slack_video_block: &SlackVideoBlock) {
162        let title: SlackBlockText = slack_video_block.title.clone().into();
163        let title = match title {
164            SlackBlockText::Plain(plain_text) => plain_text.text,
165            SlackBlockText::MarkDown(md_text) => md_text.text,
166        };
167        let escaped_title = encode_text(&title);
168        if let Some(ref title_url) = slack_video_block.title_url {
169            self.sub_texts.push(format!(
170                "<p><em><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{title_url}\">{escaped_title}</a></em></p>\n"
171            ));
172        } else {
173            self.sub_texts
174                .push(format!("<p><em>{escaped_title}</em></p>\n"));
175        }
176
177        if let Some(description) = slack_video_block.description.clone() {
178            let description: SlackBlockText = description.into();
179            let description = match description {
180                SlackBlockText::Plain(plain_text) => plain_text.text,
181                SlackBlockText::MarkDown(md_text) => md_text.text,
182            };
183            self.sub_texts
184                .push(format!("<p>{}</p>\n", encode_text(&description)));
185        }
186
187        self.sub_texts.push(format!(
188            "<p><img src=\"{}\" alt=\"{}\" /></p>\n",
189            slack_video_block.thumbnail_url,
190            encode_text(&slack_video_block.alt_text)
191        ));
192
193        visit_slack_video_block(self, slack_video_block);
194    }
195
196    fn visit_slack_markdown_block(&mut self, slack_markdown_block: &SlackMarkdownBlock) {
197        self.sub_texts.push(format!(
198            "<p>{}</p>\n",
199            encode_text(&slack_markdown_block.text)
200        ));
201        visit_slack_markdown_block(self, slack_markdown_block);
202    }
203}
204
205// --- Rich text rendering ---
206
207struct ListItem {
208    content: String,
209    indent: usize,
210    style: String,
211}
212
213fn render_rich_text_block_as_html(
214    json_value: serde_json::Value,
215    renderer: &HtmlRenderer,
216) -> String {
217    let Some(serde_json::Value::Array(elements)) = json_value.get("elements") else {
218        return String::new();
219    };
220
221    let mut result: Vec<String> = Vec::new();
222    let mut list_accumulator: Vec<ListItem> = Vec::new();
223
224    for element in elements {
225        let elem_type = element.get("type").and_then(|t| t.as_str());
226
227        if elem_type == Some("rich_text_list") {
228            if let (Some(serde_json::Value::String(style)), Some(serde_json::Value::Array(items))) =
229                (element.get("style"), element.get("elements"))
230            {
231                let indent: usize =
232                    element.get("indent").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
233                for item in items {
234                    if let Some(serde_json::Value::Array(inner)) = item.get("elements") {
235                        list_accumulator.push(ListItem {
236                            content: render_rich_text_section_elements(inner, renderer, true),
237                            indent,
238                            style: style.clone(),
239                        });
240                    }
241                }
242            }
243            continue;
244        }
245
246        // Non-list element: flush accumulated list items
247        if !list_accumulator.is_empty() {
248            result.push(build_nested_list_html(&list_accumulator));
249            list_accumulator.clear();
250        }
251
252        match (elem_type, element.get("elements")) {
253            (Some("rich_text_section"), Some(serde_json::Value::Array(elems))) => {
254                let content = render_rich_text_section_elements(elems, renderer, true);
255                if !content.is_empty() {
256                    result.push(format!("<p>{content}</p>\n"));
257                }
258            }
259            (Some("rich_text_preformatted"), Some(serde_json::Value::Array(elems))) => {
260                result.push(render_rich_text_preformatted_elements(elems, renderer));
261            }
262            (Some("rich_text_quote"), Some(serde_json::Value::Array(elems))) => {
263                result.push(render_rich_text_quote_elements(elems, renderer));
264            }
265            _ => {}
266        }
267    }
268
269    // Flush remaining list items
270    if !list_accumulator.is_empty() {
271        result.push(build_nested_list_html(&list_accumulator));
272    }
273
274    result.join("")
275}
276
277fn render_rich_text_section_elements(
278    elements: &[serde_json::Value],
279    renderer: &HtmlRenderer,
280    fix_newlines_in_text: bool,
281) -> String {
282    let parts: Vec<(String, Option<StyleSet>)> = elements
283        .iter()
284        .map(|e| render_rich_text_section_element(e, renderer))
285        .collect();
286
287    let result = join_html(parts);
288    if fix_newlines_in_text {
289        fix_newlines(result)
290    } else {
291        result
292    }
293}
294
295#[derive(Clone, PartialEq, Eq)]
296struct StyleSet {
297    bold: bool,
298    italic: bool,
299    strike: bool,
300    code: bool,
301}
302
303impl StyleSet {
304    fn from_style(style: Option<&Value>) -> Self {
305        StyleSet {
306            bold: is_styled(style, "bold"),
307            italic: is_styled(style, "italic"),
308            strike: is_styled(style, "strike"),
309            code: is_styled(style, "code"),
310        }
311    }
312
313    fn is_empty(&self) -> bool {
314        !self.bold && !self.italic && !self.strike && !self.code
315    }
316}
317
318fn wrap_with_styles(text: String, styles: &StyleSet) -> String {
319    let mut result = text;
320    if styles.bold {
321        result = format!("<strong>{result}</strong>");
322    }
323    if styles.italic {
324        result = format!("<em>{result}</em>");
325    }
326    if styles.strike {
327        result = format!("<del>{result}</del>");
328    }
329    if styles.code {
330        result = format!("<code>{result}</code>");
331    }
332    result
333}
334
335/// Merge consecutive elements with identical styles before wrapping.
336/// This produces `<strong>Hello World!</strong>` instead of
337/// `<strong>Hello</strong><strong> </strong><strong>World!</strong>`.
338fn join_html(parts: Vec<(String, Option<StyleSet>)>) -> String {
339    if parts.is_empty() {
340        return String::new();
341    }
342
343    let mut merged: Vec<(String, Option<StyleSet>)> = Vec::new();
344    for (content, styles) in parts {
345        if let Some(last) = merged.last_mut() {
346            if last.1 == styles && styles.is_some() {
347                last.0.push_str(&content);
348                continue;
349            }
350        }
351        merged.push((content, styles));
352    }
353
354    merged
355        .into_iter()
356        .map(|(content, styles)| match styles {
357            Some(s) if !s.is_empty() => wrap_with_styles(content, &s),
358            _ => content,
359        })
360        .collect::<Vec<String>>()
361        .join("")
362}
363
364fn render_rich_text_section_element(
365    element: &serde_json::Value,
366    renderer: &HtmlRenderer,
367) -> (String, Option<StyleSet>) {
368    match element.get("type").map(|t| t.as_str()) {
369        Some(Some("text")) => {
370            let Some(serde_json::Value::String(text)) = element.get("text") else {
371                return (String::new(), None);
372            };
373            let style = element.get("style");
374            let styles = StyleSet::from_style(style);
375            (encode_text(text).to_string(), Some(styles))
376        }
377        Some(Some("channel")) => {
378            let Some(serde_json::Value::String(channel_id)) = element.get("channel_id") else {
379                return (String::new(), None);
380            };
381            let channel_rendered = if let Some(Some(channel_name)) = renderer
382                .slack_references
383                .channels
384                .get(&SlackChannelId(channel_id.clone()))
385            {
386                channel_name
387            } else {
388                channel_id
389            };
390            let style = element.get("style");
391            let styles = StyleSet::from_style(style);
392            (format!("#{}", encode_text(channel_rendered)), Some(styles))
393        }
394        Some(Some("user")) => {
395            let Some(serde_json::Value::String(user_id)) = element.get("user_id") else {
396                return (String::new(), None);
397            };
398            let user_rendered = if let Some(Some(user_name)) = renderer
399                .slack_references
400                .users
401                .get(&SlackUserId(user_id.clone()))
402            {
403                user_name
404            } else {
405                user_id
406            };
407            let style_class = if renderer
408                .slack_references
409                .user_id_to_highlight
410                .as_ref()
411                .is_some_and(|id| id.0 == *user_id)
412            {
413                &renderer.highlight_style_class
414            } else {
415                &renderer.default_style_class
416            };
417            let style = element.get("style");
418            let styles = StyleSet::from_style(style);
419            // Mention is a raw HTML fragment — not mergeable with adjacent styled text
420            let html = format!(
421                "<span class=\"{style_class}\">@{}</span>",
422                encode_text(user_rendered)
423            );
424            (wrap_with_styles(html, &styles), None)
425        }
426        Some(Some("usergroup")) => {
427            let Some(serde_json::Value::String(usergroup_id)) = element.get("usergroup_id") else {
428                return (String::new(), None);
429            };
430            let usergroup_rendered = if let Some(Some(usergroup_name)) = renderer
431                .slack_references
432                .usergroups
433                .get(&SlackUserGroupId(usergroup_id.clone()))
434            {
435                usergroup_name
436            } else {
437                usergroup_id
438            };
439            let style_class = if renderer
440                .slack_references
441                .usergroup_ids_to_highlight
442                .as_ref()
443                .is_some_and(|ids| ids.iter().any(|id| id.0 == *usergroup_id))
444            {
445                &renderer.highlight_style_class
446            } else {
447                &renderer.default_style_class
448            };
449            let style = element.get("style");
450            let styles = StyleSet::from_style(style);
451            let html = format!(
452                "<span class=\"{style_class}\">@{}</span>",
453                encode_text(usergroup_rendered)
454            );
455            (wrap_with_styles(html, &styles), None)
456        }
457        Some(Some("emoji")) => {
458            let Some(serde_json::Value::String(name)) = element.get("name") else {
459                return (String::new(), None);
460            };
461            let style = element.get("style");
462            let styles = StyleSet::from_style(style);
463            let html = render_emoji(
464                &SlackEmojiName(name.to_string()),
465                &renderer.slack_references,
466            );
467            (wrap_with_styles(html, &styles), None)
468        }
469        // `attachment_mention` (a mention of a file or a third-party app entity, ie. a Google
470        // Drive document) and `message_mention` (a mention of another Slack message) both carry
471        // a `url` and an optional display `text`, so they render as links.
472        Some(Some("link")) | Some(Some("attachment_mention")) | Some(Some("message_mention")) => {
473            let Some(serde_json::Value::String(url)) = element.get("url") else {
474                return (String::new(), None);
475            };
476            let text = element
477                .get("text")
478                .and_then(|t| t.as_str())
479                .unwrap_or(url.as_str());
480            let style = element.get("style");
481            let styles = StyleSet::from_style(style);
482            let html = format!(
483                "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{url}\">{}</a>",
484                encode_text(text)
485            );
486            (wrap_with_styles(html, &styles), None)
487        }
488        _ => (String::new(), None),
489    }
490}
491
492fn render_emoji(emoji_name: &SlackEmojiName, slack_references: &SlackReferences) -> String {
493    if let Some(Some(emoji)) = slack_references.emojis.get(emoji_name) {
494        match emoji {
495            SlackEmojiRef::Alias(alias) => {
496                return render_emoji(alias, slack_references);
497            }
498            SlackEmojiRef::Url(url) => {
499                return format!(
500                    "<img class=\"slack-emoji\" src=\"{url}\" alt=\":{}:\" />",
501                    encode_text(&emoji_name.0)
502                );
503            }
504        }
505    }
506    let name = &emoji_name.0;
507
508    let splitted = name.split("::skin-tone-").collect::<Vec<&str>>();
509    let Some(first) = splitted.first() else {
510        return format!(":{}:", encode_text(name));
511    };
512    let Some(emoji) = emojis::get_by_shortcode(first) else {
513        return format!(":{}:", encode_text(name));
514    };
515    let Some(skin_tone) = splitted.get(1).and_then(|s| s.parse::<usize>().ok()) else {
516        return emoji.to_string();
517    };
518    let Some(mut skin_tones) = emoji.skin_tones() else {
519        return emoji.to_string();
520    };
521    let Some(skinned_emoji) = skin_tones.nth(skin_tone - 1) else {
522        return emoji.to_string();
523    };
524    skinned_emoji.to_string()
525}
526
527fn render_rich_text_preformatted_elements(
528    elements: &[serde_json::Value],
529    renderer: &HtmlRenderer,
530) -> String {
531    let content = render_rich_text_section_elements(elements, renderer, false);
532    format!("<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>{content}\n</code></pre>\n")
533}
534
535fn render_rich_text_quote_elements(
536    elements: &[serde_json::Value],
537    renderer: &HtmlRenderer,
538) -> String {
539    let content = render_rich_text_section_elements(elements, renderer, true);
540    format!("<blockquote>\n<p>{content}</p>\n</blockquote>\n")
541}
542
543// --- Nested list construction ---
544
545fn build_nested_list_html(items: &[ListItem]) -> String {
546    if items.is_empty() {
547        return String::new();
548    }
549    build_list_at_indent(items, 0).0
550}
551
552/// Returns (html_string, number_of_items_consumed)
553fn build_list_at_indent(items: &[ListItem], base_indent: usize) -> (String, usize) {
554    if items.is_empty() {
555        return (String::new(), 0);
556    }
557
558    let tag = if items[0].style == "ordered" {
559        "ol"
560    } else {
561        "ul"
562    };
563    let mut html = format!("<{tag}>\n");
564    let mut i = 0;
565
566    while i < items.len() && items[i].indent >= base_indent {
567        if items[i].indent > base_indent {
568            // Sub-list: attach to the previous <li> (which was left unclosed)
569            let (sub_html, consumed) = build_list_at_indent(&items[i..], items[i].indent);
570            html.push_str(&sub_html);
571            html.push_str("</li>\n");
572            i += consumed;
573        } else {
574            // Same level item
575            html.push_str(&format!("<li>{}", items[i].content));
576            // Check if next item is a sub-list
577            if i + 1 < items.len() && items[i + 1].indent > base_indent {
578                html.push('\n');
579                // Don't close <li> — sub-list will be attached
580            } else {
581                html.push_str("</li>\n");
582            }
583            i += 1;
584        }
585    }
586
587    html.push_str(&format!("</{tag}>\n"));
588    (html, i)
589}
590
591// --- Helpers ---
592
593fn is_styled(style: Option<&serde_json::Value>, style_name: &str) -> bool {
594    style
595        .and_then(|s| s.get(style_name).map(|b| b.as_bool()))
596        .flatten()
597        .unwrap_or_default()
598}
599
600/// Render Slack's mrkdwn format as HTML.
601/// Handles: *bold*, _italic_, `code`, ~strike~, <url|label> links, :emoji:, \n line breaks.
602fn render_slack_mrkdwn_as_html(text: &str, renderer: &HtmlRenderer) -> String {
603    let mut output = String::new();
604    let chars: Vec<char> = text.chars().collect();
605    let len = chars.len();
606    let mut i = 0;
607    let mut in_bold = false;
608    let mut in_italic = false;
609    let mut in_strike = false;
610    let mut in_code = false;
611
612    while i < len {
613        let ch = chars[i];
614
615        // Backtick code (highest priority — no formatting inside)
616        if ch == '`' {
617            if in_code {
618                output.push_str("</code>");
619                in_code = false;
620            } else {
621                output.push_str("<code>");
622                in_code = true;
623            }
624            i += 1;
625            continue;
626        }
627
628        // Inside code: escape everything, no formatting
629        if in_code {
630            output.push_str(&encode_char(ch));
631            i += 1;
632            continue;
633        }
634
635        // Slack link: <url|label> or <url>
636        if ch == '<' {
637            if let Some(end) = chars[i..].iter().position(|&c| c == '>') {
638                let inner: String = chars[i + 1..i + end].iter().collect();
639                // Check for special Slack references: <@U123>, <!subteam^S123>
640                if inner.starts_with('@') || inner.starts_with('!') || inner.starts_with('#') {
641                    // User/channel/subteam mention in mrkdwn — render as escaped text
642                    output.push_str(&encode_text(&inner));
643                } else if let Some(pipe_pos) = inner.find('|') {
644                    let url = &inner[..pipe_pos];
645                    let label = &inner[pipe_pos + 1..];
646                    output.push_str(&format!(
647                        "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{}\">{}</a>",
648                        url,
649                        encode_text(label)
650                    ));
651                } else {
652                    // URL without label
653                    output.push_str(&format!(
654                        "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{inner}\">{}</a>",
655                        encode_text(&inner)
656                    ));
657                }
658                i += end + 1;
659                continue;
660            }
661            // Not a valid Slack link, escape the <
662            output.push_str("&lt;");
663            i += 1;
664            continue;
665        }
666
667        // Emoji shortcode: :name: (checked before _ to avoid italic inside emoji names)
668        if ch == ':' {
669            if let Some(end) = chars[i + 1..].iter().position(|&c| c == ':') {
670                let name: String = chars[i + 1..i + 1 + end].iter().collect();
671                // Valid emoji names: non-empty, no spaces, may contain letters/digits/underscores/hyphens
672                if !name.is_empty() && !name.contains(' ') {
673                    let emoji_html =
674                        render_emoji(&SlackEmojiName(name.clone()), &renderer.slack_references);
675                    // If render_emoji returned :name: unchanged, it wasn't resolved
676                    // but it's still a valid emoji shortcode — preserve it as-is
677                    output.push_str(&emoji_html);
678                    i += end + 2; // skip past closing :
679                    continue;
680                }
681            }
682            // Not a valid emoji shortcode, output as literal
683            output.push(':');
684            i += 1;
685            continue;
686        }
687
688        // Bold: *text*
689        if ch == '*' {
690            if in_bold {
691                output.push_str("</strong>");
692                in_bold = false;
693            } else {
694                output.push_str("<strong>");
695                in_bold = true;
696            }
697            i += 1;
698            continue;
699        }
700
701        // Italic: _text_
702        if ch == '_' {
703            if in_italic {
704                output.push_str("</em>");
705                in_italic = false;
706            } else {
707                output.push_str("<em>");
708                in_italic = true;
709            }
710            i += 1;
711            continue;
712        }
713
714        // Strikethrough: ~text~
715        if ch == '~' {
716            if in_strike {
717                output.push_str("</del>");
718                in_strike = false;
719            } else {
720                output.push_str("<del>");
721                in_strike = true;
722            }
723            i += 1;
724            continue;
725        }
726
727        // Newline
728        if ch == '\n' {
729            output.push_str("<br />\n");
730            i += 1;
731            continue;
732        }
733
734        // Regular character — HTML escape
735        output.push_str(&encode_char(ch));
736        i += 1;
737    }
738
739    output
740}
741
742fn encode_char(ch: char) -> String {
743    match ch {
744        '&' => "&amp;".to_string(),
745        '<' => "&lt;".to_string(),
746        '>' => "&gt;".to_string(),
747        '"' => "&quot;".to_string(),
748        _ => ch.to_string(),
749    }
750}
751
752fn fix_newlines(text: String) -> String {
753    let result = text.replace('\t', "\u{2003}");
754    let mut output = String::new();
755    let mut chars = result.chars().peekable();
756    while let Some(ch) = chars.next() {
757        if ch == '\n' {
758            output.push_str("<br />\n");
759            while chars.peek() == Some(&' ') {
760                chars.next();
761                output.push_str("&nbsp;");
762            }
763        } else {
764            output.push(ch);
765        }
766    }
767    output.trim_end_matches("<br />\n").to_string()
768}
769
770#[cfg(test)]
771mod tests {
772    use std::collections::HashMap;
773
774    use url::Url;
775
776    use super::*;
777    use crate::test_utils::rich_text_block;
778
779    fn render(blocks: Vec<SlackBlock>, refs: SlackReferences) -> String {
780        render_blocks_as_html(blocks, refs, "text-primary", "text-accent")
781    }
782
783    #[test]
784    fn test_empty_input() {
785        assert_eq!(render(vec![], SlackReferences::default()), "");
786    }
787
788    #[test]
789    fn test_with_image() {
790        let blocks = vec![
791            SlackBlock::Image(SlackImageBlock::new(
792                SlackImageUrlOrFile::ImageUrl {
793                    image_url: Url::parse("https://example.com/image.png").unwrap(),
794                },
795                "Image".to_string(),
796            )),
797            SlackBlock::Image(SlackImageBlock::new(
798                SlackImageUrlOrFile::ImageUrl {
799                    image_url: Url::parse("https://example.com/image2.png").unwrap(),
800                },
801                "Image2".to_string(),
802            )),
803        ];
804        assert_eq!(
805            render(blocks, SlackReferences::default()),
806            "<p><img src=\"https://example.com/image.png\" alt=\"Image\" /></p>\n\
807             <p><img src=\"https://example.com/image2.png\" alt=\"Image2\" /></p>\n"
808        );
809    }
810
811    #[test]
812    fn test_with_divider() {
813        let blocks = vec![
814            SlackBlock::Divider(SlackDividerBlock::new()),
815            SlackBlock::Divider(SlackDividerBlock::new()),
816        ];
817        assert_eq!(
818            render(blocks, SlackReferences::default()),
819            "<hr />\n<hr />\n"
820        );
821    }
822
823    #[test]
824    fn test_header() {
825        let blocks = vec![SlackBlock::Header(SlackHeaderBlock::new("Text".into()))];
826        assert_eq!(
827            render(blocks, SlackReferences::default()),
828            "<h2>Text</h2>\n"
829        );
830    }
831
832    #[test]
833    fn test_with_input() {
834        let blocks = vec![SlackBlock::Input(SlackInputBlock::new(
835            "label".into(),
836            SlackInputBlockElement::PlainTextInput(
837                SlackBlockPlainTextInputElement::new().with_action_id("id".into()),
838            ),
839        ))];
840        assert_eq!(render(blocks, SlackReferences::default()), "");
841    }
842
843    #[test]
844    fn test_with_action() {
845        let blocks = vec![SlackBlock::Actions(SlackActionsBlock::new(vec![]))];
846        assert_eq!(render(blocks, SlackReferences::default()), "");
847    }
848
849    #[test]
850    fn test_with_file() {
851        let blocks = vec![SlackBlock::File(SlackFileBlock::new("external_id".into()))];
852        assert_eq!(render(blocks, SlackReferences::default()), "");
853    }
854
855    #[test]
856    fn test_with_event() {
857        let blocks = vec![SlackBlock::Event(serde_json::json!({}))];
858        assert_eq!(render(blocks, SlackReferences::default()), "");
859    }
860
861    #[test]
862    fn test_with_unrendered_blocks() {
863        // Plan and DataTable blocks, and block types slack-morphism does not model
864        let blocks: Vec<SlackBlock> = serde_json::from_value(serde_json::json!([
865            { "type": "plan", "title": "Plan", "tasks": [] },
866            { "type": "data_table", "caption": "Caption", "rows": [] },
867            { "type": "some_future_block", "text": "Text" }
868        ]))
869        .unwrap();
870        assert!(matches!(blocks[0], SlackBlock::Plan(_)));
871        assert!(matches!(blocks[2], SlackBlock::Unknown(_)));
872        assert_eq!(render(blocks, SlackReferences::default()), "".to_string());
873    }
874
875    #[test]
876    fn test_with_container() {
877        let blocks = vec![SlackBlock::Container(SlackContainerBlock::new(vec![
878            SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
879                SlackBlockPlainText::new("Text".to_string()),
880            ))),
881        ]))];
882        assert_eq!(render(blocks, SlackReferences::default()), "<p>Text</p>\n");
883    }
884
885    #[test]
886    fn test_with_video() {
887        let blocks = vec![SlackBlock::Video(
888            SlackVideoBlock::new(
889                "alt text".into(),
890                "Video title".into(),
891                "https://example.com/thumbnail.jpg".parse().unwrap(),
892                "https://example.com/video_embed.avi".parse().unwrap(),
893            )
894            .with_description("Video description".into())
895            .with_title_url("https://example.com/video".parse().unwrap()),
896        )];
897        assert_eq!(
898            render(blocks, SlackReferences::default()),
899            "<p><em><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/video\">Video title</a></em></p>\n\
900             <p>Video description</p>\n\
901             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
902        );
903    }
904
905    #[test]
906    fn test_with_video_minimal() {
907        let blocks = vec![SlackBlock::Video(SlackVideoBlock::new(
908            "alt text".into(),
909            "Video title".into(),
910            "https://example.com/thumbnail.jpg".parse().unwrap(),
911            "https://example.com/video_embed.avi".parse().unwrap(),
912        ))];
913        assert_eq!(
914            render(blocks, SlackReferences::default()),
915            "<p><em>Video title</em></p>\n\
916             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
917        );
918    }
919
920    mod section {
921        use super::*;
922
923        #[test]
924        fn test_with_plain_text() {
925            let blocks = vec![
926                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
927                    SlackBlockPlainText::new("Text".to_string()),
928                ))),
929                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
930                    SlackBlockPlainText::new("Text2".to_string()),
931                ))),
932            ];
933            assert_eq!(
934                render(blocks, SlackReferences::default()),
935                "<p>Text</p>\n<p>Text2</p>\n"
936            );
937        }
938
939        #[test]
940        fn test_with_fields() {
941            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_fields(
942                vec![
943                    SlackBlockText::Plain(SlackBlockPlainText::new("Text11".to_string())),
944                    SlackBlockText::Plain(SlackBlockPlainText::new("Text12".to_string())),
945                ],
946            ))];
947            assert_eq!(
948                render(blocks, SlackReferences::default()),
949                "<p>Text11Text12</p>\n"
950            );
951        }
952    }
953
954    mod context {
955        use super::*;
956
957        #[test]
958        fn test_with_image() {
959            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
960                SlackContextBlockElement::Image(SlackBlockImageElement::new(
961                    SlackImageUrlOrFile::ImageUrl {
962                        image_url: Url::parse("https://example.com/image.png").unwrap(),
963                    },
964                    "Image".to_string(),
965                )),
966            ]))];
967            assert_eq!(
968                render(blocks, SlackReferences::default()),
969                "<p><img src=\"https://example.com/image.png\" alt=\"Image\" /></p>\n"
970            );
971        }
972
973        #[test]
974        fn test_with_plain_text() {
975            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
976                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text".to_string())),
977                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text2".to_string())),
978            ]))];
979            assert_eq!(
980                render(blocks, SlackReferences::default()),
981                "<p>TextText2</p>\n"
982            );
983        }
984    }
985
986    mod rich_text {
987        use super::*;
988
989        #[test]
990        fn test_with_empty_json() {
991            let blocks = vec![rich_text_block(serde_json::json!({}))];
992            assert_eq!(render(blocks, SlackReferences::default()), "");
993        }
994
995        mod rich_text_section {
996            use super::*;
997
998            #[test]
999            fn test_with_text() {
1000                let blocks = vec![rich_text_block(serde_json::json!({
1001                    "type": "rich_text",
1002                    "elements": [
1003                        {
1004                            "type": "rich_text_section",
1005                            "elements": [
1006                                { "type": "text", "text": "Text111" },
1007                                { "type": "text", "text": "Text112" }
1008                            ]
1009                        },
1010                        {
1011                            "type": "rich_text_section",
1012                            "elements": [
1013                                { "type": "text", "text": "Text211" },
1014                                { "type": "text", "text": "Text212" }
1015                            ]
1016                        }
1017                    ]
1018                }))];
1019                assert_eq!(
1020                    render(blocks, SlackReferences::default()),
1021                    "<p>Text111Text112</p>\n<p>Text211Text212</p>\n"
1022                );
1023            }
1024
1025            #[test]
1026            fn test_with_text_with_newline() {
1027                let blocks = vec![rich_text_block(serde_json::json!({
1028                    "type": "rich_text",
1029                    "elements": [
1030                        {
1031                            "type": "rich_text_section",
1032                            "elements": [
1033                                { "type": "text", "text": "Text1\nText2\n" }
1034                            ]
1035                        }
1036                    ]
1037                }))];
1038                assert_eq!(
1039                    render(blocks, SlackReferences::default()),
1040                    "<p>Text1<br />\nText2</p>\n"
1041                );
1042            }
1043
1044            #[test]
1045            fn test_with_text_with_only_newline() {
1046                let blocks = vec![rich_text_block(serde_json::json!({
1047                    "type": "rich_text",
1048                    "elements": [
1049                        {
1050                            "type": "rich_text_section",
1051                            "elements": [
1052                                { "type": "text", "text": "\n" }
1053                            ]
1054                        }
1055                    ]
1056                }))];
1057                assert_eq!(render(blocks, SlackReferences::default()), "");
1058            }
1059
1060            #[test]
1061            fn test_with_bold_text() {
1062                let blocks = vec![rich_text_block(serde_json::json!({
1063                    "type": "rich_text",
1064                    "elements": [
1065                        {
1066                            "type": "rich_text_section",
1067                            "elements": [
1068                                { "type": "text", "text": "Text", "style": { "bold": true } }
1069                            ]
1070                        }
1071                    ]
1072                }))];
1073                assert_eq!(
1074                    render(blocks, SlackReferences::default()),
1075                    "<p><strong>Text</strong></p>\n"
1076                );
1077            }
1078
1079            #[test]
1080            fn test_with_consecutive_bold_text() {
1081                let blocks = vec![rich_text_block(serde_json::json!({
1082                    "type": "rich_text",
1083                    "elements": [
1084                        {
1085                            "type": "rich_text_section",
1086                            "elements": [
1087                                { "type": "text", "text": "Hello", "style": { "bold": true } },
1088                                { "type": "text", "text": " ", "style": { "bold": true } },
1089                                { "type": "text", "text": "World!", "style": { "bold": true } }
1090                            ]
1091                        }
1092                    ]
1093                }))];
1094                assert_eq!(
1095                    render(blocks, SlackReferences::default()),
1096                    "<p><strong>Hello World!</strong></p>\n"
1097                );
1098            }
1099
1100            #[test]
1101            fn test_with_italic_text() {
1102                let blocks = vec![rich_text_block(serde_json::json!({
1103                    "type": "rich_text",
1104                    "elements": [
1105                        {
1106                            "type": "rich_text_section",
1107                            "elements": [
1108                                { "type": "text", "text": "Text", "style": { "italic": true } }
1109                            ]
1110                        }
1111                    ]
1112                }))];
1113                assert_eq!(
1114                    render(blocks, SlackReferences::default()),
1115                    "<p><em>Text</em></p>\n"
1116                );
1117            }
1118
1119            #[test]
1120            fn test_with_strike_text() {
1121                let blocks = vec![rich_text_block(serde_json::json!({
1122                    "type": "rich_text",
1123                    "elements": [
1124                        {
1125                            "type": "rich_text_section",
1126                            "elements": [
1127                                { "type": "text", "text": "Text", "style": { "strike": true } }
1128                            ]
1129                        }
1130                    ]
1131                }))];
1132                assert_eq!(
1133                    render(blocks, SlackReferences::default()),
1134                    "<p><del>Text</del></p>\n"
1135                );
1136            }
1137
1138            #[test]
1139            fn test_with_code_text() {
1140                let blocks = vec![rich_text_block(serde_json::json!({
1141                    "type": "rich_text",
1142                    "elements": [
1143                        {
1144                            "type": "rich_text_section",
1145                            "elements": [
1146                                { "type": "text", "text": "Text", "style": { "code": true } }
1147                            ]
1148                        }
1149                    ]
1150                }))];
1151                assert_eq!(
1152                    render(blocks, SlackReferences::default()),
1153                    "<p><code>Text</code></p>\n"
1154                );
1155            }
1156
1157            #[test]
1158            fn test_with_all_styles() {
1159                let blocks = vec![rich_text_block(serde_json::json!({
1160                    "type": "rich_text",
1161                    "elements": [
1162                        {
1163                            "type": "rich_text_section",
1164                            "elements": [
1165                                {
1166                                    "type": "text",
1167                                    "text": "Text",
1168                                    "style": { "bold": true, "italic": true, "strike": true, "code": true }
1169                                }
1170                            ]
1171                        }
1172                    ]
1173                }))];
1174                assert_eq!(
1175                    render(blocks, SlackReferences::default()),
1176                    "<p><code><del><em><strong>Text</strong></em></del></code></p>\n"
1177                );
1178            }
1179
1180            #[test]
1181            fn test_with_link() {
1182                let blocks = vec![rich_text_block(serde_json::json!({
1183                    "type": "rich_text",
1184                    "elements": [
1185                        {
1186                            "type": "rich_text_section",
1187                            "elements": [
1188                                { "type": "link", "url": "https://example.com", "text": "Example" }
1189                            ]
1190                        }
1191                    ]
1192                }))];
1193                assert_eq!(
1194                    render(blocks, SlackReferences::default()),
1195                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">Example</a></p>\n"
1196                );
1197            }
1198
1199            #[test]
1200            fn test_with_attachment_mention() {
1201                let blocks = vec![rich_text_block(serde_json::json!({
1202                    "type": "rich_text",
1203                    "elements": [
1204                        {
1205                            "type": "rich_text_section",
1206                            "elements": [
1207                                {
1208                                    "type": "attachment_mention",
1209                                    "url": "https://docs.google.com/document/d/1abc/edit?tab=t.0",
1210                                    "text": "Project plan",
1211                                    "app_id": "A6NL8MJ6Q",
1212                                    "product_name": "Google Docs"
1213                                }
1214                            ]
1215                        }
1216                    ]
1217                }))];
1218                assert_eq!(
1219                    render(blocks, SlackReferences::default()),
1220                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://docs.google.com/document/d/1abc/edit?tab=t.0\">Project plan</a></p>\n"
1221                );
1222            }
1223
1224            #[test]
1225            fn test_with_attachment_mention_without_text() {
1226                let blocks = vec![rich_text_block(serde_json::json!({
1227                    "type": "rich_text",
1228                    "elements": [
1229                        {
1230                            "type": "rich_text_section",
1231                            "elements": [
1232                                { "type": "attachment_mention", "url": "https://example.com/doc" }
1233                            ]
1234                        }
1235                    ]
1236                }))];
1237                assert_eq!(
1238                    render(blocks, SlackReferences::default()),
1239                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/doc\">https://example.com/doc</a></p>\n"
1240                );
1241            }
1242
1243            #[test]
1244            fn test_with_message_mention() {
1245                let blocks = vec![rich_text_block(serde_json::json!({
1246                    "type": "rich_text",
1247                    "elements": [
1248                        {
1249                            "type": "rich_text_section",
1250                            "elements": [
1251                                {
1252                                    "type": "message_mention",
1253                                    "url": "https://slack.com/archives/C123/p1788850799491249",
1254                                    "text": "a previous message",
1255                                    "channel_id": "C123"
1256                                }
1257                            ]
1258                        }
1259                    ]
1260                }))];
1261                assert_eq!(
1262                    render(blocks, SlackReferences::default()),
1263                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://slack.com/archives/C123/p1788850799491249\">a previous message</a></p>\n"
1264                );
1265            }
1266
1267            #[test]
1268            fn test_with_user_mention() {
1269                let refs = SlackReferences {
1270                    users: HashMap::from([(
1271                        SlackUserId("U123".to_string()),
1272                        Some("john.doe".to_string()),
1273                    )]),
1274                    ..SlackReferences::default()
1275                };
1276                let blocks = vec![rich_text_block(serde_json::json!({
1277                    "type": "rich_text",
1278                    "elements": [
1279                        {
1280                            "type": "rich_text_section",
1281                            "elements": [
1282                                { "type": "user", "user_id": "U123" }
1283                            ]
1284                        }
1285                    ]
1286                }))];
1287                assert_eq!(
1288                    render(blocks, refs),
1289                    "<p><span class=\"text-primary\">@john.doe</span></p>\n"
1290                );
1291            }
1292
1293            #[test]
1294            fn test_with_highlighted_user_mention() {
1295                let refs = SlackReferences {
1296                    users: HashMap::from([(
1297                        SlackUserId("U123".to_string()),
1298                        Some("john.doe".to_string()),
1299                    )]),
1300                    user_id_to_highlight: Some(SlackUserId("U123".to_string())),
1301                    ..SlackReferences::default()
1302                };
1303                let blocks = vec![rich_text_block(serde_json::json!({
1304                    "type": "rich_text",
1305                    "elements": [
1306                        {
1307                            "type": "rich_text_section",
1308                            "elements": [
1309                                { "type": "user", "user_id": "U123" }
1310                            ]
1311                        }
1312                    ]
1313                }))];
1314                assert_eq!(
1315                    render(blocks, refs),
1316                    "<p><span class=\"text-accent\">@john.doe</span></p>\n"
1317                );
1318            }
1319
1320            #[test]
1321            fn test_with_usergroup_mention() {
1322                let refs = SlackReferences {
1323                    usergroups: HashMap::from([(
1324                        SlackUserGroupId("G123".to_string()),
1325                        Some("team-eng".to_string()),
1326                    )]),
1327                    ..SlackReferences::default()
1328                };
1329                let blocks = vec![rich_text_block(serde_json::json!({
1330                    "type": "rich_text",
1331                    "elements": [
1332                        {
1333                            "type": "rich_text_section",
1334                            "elements": [
1335                                { "type": "usergroup", "usergroup_id": "G123" }
1336                            ]
1337                        }
1338                    ]
1339                }))];
1340                assert_eq!(
1341                    render(blocks, refs),
1342                    "<p><span class=\"text-primary\">@team-eng</span></p>\n"
1343                );
1344            }
1345
1346            #[test]
1347            fn test_with_highlighted_usergroup_mention() {
1348                let refs = SlackReferences {
1349                    usergroups: HashMap::from([(
1350                        SlackUserGroupId("G123".to_string()),
1351                        Some("team-eng".to_string()),
1352                    )]),
1353                    usergroup_ids_to_highlight: Some(vec![SlackUserGroupId("G123".to_string())]),
1354                    ..SlackReferences::default()
1355                };
1356                let blocks = vec![rich_text_block(serde_json::json!({
1357                    "type": "rich_text",
1358                    "elements": [
1359                        {
1360                            "type": "rich_text_section",
1361                            "elements": [
1362                                { "type": "usergroup", "usergroup_id": "G123" }
1363                            ]
1364                        }
1365                    ]
1366                }))];
1367                assert_eq!(
1368                    render(blocks, refs),
1369                    "<p><span class=\"text-accent\">@team-eng</span></p>\n"
1370                );
1371            }
1372
1373            #[test]
1374            fn test_with_channel_ref() {
1375                let refs = SlackReferences {
1376                    channels: HashMap::from([(
1377                        SlackChannelId("C123".to_string()),
1378                        Some("general".to_string()),
1379                    )]),
1380                    ..SlackReferences::default()
1381                };
1382                let blocks = vec![rich_text_block(serde_json::json!({
1383                    "type": "rich_text",
1384                    "elements": [
1385                        {
1386                            "type": "rich_text_section",
1387                            "elements": [
1388                                { "type": "channel", "channel_id": "C123" }
1389                            ]
1390                        }
1391                    ]
1392                }))];
1393                assert_eq!(render(blocks, refs), "<p>#general</p>\n");
1394            }
1395
1396            #[test]
1397            fn test_with_unicode_emoji() {
1398                let blocks = vec![rich_text_block(serde_json::json!({
1399                    "type": "rich_text",
1400                    "elements": [
1401                        {
1402                            "type": "rich_text_section",
1403                            "elements": [
1404                                { "type": "emoji", "name": "wave" }
1405                            ]
1406                        }
1407                    ]
1408                }))];
1409                assert_eq!(
1410                    render(blocks, SlackReferences::default()),
1411                    "<p>\u{1F44B}</p>\n"
1412                );
1413            }
1414
1415            #[test]
1416            fn test_with_custom_emoji() {
1417                let refs = SlackReferences {
1418                    emojis: HashMap::from([(
1419                        SlackEmojiName("custom".to_string()),
1420                        Some(SlackEmojiRef::Url(
1421                            Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1422                        )),
1423                    )]),
1424                    ..SlackReferences::default()
1425                };
1426                let blocks = vec![rich_text_block(serde_json::json!({
1427                    "type": "rich_text",
1428                    "elements": [
1429                        {
1430                            "type": "rich_text_section",
1431                            "elements": [
1432                                { "type": "emoji", "name": "custom" }
1433                            ]
1434                        }
1435                    ]
1436                }))];
1437                assert_eq!(
1438                    render(blocks, refs),
1439                    "<p><img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1440                );
1441            }
1442        }
1443
1444        mod rich_text_section_with_inline_indentation {
1445            use super::*;
1446
1447            #[test]
1448            fn test_with_tab_indentation() {
1449                let blocks = vec![rich_text_block(serde_json::json!({
1450                    "type": "rich_text",
1451                    "elements": [
1452                        {
1453                            "type": "rich_text_section",
1454                            "elements": [
1455                                { "type": "text", "text": "Title", "style": { "bold": true } },
1456                                { "type": "text", "text": "\n• item1\n\t• sub-item1\n• item2" }
1457                            ]
1458                        }
1459                    ]
1460                }))];
1461                let result = render(blocks, SlackReferences::default());
1462                assert!(
1463                    result.contains("\u{2003}"),
1464                    "Tab should be converted to em space, got: {result}"
1465                );
1466                assert!(
1467                    !result.contains('\t'),
1468                    "Raw tab should not remain in output, got: {result}"
1469                );
1470            }
1471
1472            #[test]
1473            fn test_with_space_indentation() {
1474                let blocks = vec![rich_text_block(serde_json::json!({
1475                    "type": "rich_text",
1476                    "elements": [
1477                        {
1478                            "type": "rich_text_section",
1479                            "elements": [
1480                                { "type": "text", "text": "Title", "style": { "bold": true } },
1481                                { "type": "text", "text": "\n• " },
1482                                { "type": "text", "text": "eu-tools:", "style": { "bold": true } },
1483                                { "type": "text", "text": "\n     • standard\n\n• " },
1484                                { "type": "text", "text": "fr-api:", "style": { "bold": true } },
1485                                { "type": "text", "text": "\n     • document_parsing" }
1486                            ]
1487                        }
1488                    ]
1489                }))];
1490                let result = render(blocks, SlackReferences::default());
1491                // After <br />\n, leading spaces must be preserved as &nbsp;
1492                assert!(
1493                    result.contains("&nbsp;"),
1494                    "Leading spaces after line break should be converted to &nbsp;, got: {result}"
1495                );
1496                // The 5 leading spaces before sub-bullets should not collapse
1497                assert!(
1498                    result.contains("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;•"),
1499                    "5 leading spaces should become 5 &nbsp; before the bullet, got: {result}"
1500                );
1501            }
1502        }
1503
1504        mod rich_text_list {
1505            use super::*;
1506
1507            #[test]
1508            fn test_ordered_list() {
1509                let blocks = vec![rich_text_block(serde_json::json!({
1510                    "type": "rich_text",
1511                    "elements": [
1512                        {
1513                            "type": "rich_text_list",
1514                            "style": "ordered",
1515                            "elements": [
1516                                {
1517                                    "type": "rich_text_section",
1518                                    "elements": [{ "type": "text", "text": "Item1" }]
1519                                },
1520                                {
1521                                    "type": "rich_text_section",
1522                                    "elements": [{ "type": "text", "text": "Item2" }]
1523                                }
1524                            ]
1525                        }
1526                    ]
1527                }))];
1528                assert_eq!(
1529                    render(blocks, SlackReferences::default()),
1530                    "<ol>\n<li>Item1</li>\n<li>Item2</li>\n</ol>\n"
1531                );
1532            }
1533
1534            #[test]
1535            fn test_unordered_list() {
1536                let blocks = vec![rich_text_block(serde_json::json!({
1537                    "type": "rich_text",
1538                    "elements": [
1539                        {
1540                            "type": "rich_text_list",
1541                            "style": "bullet",
1542                            "elements": [
1543                                {
1544                                    "type": "rich_text_section",
1545                                    "elements": [{ "type": "text", "text": "Item1" }]
1546                                },
1547                                {
1548                                    "type": "rich_text_section",
1549                                    "elements": [{ "type": "text", "text": "Item2" }]
1550                                }
1551                            ]
1552                        }
1553                    ]
1554                }))];
1555                assert_eq!(
1556                    render(blocks, SlackReferences::default()),
1557                    "<ul>\n<li>Item1</li>\n<li>Item2</li>\n</ul>\n"
1558                );
1559            }
1560
1561            #[test]
1562            fn test_nested_ordered_list() {
1563                let blocks = vec![rich_text_block(serde_json::json!({
1564                    "type": "rich_text",
1565                    "elements": [
1566                        {
1567                            "type": "rich_text_list",
1568                            "style": "ordered",
1569                            "elements": [
1570                                {
1571                                    "type": "rich_text_section",
1572                                    "elements": [{ "type": "text", "text": "Item1" }]
1573                                },
1574                                {
1575                                    "type": "rich_text_section",
1576                                    "elements": [{ "type": "text", "text": "Item2" }]
1577                                }
1578                            ]
1579                        },
1580                        {
1581                            "type": "rich_text_list",
1582                            "style": "ordered",
1583                            "indent": 1,
1584                            "elements": [
1585                                {
1586                                    "type": "rich_text_section",
1587                                    "elements": [{ "type": "text", "text": "Item2.1" }]
1588                                }
1589                            ]
1590                        }
1591                    ]
1592                }))];
1593                assert_eq!(
1594                    render(blocks, SlackReferences::default()),
1595                    "<ol>\n<li>Item1</li>\n<li>Item2\n<ol>\n<li>Item2.1</li>\n</ol>\n</li>\n</ol>\n"
1596                );
1597            }
1598        }
1599
1600        mod rich_text_preformatted {
1601            use super::*;
1602
1603            #[test]
1604            fn test_preformatted() {
1605                let blocks = vec![rich_text_block(serde_json::json!({
1606                    "type": "rich_text",
1607                    "elements": [
1608                        {
1609                            "type": "rich_text_preformatted",
1610                            "elements": [
1611                                { "type": "text", "text": "code here" }
1612                            ]
1613                        }
1614                    ]
1615                }))];
1616                assert_eq!(
1617                    render(blocks, SlackReferences::default()),
1618                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>code here\n</code></pre>\n"
1619                );
1620            }
1621
1622            #[test]
1623            fn test_preformatted_with_newlines() {
1624                let blocks = vec![rich_text_block(serde_json::json!({
1625                    "type": "rich_text",
1626                    "elements": [
1627                        {
1628                            "type": "rich_text_preformatted",
1629                            "elements": [
1630                                { "type": "text", "text": "line1\nline2" }
1631                            ]
1632                        }
1633                    ]
1634                }))];
1635                assert_eq!(
1636                    render(blocks, SlackReferences::default()),
1637                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>line1\nline2\n</code></pre>\n"
1638                );
1639            }
1640        }
1641
1642        mod rich_text_quote {
1643            use super::*;
1644
1645            #[test]
1646            fn test_quote() {
1647                let blocks = vec![rich_text_block(serde_json::json!({
1648                    "type": "rich_text",
1649                    "elements": [
1650                        {
1651                            "type": "rich_text_quote",
1652                            "elements": [
1653                                { "type": "text", "text": "quoted text" }
1654                            ]
1655                        }
1656                    ]
1657                }))];
1658                assert_eq!(
1659                    render(blocks, SlackReferences::default()),
1660                    "<blockquote>\n<p>quoted text</p>\n</blockquote>\n"
1661                );
1662            }
1663
1664            #[test]
1665            fn test_quote_followed_by_text() {
1666                let blocks = vec![rich_text_block(serde_json::json!({
1667                    "type": "rich_text",
1668                    "elements": [
1669                        {
1670                            "type": "rich_text_quote",
1671                            "elements": [
1672                                { "type": "text", "text": "quoted" }
1673                            ]
1674                        },
1675                        {
1676                            "type": "rich_text_section",
1677                            "elements": [
1678                                { "type": "text", "text": "normal" }
1679                            ]
1680                        }
1681                    ]
1682                }))];
1683                assert_eq!(
1684                    render(blocks, SlackReferences::default()),
1685                    "<blockquote>\n<p>quoted</p>\n</blockquote>\n<p>normal</p>\n"
1686                );
1687            }
1688        }
1689    }
1690
1691    #[test]
1692    fn test_html_escaping() {
1693        let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1694            SlackBlockText::Plain(SlackBlockPlainText::new(
1695                "<script>alert('xss')</script>".to_string(),
1696            )),
1697        ))];
1698        assert_eq!(
1699            render(blocks, SlackReferences::default()),
1700            "<p>&lt;script&gt;alert(\'xss\')&lt;/script&gt;</p>\n"
1701        );
1702    }
1703
1704    mod mrkdwn {
1705        use super::*;
1706
1707        #[test]
1708        fn test_bold() {
1709            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1710                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*bold text*".to_string())),
1711            ))];
1712            assert_eq!(
1713                render(blocks, SlackReferences::default()),
1714                "<p><strong>bold text</strong></p>\n"
1715            );
1716        }
1717
1718        #[test]
1719        fn test_italic() {
1720            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1721                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("_italic text_".to_string())),
1722            ))];
1723            assert_eq!(
1724                render(blocks, SlackReferences::default()),
1725                "<p><em>italic text</em></p>\n"
1726            );
1727        }
1728
1729        #[test]
1730        fn test_code() {
1731            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1732                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("`code`".to_string())),
1733            ))];
1734            assert_eq!(
1735                render(blocks, SlackReferences::default()),
1736                "<p><code>code</code></p>\n"
1737            );
1738        }
1739
1740        #[test]
1741        fn test_strike() {
1742            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1743                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("~strike~".to_string())),
1744            ))];
1745            assert_eq!(
1746                render(blocks, SlackReferences::default()),
1747                "<p><del>strike</del></p>\n"
1748            );
1749        }
1750
1751        #[test]
1752        fn test_nested_code_in_bold() {
1753            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1754                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*`+0.79%`*".to_string())),
1755            ))];
1756            assert_eq!(
1757                render(blocks, SlackReferences::default()),
1758                "<p><strong><code>+0.79%</code></strong></p>\n"
1759            );
1760        }
1761
1762        #[test]
1763        fn test_link_with_label() {
1764            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1765                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1766                    "<https://example.com|Example>".to_string(),
1767                )),
1768            ))];
1769            assert_eq!(
1770                render(blocks, SlackReferences::default()),
1771                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">Example</a></p>\n"
1772            );
1773        }
1774
1775        #[test]
1776        fn test_link_without_label() {
1777            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1778                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1779                    "<https://example.com>".to_string(),
1780                )),
1781            ))];
1782            assert_eq!(
1783                render(blocks, SlackReferences::default()),
1784                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">https://example.com</a></p>\n"
1785            );
1786        }
1787
1788        #[test]
1789        fn test_unicode_emoji() {
1790            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1791                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1792                    "hello :ok_hand:".to_string(),
1793                )),
1794            ))];
1795            assert_eq!(
1796                render(blocks, SlackReferences::default()),
1797                "<p>hello \u{1F44C}</p>\n"
1798            );
1799        }
1800
1801        #[test]
1802        fn test_custom_emoji() {
1803            let refs = SlackReferences {
1804                emojis: HashMap::from([(
1805                    SlackEmojiName("custom".to_string()),
1806                    Some(SlackEmojiRef::Url(
1807                        Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1808                    )),
1809                )]),
1810                ..SlackReferences::default()
1811            };
1812            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1813                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("hello :custom:".to_string())),
1814            ))];
1815            assert_eq!(
1816                render(blocks, refs),
1817                "<p>hello <img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1818            );
1819        }
1820
1821        #[test]
1822        fn test_line_breaks() {
1823            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1824                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("line1\nline2".to_string())),
1825            ))];
1826            assert_eq!(
1827                render(blocks, SlackReferences::default()),
1828                "<p>line1<br />\nline2</p>\n"
1829            );
1830        }
1831
1832        #[test]
1833        fn test_bullet_list() {
1834            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1835                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1836                    "• item1\n• item2".to_string(),
1837                )),
1838            ))];
1839            assert_eq!(
1840                render(blocks, SlackReferences::default()),
1841                "<p>\u{2022} item1<br />\n\u{2022} item2</p>\n"
1842            );
1843        }
1844
1845        #[test]
1846        fn test_html_escaping_in_mrkdwn() {
1847            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1848                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("a & b < c".to_string())),
1849            ))];
1850            assert_eq!(
1851                render(blocks, SlackReferences::default()),
1852                "<p>a &amp; b &lt; c</p>\n"
1853            );
1854        }
1855
1856        #[test]
1857        fn test_costory_real_world() {
1858            let blocks = vec![SlackBlock::Section(
1859                SlackSectionBlock::new().with_text(SlackBlockText::MarkDown(
1860                    SlackBlockMarkDownText::new(
1861                        "Cloud spend has remained stable *`+0.79%`* compared to the previous week.\nLast week, you've spent *`$60.09K`* :ok_hand:".to_string(),
1862                    ),
1863                )),
1864            )];
1865            assert_eq!(
1866                render(blocks, SlackReferences::default()),
1867                "<p>Cloud spend has remained stable <strong><code>+0.79%</code></strong> compared to the previous week.<br />\nLast week, you've spent <strong><code>$60.09K</code></strong> \u{1F44C}</p>\n"
1868            );
1869        }
1870
1871        #[test]
1872        fn test_unresolved_emoji_kept_as_literal() {
1873            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1874                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1875                    ":unknown_emoji:".to_string(),
1876                )),
1877            ))];
1878            assert_eq!(
1879                render(blocks, SlackReferences::default()),
1880                "<p>:unknown_emoji:</p>\n"
1881            );
1882        }
1883    }
1884
1885    mod render_slack_mrkdwn_text {
1886        use super::*;
1887
1888        #[test]
1889        fn test_link_rendered_as_html() {
1890            let result = render_slack_mrkdwn_text_as_html(
1891                "Check <https://example.com|this link>",
1892                &SlackReferences::default(),
1893                "text-primary",
1894                "text-warning",
1895            );
1896            assert_eq!(
1897                result,
1898                "Check <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">this link</a>"
1899            );
1900        }
1901
1902        #[test]
1903        fn test_bold_and_code() {
1904            let result = render_slack_mrkdwn_text_as_html(
1905                "*bold* and `code`",
1906                &SlackReferences::default(),
1907                "text-primary",
1908                "text-warning",
1909            );
1910            assert_eq!(result, "<strong>bold</strong> and <code>code</code>");
1911        }
1912
1913        #[test]
1914        fn test_plain_text_escaped() {
1915            let result = render_slack_mrkdwn_text_as_html(
1916                "a & b",
1917                &SlackReferences::default(),
1918                "text-primary",
1919                "text-warning",
1920            );
1921            assert_eq!(result, "a &amp; b");
1922        }
1923    }
1924}