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(SlackBlockPlainTextInputElement::new(
837                "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_video() {
863        let blocks = vec![SlackBlock::Video(
864            SlackVideoBlock::new(
865                "alt text".into(),
866                "Video title".into(),
867                "https://example.com/thumbnail.jpg".parse().unwrap(),
868                "https://example.com/video_embed.avi".parse().unwrap(),
869            )
870            .with_description("Video description".into())
871            .with_title_url("https://example.com/video".parse().unwrap()),
872        )];
873        assert_eq!(
874            render(blocks, SlackReferences::default()),
875            "<p><em><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/video\">Video title</a></em></p>\n\
876             <p>Video description</p>\n\
877             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
878        );
879    }
880
881    #[test]
882    fn test_with_video_minimal() {
883        let blocks = vec![SlackBlock::Video(SlackVideoBlock::new(
884            "alt text".into(),
885            "Video title".into(),
886            "https://example.com/thumbnail.jpg".parse().unwrap(),
887            "https://example.com/video_embed.avi".parse().unwrap(),
888        ))];
889        assert_eq!(
890            render(blocks, SlackReferences::default()),
891            "<p><em>Video title</em></p>\n\
892             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
893        );
894    }
895
896    mod section {
897        use super::*;
898
899        #[test]
900        fn test_with_plain_text() {
901            let blocks = vec![
902                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
903                    SlackBlockPlainText::new("Text".to_string()),
904                ))),
905                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
906                    SlackBlockPlainText::new("Text2".to_string()),
907                ))),
908            ];
909            assert_eq!(
910                render(blocks, SlackReferences::default()),
911                "<p>Text</p>\n<p>Text2</p>\n"
912            );
913        }
914
915        #[test]
916        fn test_with_fields() {
917            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_fields(
918                vec![
919                    SlackBlockText::Plain(SlackBlockPlainText::new("Text11".to_string())),
920                    SlackBlockText::Plain(SlackBlockPlainText::new("Text12".to_string())),
921                ],
922            ))];
923            assert_eq!(
924                render(blocks, SlackReferences::default()),
925                "<p>Text11Text12</p>\n"
926            );
927        }
928    }
929
930    mod context {
931        use super::*;
932
933        #[test]
934        fn test_with_image() {
935            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
936                SlackContextBlockElement::Image(SlackBlockImageElement::new(
937                    SlackImageUrlOrFile::ImageUrl {
938                        image_url: Url::parse("https://example.com/image.png").unwrap(),
939                    },
940                    "Image".to_string(),
941                )),
942            ]))];
943            assert_eq!(
944                render(blocks, SlackReferences::default()),
945                "<p><img src=\"https://example.com/image.png\" alt=\"Image\" /></p>\n"
946            );
947        }
948
949        #[test]
950        fn test_with_plain_text() {
951            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
952                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text".to_string())),
953                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text2".to_string())),
954            ]))];
955            assert_eq!(
956                render(blocks, SlackReferences::default()),
957                "<p>TextText2</p>\n"
958            );
959        }
960    }
961
962    mod rich_text {
963        use super::*;
964
965        #[test]
966        fn test_with_empty_json() {
967            let blocks = vec![rich_text_block(serde_json::json!({}))];
968            assert_eq!(render(blocks, SlackReferences::default()), "");
969        }
970
971        mod rich_text_section {
972            use super::*;
973
974            #[test]
975            fn test_with_text() {
976                let blocks = vec![rich_text_block(serde_json::json!({
977                    "type": "rich_text",
978                    "elements": [
979                        {
980                            "type": "rich_text_section",
981                            "elements": [
982                                { "type": "text", "text": "Text111" },
983                                { "type": "text", "text": "Text112" }
984                            ]
985                        },
986                        {
987                            "type": "rich_text_section",
988                            "elements": [
989                                { "type": "text", "text": "Text211" },
990                                { "type": "text", "text": "Text212" }
991                            ]
992                        }
993                    ]
994                }))];
995                assert_eq!(
996                    render(blocks, SlackReferences::default()),
997                    "<p>Text111Text112</p>\n<p>Text211Text212</p>\n"
998                );
999            }
1000
1001            #[test]
1002            fn test_with_text_with_newline() {
1003                let blocks = vec![rich_text_block(serde_json::json!({
1004                    "type": "rich_text",
1005                    "elements": [
1006                        {
1007                            "type": "rich_text_section",
1008                            "elements": [
1009                                { "type": "text", "text": "Text1\nText2\n" }
1010                            ]
1011                        }
1012                    ]
1013                }))];
1014                assert_eq!(
1015                    render(blocks, SlackReferences::default()),
1016                    "<p>Text1<br />\nText2</p>\n"
1017                );
1018            }
1019
1020            #[test]
1021            fn test_with_text_with_only_newline() {
1022                let blocks = vec![rich_text_block(serde_json::json!({
1023                    "type": "rich_text",
1024                    "elements": [
1025                        {
1026                            "type": "rich_text_section",
1027                            "elements": [
1028                                { "type": "text", "text": "\n" }
1029                            ]
1030                        }
1031                    ]
1032                }))];
1033                assert_eq!(render(blocks, SlackReferences::default()), "");
1034            }
1035
1036            #[test]
1037            fn test_with_bold_text() {
1038                let blocks = vec![rich_text_block(serde_json::json!({
1039                    "type": "rich_text",
1040                    "elements": [
1041                        {
1042                            "type": "rich_text_section",
1043                            "elements": [
1044                                { "type": "text", "text": "Text", "style": { "bold": true } }
1045                            ]
1046                        }
1047                    ]
1048                }))];
1049                assert_eq!(
1050                    render(blocks, SlackReferences::default()),
1051                    "<p><strong>Text</strong></p>\n"
1052                );
1053            }
1054
1055            #[test]
1056            fn test_with_consecutive_bold_text() {
1057                let blocks = vec![rich_text_block(serde_json::json!({
1058                    "type": "rich_text",
1059                    "elements": [
1060                        {
1061                            "type": "rich_text_section",
1062                            "elements": [
1063                                { "type": "text", "text": "Hello", "style": { "bold": true } },
1064                                { "type": "text", "text": " ", "style": { "bold": true } },
1065                                { "type": "text", "text": "World!", "style": { "bold": true } }
1066                            ]
1067                        }
1068                    ]
1069                }))];
1070                assert_eq!(
1071                    render(blocks, SlackReferences::default()),
1072                    "<p><strong>Hello World!</strong></p>\n"
1073                );
1074            }
1075
1076            #[test]
1077            fn test_with_italic_text() {
1078                let blocks = vec![rich_text_block(serde_json::json!({
1079                    "type": "rich_text",
1080                    "elements": [
1081                        {
1082                            "type": "rich_text_section",
1083                            "elements": [
1084                                { "type": "text", "text": "Text", "style": { "italic": true } }
1085                            ]
1086                        }
1087                    ]
1088                }))];
1089                assert_eq!(
1090                    render(blocks, SlackReferences::default()),
1091                    "<p><em>Text</em></p>\n"
1092                );
1093            }
1094
1095            #[test]
1096            fn test_with_strike_text() {
1097                let blocks = vec![rich_text_block(serde_json::json!({
1098                    "type": "rich_text",
1099                    "elements": [
1100                        {
1101                            "type": "rich_text_section",
1102                            "elements": [
1103                                { "type": "text", "text": "Text", "style": { "strike": true } }
1104                            ]
1105                        }
1106                    ]
1107                }))];
1108                assert_eq!(
1109                    render(blocks, SlackReferences::default()),
1110                    "<p><del>Text</del></p>\n"
1111                );
1112            }
1113
1114            #[test]
1115            fn test_with_code_text() {
1116                let blocks = vec![rich_text_block(serde_json::json!({
1117                    "type": "rich_text",
1118                    "elements": [
1119                        {
1120                            "type": "rich_text_section",
1121                            "elements": [
1122                                { "type": "text", "text": "Text", "style": { "code": true } }
1123                            ]
1124                        }
1125                    ]
1126                }))];
1127                assert_eq!(
1128                    render(blocks, SlackReferences::default()),
1129                    "<p><code>Text</code></p>\n"
1130                );
1131            }
1132
1133            #[test]
1134            fn test_with_all_styles() {
1135                let blocks = vec![rich_text_block(serde_json::json!({
1136                    "type": "rich_text",
1137                    "elements": [
1138                        {
1139                            "type": "rich_text_section",
1140                            "elements": [
1141                                {
1142                                    "type": "text",
1143                                    "text": "Text",
1144                                    "style": { "bold": true, "italic": true, "strike": true, "code": true }
1145                                }
1146                            ]
1147                        }
1148                    ]
1149                }))];
1150                assert_eq!(
1151                    render(blocks, SlackReferences::default()),
1152                    "<p><code><del><em><strong>Text</strong></em></del></code></p>\n"
1153                );
1154            }
1155
1156            #[test]
1157            fn test_with_link() {
1158                let blocks = vec![rich_text_block(serde_json::json!({
1159                    "type": "rich_text",
1160                    "elements": [
1161                        {
1162                            "type": "rich_text_section",
1163                            "elements": [
1164                                { "type": "link", "url": "https://example.com", "text": "Example" }
1165                            ]
1166                        }
1167                    ]
1168                }))];
1169                assert_eq!(
1170                    render(blocks, SlackReferences::default()),
1171                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">Example</a></p>\n"
1172                );
1173            }
1174
1175            #[test]
1176            fn test_with_attachment_mention() {
1177                let blocks = vec![rich_text_block(serde_json::json!({
1178                    "type": "rich_text",
1179                    "elements": [
1180                        {
1181                            "type": "rich_text_section",
1182                            "elements": [
1183                                {
1184                                    "type": "attachment_mention",
1185                                    "url": "https://docs.google.com/document/d/1abc/edit?tab=t.0",
1186                                    "text": "Project plan",
1187                                    "app_id": "A6NL8MJ6Q",
1188                                    "product_name": "Google Docs"
1189                                }
1190                            ]
1191                        }
1192                    ]
1193                }))];
1194                assert_eq!(
1195                    render(blocks, SlackReferences::default()),
1196                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://docs.google.com/document/d/1abc/edit?tab=t.0\">Project plan</a></p>\n"
1197                );
1198            }
1199
1200            #[test]
1201            fn test_with_attachment_mention_without_text() {
1202                let blocks = vec![rich_text_block(serde_json::json!({
1203                    "type": "rich_text",
1204                    "elements": [
1205                        {
1206                            "type": "rich_text_section",
1207                            "elements": [
1208                                { "type": "attachment_mention", "url": "https://example.com/doc" }
1209                            ]
1210                        }
1211                    ]
1212                }))];
1213                assert_eq!(
1214                    render(blocks, SlackReferences::default()),
1215                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/doc\">https://example.com/doc</a></p>\n"
1216                );
1217            }
1218
1219            #[test]
1220            fn test_with_message_mention() {
1221                let blocks = vec![rich_text_block(serde_json::json!({
1222                    "type": "rich_text",
1223                    "elements": [
1224                        {
1225                            "type": "rich_text_section",
1226                            "elements": [
1227                                {
1228                                    "type": "message_mention",
1229                                    "url": "https://slack.com/archives/C123/p1788850799491249",
1230                                    "text": "a previous message",
1231                                    "channel_id": "C123"
1232                                }
1233                            ]
1234                        }
1235                    ]
1236                }))];
1237                assert_eq!(
1238                    render(blocks, SlackReferences::default()),
1239                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://slack.com/archives/C123/p1788850799491249\">a previous message</a></p>\n"
1240                );
1241            }
1242
1243            #[test]
1244            fn test_with_user_mention() {
1245                let refs = SlackReferences {
1246                    users: HashMap::from([(
1247                        SlackUserId("U123".to_string()),
1248                        Some("john.doe".to_string()),
1249                    )]),
1250                    ..SlackReferences::default()
1251                };
1252                let blocks = vec![rich_text_block(serde_json::json!({
1253                    "type": "rich_text",
1254                    "elements": [
1255                        {
1256                            "type": "rich_text_section",
1257                            "elements": [
1258                                { "type": "user", "user_id": "U123" }
1259                            ]
1260                        }
1261                    ]
1262                }))];
1263                assert_eq!(
1264                    render(blocks, refs),
1265                    "<p><span class=\"text-primary\">@john.doe</span></p>\n"
1266                );
1267            }
1268
1269            #[test]
1270            fn test_with_highlighted_user_mention() {
1271                let refs = SlackReferences {
1272                    users: HashMap::from([(
1273                        SlackUserId("U123".to_string()),
1274                        Some("john.doe".to_string()),
1275                    )]),
1276                    user_id_to_highlight: Some(SlackUserId("U123".to_string())),
1277                    ..SlackReferences::default()
1278                };
1279                let blocks = vec![rich_text_block(serde_json::json!({
1280                    "type": "rich_text",
1281                    "elements": [
1282                        {
1283                            "type": "rich_text_section",
1284                            "elements": [
1285                                { "type": "user", "user_id": "U123" }
1286                            ]
1287                        }
1288                    ]
1289                }))];
1290                assert_eq!(
1291                    render(blocks, refs),
1292                    "<p><span class=\"text-accent\">@john.doe</span></p>\n"
1293                );
1294            }
1295
1296            #[test]
1297            fn test_with_usergroup_mention() {
1298                let refs = SlackReferences {
1299                    usergroups: HashMap::from([(
1300                        SlackUserGroupId("G123".to_string()),
1301                        Some("team-eng".to_string()),
1302                    )]),
1303                    ..SlackReferences::default()
1304                };
1305                let blocks = vec![rich_text_block(serde_json::json!({
1306                    "type": "rich_text",
1307                    "elements": [
1308                        {
1309                            "type": "rich_text_section",
1310                            "elements": [
1311                                { "type": "usergroup", "usergroup_id": "G123" }
1312                            ]
1313                        }
1314                    ]
1315                }))];
1316                assert_eq!(
1317                    render(blocks, refs),
1318                    "<p><span class=\"text-primary\">@team-eng</span></p>\n"
1319                );
1320            }
1321
1322            #[test]
1323            fn test_with_highlighted_usergroup_mention() {
1324                let refs = SlackReferences {
1325                    usergroups: HashMap::from([(
1326                        SlackUserGroupId("G123".to_string()),
1327                        Some("team-eng".to_string()),
1328                    )]),
1329                    usergroup_ids_to_highlight: Some(vec![SlackUserGroupId("G123".to_string())]),
1330                    ..SlackReferences::default()
1331                };
1332                let blocks = vec![rich_text_block(serde_json::json!({
1333                    "type": "rich_text",
1334                    "elements": [
1335                        {
1336                            "type": "rich_text_section",
1337                            "elements": [
1338                                { "type": "usergroup", "usergroup_id": "G123" }
1339                            ]
1340                        }
1341                    ]
1342                }))];
1343                assert_eq!(
1344                    render(blocks, refs),
1345                    "<p><span class=\"text-accent\">@team-eng</span></p>\n"
1346                );
1347            }
1348
1349            #[test]
1350            fn test_with_channel_ref() {
1351                let refs = SlackReferences {
1352                    channels: HashMap::from([(
1353                        SlackChannelId("C123".to_string()),
1354                        Some("general".to_string()),
1355                    )]),
1356                    ..SlackReferences::default()
1357                };
1358                let blocks = vec![rich_text_block(serde_json::json!({
1359                    "type": "rich_text",
1360                    "elements": [
1361                        {
1362                            "type": "rich_text_section",
1363                            "elements": [
1364                                { "type": "channel", "channel_id": "C123" }
1365                            ]
1366                        }
1367                    ]
1368                }))];
1369                assert_eq!(render(blocks, refs), "<p>#general</p>\n");
1370            }
1371
1372            #[test]
1373            fn test_with_unicode_emoji() {
1374                let blocks = vec![rich_text_block(serde_json::json!({
1375                    "type": "rich_text",
1376                    "elements": [
1377                        {
1378                            "type": "rich_text_section",
1379                            "elements": [
1380                                { "type": "emoji", "name": "wave" }
1381                            ]
1382                        }
1383                    ]
1384                }))];
1385                assert_eq!(
1386                    render(blocks, SlackReferences::default()),
1387                    "<p>\u{1F44B}</p>\n"
1388                );
1389            }
1390
1391            #[test]
1392            fn test_with_custom_emoji() {
1393                let refs = SlackReferences {
1394                    emojis: HashMap::from([(
1395                        SlackEmojiName("custom".to_string()),
1396                        Some(SlackEmojiRef::Url(
1397                            Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1398                        )),
1399                    )]),
1400                    ..SlackReferences::default()
1401                };
1402                let blocks = vec![rich_text_block(serde_json::json!({
1403                    "type": "rich_text",
1404                    "elements": [
1405                        {
1406                            "type": "rich_text_section",
1407                            "elements": [
1408                                { "type": "emoji", "name": "custom" }
1409                            ]
1410                        }
1411                    ]
1412                }))];
1413                assert_eq!(
1414                    render(blocks, refs),
1415                    "<p><img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1416                );
1417            }
1418        }
1419
1420        mod rich_text_section_with_inline_indentation {
1421            use super::*;
1422
1423            #[test]
1424            fn test_with_tab_indentation() {
1425                let blocks = vec![rich_text_block(serde_json::json!({
1426                    "type": "rich_text",
1427                    "elements": [
1428                        {
1429                            "type": "rich_text_section",
1430                            "elements": [
1431                                { "type": "text", "text": "Title", "style": { "bold": true } },
1432                                { "type": "text", "text": "\n• item1\n\t• sub-item1\n• item2" }
1433                            ]
1434                        }
1435                    ]
1436                }))];
1437                let result = render(blocks, SlackReferences::default());
1438                assert!(
1439                    result.contains("\u{2003}"),
1440                    "Tab should be converted to em space, got: {result}"
1441                );
1442                assert!(
1443                    !result.contains('\t'),
1444                    "Raw tab should not remain in output, got: {result}"
1445                );
1446            }
1447
1448            #[test]
1449            fn test_with_space_indentation() {
1450                let blocks = vec![rich_text_block(serde_json::json!({
1451                    "type": "rich_text",
1452                    "elements": [
1453                        {
1454                            "type": "rich_text_section",
1455                            "elements": [
1456                                { "type": "text", "text": "Title", "style": { "bold": true } },
1457                                { "type": "text", "text": "\n• " },
1458                                { "type": "text", "text": "eu-tools:", "style": { "bold": true } },
1459                                { "type": "text", "text": "\n     • standard\n\n• " },
1460                                { "type": "text", "text": "fr-api:", "style": { "bold": true } },
1461                                { "type": "text", "text": "\n     • document_parsing" }
1462                            ]
1463                        }
1464                    ]
1465                }))];
1466                let result = render(blocks, SlackReferences::default());
1467                // After <br />\n, leading spaces must be preserved as &nbsp;
1468                assert!(
1469                    result.contains("&nbsp;"),
1470                    "Leading spaces after line break should be converted to &nbsp;, got: {result}"
1471                );
1472                // The 5 leading spaces before sub-bullets should not collapse
1473                assert!(
1474                    result.contains("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;•"),
1475                    "5 leading spaces should become 5 &nbsp; before the bullet, got: {result}"
1476                );
1477            }
1478        }
1479
1480        mod rich_text_list {
1481            use super::*;
1482
1483            #[test]
1484            fn test_ordered_list() {
1485                let blocks = vec![rich_text_block(serde_json::json!({
1486                    "type": "rich_text",
1487                    "elements": [
1488                        {
1489                            "type": "rich_text_list",
1490                            "style": "ordered",
1491                            "elements": [
1492                                {
1493                                    "type": "rich_text_section",
1494                                    "elements": [{ "type": "text", "text": "Item1" }]
1495                                },
1496                                {
1497                                    "type": "rich_text_section",
1498                                    "elements": [{ "type": "text", "text": "Item2" }]
1499                                }
1500                            ]
1501                        }
1502                    ]
1503                }))];
1504                assert_eq!(
1505                    render(blocks, SlackReferences::default()),
1506                    "<ol>\n<li>Item1</li>\n<li>Item2</li>\n</ol>\n"
1507                );
1508            }
1509
1510            #[test]
1511            fn test_unordered_list() {
1512                let blocks = vec![rich_text_block(serde_json::json!({
1513                    "type": "rich_text",
1514                    "elements": [
1515                        {
1516                            "type": "rich_text_list",
1517                            "style": "bullet",
1518                            "elements": [
1519                                {
1520                                    "type": "rich_text_section",
1521                                    "elements": [{ "type": "text", "text": "Item1" }]
1522                                },
1523                                {
1524                                    "type": "rich_text_section",
1525                                    "elements": [{ "type": "text", "text": "Item2" }]
1526                                }
1527                            ]
1528                        }
1529                    ]
1530                }))];
1531                assert_eq!(
1532                    render(blocks, SlackReferences::default()),
1533                    "<ul>\n<li>Item1</li>\n<li>Item2</li>\n</ul>\n"
1534                );
1535            }
1536
1537            #[test]
1538            fn test_nested_ordered_list() {
1539                let blocks = vec![rich_text_block(serde_json::json!({
1540                    "type": "rich_text",
1541                    "elements": [
1542                        {
1543                            "type": "rich_text_list",
1544                            "style": "ordered",
1545                            "elements": [
1546                                {
1547                                    "type": "rich_text_section",
1548                                    "elements": [{ "type": "text", "text": "Item1" }]
1549                                },
1550                                {
1551                                    "type": "rich_text_section",
1552                                    "elements": [{ "type": "text", "text": "Item2" }]
1553                                }
1554                            ]
1555                        },
1556                        {
1557                            "type": "rich_text_list",
1558                            "style": "ordered",
1559                            "indent": 1,
1560                            "elements": [
1561                                {
1562                                    "type": "rich_text_section",
1563                                    "elements": [{ "type": "text", "text": "Item2.1" }]
1564                                }
1565                            ]
1566                        }
1567                    ]
1568                }))];
1569                assert_eq!(
1570                    render(blocks, SlackReferences::default()),
1571                    "<ol>\n<li>Item1</li>\n<li>Item2\n<ol>\n<li>Item2.1</li>\n</ol>\n</li>\n</ol>\n"
1572                );
1573            }
1574        }
1575
1576        mod rich_text_preformatted {
1577            use super::*;
1578
1579            #[test]
1580            fn test_preformatted() {
1581                let blocks = vec![rich_text_block(serde_json::json!({
1582                    "type": "rich_text",
1583                    "elements": [
1584                        {
1585                            "type": "rich_text_preformatted",
1586                            "elements": [
1587                                { "type": "text", "text": "code here" }
1588                            ]
1589                        }
1590                    ]
1591                }))];
1592                assert_eq!(
1593                    render(blocks, SlackReferences::default()),
1594                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>code here\n</code></pre>\n"
1595                );
1596            }
1597
1598            #[test]
1599            fn test_preformatted_with_newlines() {
1600                let blocks = vec![rich_text_block(serde_json::json!({
1601                    "type": "rich_text",
1602                    "elements": [
1603                        {
1604                            "type": "rich_text_preformatted",
1605                            "elements": [
1606                                { "type": "text", "text": "line1\nline2" }
1607                            ]
1608                        }
1609                    ]
1610                }))];
1611                assert_eq!(
1612                    render(blocks, SlackReferences::default()),
1613                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>line1\nline2\n</code></pre>\n"
1614                );
1615            }
1616        }
1617
1618        mod rich_text_quote {
1619            use super::*;
1620
1621            #[test]
1622            fn test_quote() {
1623                let blocks = vec![rich_text_block(serde_json::json!({
1624                    "type": "rich_text",
1625                    "elements": [
1626                        {
1627                            "type": "rich_text_quote",
1628                            "elements": [
1629                                { "type": "text", "text": "quoted text" }
1630                            ]
1631                        }
1632                    ]
1633                }))];
1634                assert_eq!(
1635                    render(blocks, SlackReferences::default()),
1636                    "<blockquote>\n<p>quoted text</p>\n</blockquote>\n"
1637                );
1638            }
1639
1640            #[test]
1641            fn test_quote_followed_by_text() {
1642                let blocks = vec![rich_text_block(serde_json::json!({
1643                    "type": "rich_text",
1644                    "elements": [
1645                        {
1646                            "type": "rich_text_quote",
1647                            "elements": [
1648                                { "type": "text", "text": "quoted" }
1649                            ]
1650                        },
1651                        {
1652                            "type": "rich_text_section",
1653                            "elements": [
1654                                { "type": "text", "text": "normal" }
1655                            ]
1656                        }
1657                    ]
1658                }))];
1659                assert_eq!(
1660                    render(blocks, SlackReferences::default()),
1661                    "<blockquote>\n<p>quoted</p>\n</blockquote>\n<p>normal</p>\n"
1662                );
1663            }
1664        }
1665    }
1666
1667    #[test]
1668    fn test_html_escaping() {
1669        let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1670            SlackBlockText::Plain(SlackBlockPlainText::new(
1671                "<script>alert('xss')</script>".to_string(),
1672            )),
1673        ))];
1674        assert_eq!(
1675            render(blocks, SlackReferences::default()),
1676            "<p>&lt;script&gt;alert(\'xss\')&lt;/script&gt;</p>\n"
1677        );
1678    }
1679
1680    mod mrkdwn {
1681        use super::*;
1682
1683        #[test]
1684        fn test_bold() {
1685            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1686                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*bold text*".to_string())),
1687            ))];
1688            assert_eq!(
1689                render(blocks, SlackReferences::default()),
1690                "<p><strong>bold text</strong></p>\n"
1691            );
1692        }
1693
1694        #[test]
1695        fn test_italic() {
1696            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1697                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("_italic text_".to_string())),
1698            ))];
1699            assert_eq!(
1700                render(blocks, SlackReferences::default()),
1701                "<p><em>italic text</em></p>\n"
1702            );
1703        }
1704
1705        #[test]
1706        fn test_code() {
1707            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1708                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("`code`".to_string())),
1709            ))];
1710            assert_eq!(
1711                render(blocks, SlackReferences::default()),
1712                "<p><code>code</code></p>\n"
1713            );
1714        }
1715
1716        #[test]
1717        fn test_strike() {
1718            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1719                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("~strike~".to_string())),
1720            ))];
1721            assert_eq!(
1722                render(blocks, SlackReferences::default()),
1723                "<p><del>strike</del></p>\n"
1724            );
1725        }
1726
1727        #[test]
1728        fn test_nested_code_in_bold() {
1729            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1730                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*`+0.79%`*".to_string())),
1731            ))];
1732            assert_eq!(
1733                render(blocks, SlackReferences::default()),
1734                "<p><strong><code>+0.79%</code></strong></p>\n"
1735            );
1736        }
1737
1738        #[test]
1739        fn test_link_with_label() {
1740            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1741                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1742                    "<https://example.com|Example>".to_string(),
1743                )),
1744            ))];
1745            assert_eq!(
1746                render(blocks, SlackReferences::default()),
1747                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">Example</a></p>\n"
1748            );
1749        }
1750
1751        #[test]
1752        fn test_link_without_label() {
1753            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1754                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1755                    "<https://example.com>".to_string(),
1756                )),
1757            ))];
1758            assert_eq!(
1759                render(blocks, SlackReferences::default()),
1760                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">https://example.com</a></p>\n"
1761            );
1762        }
1763
1764        #[test]
1765        fn test_unicode_emoji() {
1766            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1767                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1768                    "hello :ok_hand:".to_string(),
1769                )),
1770            ))];
1771            assert_eq!(
1772                render(blocks, SlackReferences::default()),
1773                "<p>hello \u{1F44C}</p>\n"
1774            );
1775        }
1776
1777        #[test]
1778        fn test_custom_emoji() {
1779            let refs = SlackReferences {
1780                emojis: HashMap::from([(
1781                    SlackEmojiName("custom".to_string()),
1782                    Some(SlackEmojiRef::Url(
1783                        Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1784                    )),
1785                )]),
1786                ..SlackReferences::default()
1787            };
1788            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1789                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("hello :custom:".to_string())),
1790            ))];
1791            assert_eq!(
1792                render(blocks, refs),
1793                "<p>hello <img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1794            );
1795        }
1796
1797        #[test]
1798        fn test_line_breaks() {
1799            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1800                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("line1\nline2".to_string())),
1801            ))];
1802            assert_eq!(
1803                render(blocks, SlackReferences::default()),
1804                "<p>line1<br />\nline2</p>\n"
1805            );
1806        }
1807
1808        #[test]
1809        fn test_bullet_list() {
1810            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1811                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1812                    "• item1\n• item2".to_string(),
1813                )),
1814            ))];
1815            assert_eq!(
1816                render(blocks, SlackReferences::default()),
1817                "<p>\u{2022} item1<br />\n\u{2022} item2</p>\n"
1818            );
1819        }
1820
1821        #[test]
1822        fn test_html_escaping_in_mrkdwn() {
1823            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1824                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("a & b < c".to_string())),
1825            ))];
1826            assert_eq!(
1827                render(blocks, SlackReferences::default()),
1828                "<p>a &amp; b &lt; c</p>\n"
1829            );
1830        }
1831
1832        #[test]
1833        fn test_costory_real_world() {
1834            let blocks = vec![SlackBlock::Section(
1835                SlackSectionBlock::new().with_text(SlackBlockText::MarkDown(
1836                    SlackBlockMarkDownText::new(
1837                        "Cloud spend has remained stable *`+0.79%`* compared to the previous week.\nLast week, you've spent *`$60.09K`* :ok_hand:".to_string(),
1838                    ),
1839                )),
1840            )];
1841            assert_eq!(
1842                render(blocks, SlackReferences::default()),
1843                "<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"
1844            );
1845        }
1846
1847        #[test]
1848        fn test_unresolved_emoji_kept_as_literal() {
1849            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1850                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1851                    ":unknown_emoji:".to_string(),
1852                )),
1853            ))];
1854            assert_eq!(
1855                render(blocks, SlackReferences::default()),
1856                "<p>:unknown_emoji:</p>\n"
1857            );
1858        }
1859    }
1860
1861    mod render_slack_mrkdwn_text {
1862        use super::*;
1863
1864        #[test]
1865        fn test_link_rendered_as_html() {
1866            let result = render_slack_mrkdwn_text_as_html(
1867                "Check <https://example.com|this link>",
1868                &SlackReferences::default(),
1869                "text-primary",
1870                "text-warning",
1871            );
1872            assert_eq!(
1873                result,
1874                "Check <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">this link</a>"
1875            );
1876        }
1877
1878        #[test]
1879        fn test_bold_and_code() {
1880            let result = render_slack_mrkdwn_text_as_html(
1881                "*bold* and `code`",
1882                &SlackReferences::default(),
1883                "text-primary",
1884                "text-warning",
1885            );
1886            assert_eq!(result, "<strong>bold</strong> and <code>code</code>");
1887        }
1888
1889        #[test]
1890        fn test_plain_text_escaped() {
1891            let result = render_slack_mrkdwn_text_as_html(
1892                "a & b",
1893                &SlackReferences::default(),
1894                "text-primary",
1895                "text-warning",
1896            );
1897            assert_eq!(result, "a &amp; b");
1898        }
1899    }
1900}