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        Some(Some("link")) => {
470            let Some(serde_json::Value::String(url)) = element.get("url") else {
471                return (String::new(), None);
472            };
473            let text = element
474                .get("text")
475                .and_then(|t| t.as_str())
476                .unwrap_or(url.as_str());
477            let style = element.get("style");
478            let styles = StyleSet::from_style(style);
479            let html = format!(
480                "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{url}\">{}</a>",
481                encode_text(text)
482            );
483            (wrap_with_styles(html, &styles), None)
484        }
485        _ => (String::new(), None),
486    }
487}
488
489fn render_emoji(emoji_name: &SlackEmojiName, slack_references: &SlackReferences) -> String {
490    if let Some(Some(emoji)) = slack_references.emojis.get(emoji_name) {
491        match emoji {
492            SlackEmojiRef::Alias(alias) => {
493                return render_emoji(alias, slack_references);
494            }
495            SlackEmojiRef::Url(url) => {
496                return format!(
497                    "<img class=\"slack-emoji\" src=\"{url}\" alt=\":{}:\" />",
498                    encode_text(&emoji_name.0)
499                );
500            }
501        }
502    }
503    let name = &emoji_name.0;
504
505    let splitted = name.split("::skin-tone-").collect::<Vec<&str>>();
506    let Some(first) = splitted.first() else {
507        return format!(":{}:", encode_text(name));
508    };
509    let Some(emoji) = emojis::get_by_shortcode(first) else {
510        return format!(":{}:", encode_text(name));
511    };
512    let Some(skin_tone) = splitted.get(1).and_then(|s| s.parse::<usize>().ok()) else {
513        return emoji.to_string();
514    };
515    let Some(mut skin_tones) = emoji.skin_tones() else {
516        return emoji.to_string();
517    };
518    let Some(skinned_emoji) = skin_tones.nth(skin_tone - 1) else {
519        return emoji.to_string();
520    };
521    skinned_emoji.to_string()
522}
523
524fn render_rich_text_preformatted_elements(
525    elements: &[serde_json::Value],
526    renderer: &HtmlRenderer,
527) -> String {
528    let content = render_rich_text_section_elements(elements, renderer, false);
529    format!("<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>{content}\n</code></pre>\n")
530}
531
532fn render_rich_text_quote_elements(
533    elements: &[serde_json::Value],
534    renderer: &HtmlRenderer,
535) -> String {
536    let content = render_rich_text_section_elements(elements, renderer, true);
537    format!("<blockquote>\n<p>{content}</p>\n</blockquote>\n")
538}
539
540// --- Nested list construction ---
541
542fn build_nested_list_html(items: &[ListItem]) -> String {
543    if items.is_empty() {
544        return String::new();
545    }
546    build_list_at_indent(items, 0).0
547}
548
549/// Returns (html_string, number_of_items_consumed)
550fn build_list_at_indent(items: &[ListItem], base_indent: usize) -> (String, usize) {
551    if items.is_empty() {
552        return (String::new(), 0);
553    }
554
555    let tag = if items[0].style == "ordered" {
556        "ol"
557    } else {
558        "ul"
559    };
560    let mut html = format!("<{tag}>\n");
561    let mut i = 0;
562
563    while i < items.len() && items[i].indent >= base_indent {
564        if items[i].indent > base_indent {
565            // Sub-list: attach to the previous <li> (which was left unclosed)
566            let (sub_html, consumed) = build_list_at_indent(&items[i..], items[i].indent);
567            html.push_str(&sub_html);
568            html.push_str("</li>\n");
569            i += consumed;
570        } else {
571            // Same level item
572            html.push_str(&format!("<li>{}", items[i].content));
573            // Check if next item is a sub-list
574            if i + 1 < items.len() && items[i + 1].indent > base_indent {
575                html.push('\n');
576                // Don't close <li> — sub-list will be attached
577            } else {
578                html.push_str("</li>\n");
579            }
580            i += 1;
581        }
582    }
583
584    html.push_str(&format!("</{tag}>\n"));
585    (html, i)
586}
587
588// --- Helpers ---
589
590fn is_styled(style: Option<&serde_json::Value>, style_name: &str) -> bool {
591    style
592        .and_then(|s| s.get(style_name).map(|b| b.as_bool()))
593        .flatten()
594        .unwrap_or_default()
595}
596
597/// Render Slack's mrkdwn format as HTML.
598/// Handles: *bold*, _italic_, `code`, ~strike~, <url|label> links, :emoji:, \n line breaks.
599fn render_slack_mrkdwn_as_html(text: &str, renderer: &HtmlRenderer) -> String {
600    let mut output = String::new();
601    let chars: Vec<char> = text.chars().collect();
602    let len = chars.len();
603    let mut i = 0;
604    let mut in_bold = false;
605    let mut in_italic = false;
606    let mut in_strike = false;
607    let mut in_code = false;
608
609    while i < len {
610        let ch = chars[i];
611
612        // Backtick code (highest priority — no formatting inside)
613        if ch == '`' {
614            if in_code {
615                output.push_str("</code>");
616                in_code = false;
617            } else {
618                output.push_str("<code>");
619                in_code = true;
620            }
621            i += 1;
622            continue;
623        }
624
625        // Inside code: escape everything, no formatting
626        if in_code {
627            output.push_str(&encode_char(ch));
628            i += 1;
629            continue;
630        }
631
632        // Slack link: <url|label> or <url>
633        if ch == '<' {
634            if let Some(end) = chars[i..].iter().position(|&c| c == '>') {
635                let inner: String = chars[i + 1..i + end].iter().collect();
636                // Check for special Slack references: <@U123>, <!subteam^S123>
637                if inner.starts_with('@') || inner.starts_with('!') || inner.starts_with('#') {
638                    // User/channel/subteam mention in mrkdwn — render as escaped text
639                    output.push_str(&encode_text(&inner));
640                } else if let Some(pipe_pos) = inner.find('|') {
641                    let url = &inner[..pipe_pos];
642                    let label = &inner[pipe_pos + 1..];
643                    output.push_str(&format!(
644                        "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{}\">{}</a>",
645                        url,
646                        encode_text(label)
647                    ));
648                } else {
649                    // URL without label
650                    output.push_str(&format!(
651                        "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"{inner}\">{}</a>",
652                        encode_text(&inner)
653                    ));
654                }
655                i += end + 1;
656                continue;
657            }
658            // Not a valid Slack link, escape the <
659            output.push_str("&lt;");
660            i += 1;
661            continue;
662        }
663
664        // Emoji shortcode: :name: (checked before _ to avoid italic inside emoji names)
665        if ch == ':' {
666            if let Some(end) = chars[i + 1..].iter().position(|&c| c == ':') {
667                let name: String = chars[i + 1..i + 1 + end].iter().collect();
668                // Valid emoji names: non-empty, no spaces, may contain letters/digits/underscores/hyphens
669                if !name.is_empty() && !name.contains(' ') {
670                    let emoji_html =
671                        render_emoji(&SlackEmojiName(name.clone()), &renderer.slack_references);
672                    // If render_emoji returned :name: unchanged, it wasn't resolved
673                    // but it's still a valid emoji shortcode — preserve it as-is
674                    output.push_str(&emoji_html);
675                    i += end + 2; // skip past closing :
676                    continue;
677                }
678            }
679            // Not a valid emoji shortcode, output as literal
680            output.push(':');
681            i += 1;
682            continue;
683        }
684
685        // Bold: *text*
686        if ch == '*' {
687            if in_bold {
688                output.push_str("</strong>");
689                in_bold = false;
690            } else {
691                output.push_str("<strong>");
692                in_bold = true;
693            }
694            i += 1;
695            continue;
696        }
697
698        // Italic: _text_
699        if ch == '_' {
700            if in_italic {
701                output.push_str("</em>");
702                in_italic = false;
703            } else {
704                output.push_str("<em>");
705                in_italic = true;
706            }
707            i += 1;
708            continue;
709        }
710
711        // Strikethrough: ~text~
712        if ch == '~' {
713            if in_strike {
714                output.push_str("</del>");
715                in_strike = false;
716            } else {
717                output.push_str("<del>");
718                in_strike = true;
719            }
720            i += 1;
721            continue;
722        }
723
724        // Newline
725        if ch == '\n' {
726            output.push_str("<br />\n");
727            i += 1;
728            continue;
729        }
730
731        // Regular character — HTML escape
732        output.push_str(&encode_char(ch));
733        i += 1;
734    }
735
736    output
737}
738
739fn encode_char(ch: char) -> String {
740    match ch {
741        '&' => "&amp;".to_string(),
742        '<' => "&lt;".to_string(),
743        '>' => "&gt;".to_string(),
744        '"' => "&quot;".to_string(),
745        _ => ch.to_string(),
746    }
747}
748
749fn fix_newlines(text: String) -> String {
750    let result = text.replace('\t', "\u{2003}");
751    let mut output = String::new();
752    let mut chars = result.chars().peekable();
753    while let Some(ch) = chars.next() {
754        if ch == '\n' {
755            output.push_str("<br />\n");
756            while chars.peek() == Some(&' ') {
757                chars.next();
758                output.push_str("&nbsp;");
759            }
760        } else {
761            output.push(ch);
762        }
763    }
764    output.trim_end_matches("<br />\n").to_string()
765}
766
767#[cfg(test)]
768mod tests {
769    use std::collections::HashMap;
770
771    use url::Url;
772
773    use super::*;
774    use crate::test_utils::rich_text_block;
775
776    fn render(blocks: Vec<SlackBlock>, refs: SlackReferences) -> String {
777        render_blocks_as_html(blocks, refs, "text-primary", "text-accent")
778    }
779
780    #[test]
781    fn test_empty_input() {
782        assert_eq!(render(vec![], SlackReferences::default()), "");
783    }
784
785    #[test]
786    fn test_with_image() {
787        let blocks = vec![
788            SlackBlock::Image(SlackImageBlock::new(
789                SlackImageUrlOrFile::ImageUrl {
790                    image_url: Url::parse("https://example.com/image.png").unwrap(),
791                },
792                "Image".to_string(),
793            )),
794            SlackBlock::Image(SlackImageBlock::new(
795                SlackImageUrlOrFile::ImageUrl {
796                    image_url: Url::parse("https://example.com/image2.png").unwrap(),
797                },
798                "Image2".to_string(),
799            )),
800        ];
801        assert_eq!(
802            render(blocks, SlackReferences::default()),
803            "<p><img src=\"https://example.com/image.png\" alt=\"Image\" /></p>\n\
804             <p><img src=\"https://example.com/image2.png\" alt=\"Image2\" /></p>\n"
805        );
806    }
807
808    #[test]
809    fn test_with_divider() {
810        let blocks = vec![
811            SlackBlock::Divider(SlackDividerBlock::new()),
812            SlackBlock::Divider(SlackDividerBlock::new()),
813        ];
814        assert_eq!(
815            render(blocks, SlackReferences::default()),
816            "<hr />\n<hr />\n"
817        );
818    }
819
820    #[test]
821    fn test_header() {
822        let blocks = vec![SlackBlock::Header(SlackHeaderBlock::new("Text".into()))];
823        assert_eq!(
824            render(blocks, SlackReferences::default()),
825            "<h2>Text</h2>\n"
826        );
827    }
828
829    #[test]
830    fn test_with_input() {
831        let blocks = vec![SlackBlock::Input(SlackInputBlock::new(
832            "label".into(),
833            SlackInputBlockElement::PlainTextInput(SlackBlockPlainTextInputElement::new(
834                "id".into(),
835            )),
836        ))];
837        assert_eq!(render(blocks, SlackReferences::default()), "");
838    }
839
840    #[test]
841    fn test_with_action() {
842        let blocks = vec![SlackBlock::Actions(SlackActionsBlock::new(vec![]))];
843        assert_eq!(render(blocks, SlackReferences::default()), "");
844    }
845
846    #[test]
847    fn test_with_file() {
848        let blocks = vec![SlackBlock::File(SlackFileBlock::new("external_id".into()))];
849        assert_eq!(render(blocks, SlackReferences::default()), "");
850    }
851
852    #[test]
853    fn test_with_event() {
854        let blocks = vec![SlackBlock::Event(serde_json::json!({}))];
855        assert_eq!(render(blocks, SlackReferences::default()), "");
856    }
857
858    #[test]
859    fn test_with_video() {
860        let blocks = vec![SlackBlock::Video(
861            SlackVideoBlock::new(
862                "alt text".into(),
863                "Video title".into(),
864                "https://example.com/thumbnail.jpg".parse().unwrap(),
865                "https://example.com/video_embed.avi".parse().unwrap(),
866            )
867            .with_description("Video description".into())
868            .with_title_url("https://example.com/video".parse().unwrap()),
869        )];
870        assert_eq!(
871            render(blocks, SlackReferences::default()),
872            "<p><em><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/video\">Video title</a></em></p>\n\
873             <p>Video description</p>\n\
874             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
875        );
876    }
877
878    #[test]
879    fn test_with_video_minimal() {
880        let blocks = vec![SlackBlock::Video(SlackVideoBlock::new(
881            "alt text".into(),
882            "Video title".into(),
883            "https://example.com/thumbnail.jpg".parse().unwrap(),
884            "https://example.com/video_embed.avi".parse().unwrap(),
885        ))];
886        assert_eq!(
887            render(blocks, SlackReferences::default()),
888            "<p><em>Video title</em></p>\n\
889             <p><img src=\"https://example.com/thumbnail.jpg\" alt=\"alt text\" /></p>\n"
890        );
891    }
892
893    mod section {
894        use super::*;
895
896        #[test]
897        fn test_with_plain_text() {
898            let blocks = vec![
899                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
900                    SlackBlockPlainText::new("Text".to_string()),
901                ))),
902                SlackBlock::Section(SlackSectionBlock::new().with_text(SlackBlockText::Plain(
903                    SlackBlockPlainText::new("Text2".to_string()),
904                ))),
905            ];
906            assert_eq!(
907                render(blocks, SlackReferences::default()),
908                "<p>Text</p>\n<p>Text2</p>\n"
909            );
910        }
911
912        #[test]
913        fn test_with_fields() {
914            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_fields(
915                vec![
916                    SlackBlockText::Plain(SlackBlockPlainText::new("Text11".to_string())),
917                    SlackBlockText::Plain(SlackBlockPlainText::new("Text12".to_string())),
918                ],
919            ))];
920            assert_eq!(
921                render(blocks, SlackReferences::default()),
922                "<p>Text11Text12</p>\n"
923            );
924        }
925    }
926
927    mod context {
928        use super::*;
929
930        #[test]
931        fn test_with_image() {
932            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
933                SlackContextBlockElement::Image(SlackBlockImageElement::new(
934                    SlackImageUrlOrFile::ImageUrl {
935                        image_url: Url::parse("https://example.com/image.png").unwrap(),
936                    },
937                    "Image".to_string(),
938                )),
939            ]))];
940            assert_eq!(
941                render(blocks, SlackReferences::default()),
942                "<p><img src=\"https://example.com/image.png\" alt=\"Image\" /></p>\n"
943            );
944        }
945
946        #[test]
947        fn test_with_plain_text() {
948            let blocks = vec![SlackBlock::Context(SlackContextBlock::new(vec![
949                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text".to_string())),
950                SlackContextBlockElement::Plain(SlackBlockPlainText::new("Text2".to_string())),
951            ]))];
952            assert_eq!(
953                render(blocks, SlackReferences::default()),
954                "<p>TextText2</p>\n"
955            );
956        }
957    }
958
959    mod rich_text {
960        use super::*;
961
962        #[test]
963        fn test_with_empty_json() {
964            let blocks = vec![rich_text_block(serde_json::json!({}))];
965            assert_eq!(render(blocks, SlackReferences::default()), "");
966        }
967
968        mod rich_text_section {
969            use super::*;
970
971            #[test]
972            fn test_with_text() {
973                let blocks = vec![rich_text_block(serde_json::json!({
974                    "type": "rich_text",
975                    "elements": [
976                        {
977                            "type": "rich_text_section",
978                            "elements": [
979                                { "type": "text", "text": "Text111" },
980                                { "type": "text", "text": "Text112" }
981                            ]
982                        },
983                        {
984                            "type": "rich_text_section",
985                            "elements": [
986                                { "type": "text", "text": "Text211" },
987                                { "type": "text", "text": "Text212" }
988                            ]
989                        }
990                    ]
991                }))];
992                assert_eq!(
993                    render(blocks, SlackReferences::default()),
994                    "<p>Text111Text112</p>\n<p>Text211Text212</p>\n"
995                );
996            }
997
998            #[test]
999            fn test_with_text_with_newline() {
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": "Text1\nText2\n" }
1007                            ]
1008                        }
1009                    ]
1010                }))];
1011                assert_eq!(
1012                    render(blocks, SlackReferences::default()),
1013                    "<p>Text1<br />\nText2</p>\n"
1014                );
1015            }
1016
1017            #[test]
1018            fn test_with_text_with_only_newline() {
1019                let blocks = vec![rich_text_block(serde_json::json!({
1020                    "type": "rich_text",
1021                    "elements": [
1022                        {
1023                            "type": "rich_text_section",
1024                            "elements": [
1025                                { "type": "text", "text": "\n" }
1026                            ]
1027                        }
1028                    ]
1029                }))];
1030                assert_eq!(render(blocks, SlackReferences::default()), "");
1031            }
1032
1033            #[test]
1034            fn test_with_bold_text() {
1035                let blocks = vec![rich_text_block(serde_json::json!({
1036                    "type": "rich_text",
1037                    "elements": [
1038                        {
1039                            "type": "rich_text_section",
1040                            "elements": [
1041                                { "type": "text", "text": "Text", "style": { "bold": true } }
1042                            ]
1043                        }
1044                    ]
1045                }))];
1046                assert_eq!(
1047                    render(blocks, SlackReferences::default()),
1048                    "<p><strong>Text</strong></p>\n"
1049                );
1050            }
1051
1052            #[test]
1053            fn test_with_consecutive_bold_text() {
1054                let blocks = vec![rich_text_block(serde_json::json!({
1055                    "type": "rich_text",
1056                    "elements": [
1057                        {
1058                            "type": "rich_text_section",
1059                            "elements": [
1060                                { "type": "text", "text": "Hello", "style": { "bold": true } },
1061                                { "type": "text", "text": " ", "style": { "bold": true } },
1062                                { "type": "text", "text": "World!", "style": { "bold": true } }
1063                            ]
1064                        }
1065                    ]
1066                }))];
1067                assert_eq!(
1068                    render(blocks, SlackReferences::default()),
1069                    "<p><strong>Hello World!</strong></p>\n"
1070                );
1071            }
1072
1073            #[test]
1074            fn test_with_italic_text() {
1075                let blocks = vec![rich_text_block(serde_json::json!({
1076                    "type": "rich_text",
1077                    "elements": [
1078                        {
1079                            "type": "rich_text_section",
1080                            "elements": [
1081                                { "type": "text", "text": "Text", "style": { "italic": true } }
1082                            ]
1083                        }
1084                    ]
1085                }))];
1086                assert_eq!(
1087                    render(blocks, SlackReferences::default()),
1088                    "<p><em>Text</em></p>\n"
1089                );
1090            }
1091
1092            #[test]
1093            fn test_with_strike_text() {
1094                let blocks = vec![rich_text_block(serde_json::json!({
1095                    "type": "rich_text",
1096                    "elements": [
1097                        {
1098                            "type": "rich_text_section",
1099                            "elements": [
1100                                { "type": "text", "text": "Text", "style": { "strike": true } }
1101                            ]
1102                        }
1103                    ]
1104                }))];
1105                assert_eq!(
1106                    render(blocks, SlackReferences::default()),
1107                    "<p><del>Text</del></p>\n"
1108                );
1109            }
1110
1111            #[test]
1112            fn test_with_code_text() {
1113                let blocks = vec![rich_text_block(serde_json::json!({
1114                    "type": "rich_text",
1115                    "elements": [
1116                        {
1117                            "type": "rich_text_section",
1118                            "elements": [
1119                                { "type": "text", "text": "Text", "style": { "code": true } }
1120                            ]
1121                        }
1122                    ]
1123                }))];
1124                assert_eq!(
1125                    render(blocks, SlackReferences::default()),
1126                    "<p><code>Text</code></p>\n"
1127                );
1128            }
1129
1130            #[test]
1131            fn test_with_all_styles() {
1132                let blocks = vec![rich_text_block(serde_json::json!({
1133                    "type": "rich_text",
1134                    "elements": [
1135                        {
1136                            "type": "rich_text_section",
1137                            "elements": [
1138                                {
1139                                    "type": "text",
1140                                    "text": "Text",
1141                                    "style": { "bold": true, "italic": true, "strike": true, "code": true }
1142                                }
1143                            ]
1144                        }
1145                    ]
1146                }))];
1147                assert_eq!(
1148                    render(blocks, SlackReferences::default()),
1149                    "<p><code><del><em><strong>Text</strong></em></del></code></p>\n"
1150                );
1151            }
1152
1153            #[test]
1154            fn test_with_link() {
1155                let blocks = vec![rich_text_block(serde_json::json!({
1156                    "type": "rich_text",
1157                    "elements": [
1158                        {
1159                            "type": "rich_text_section",
1160                            "elements": [
1161                                { "type": "link", "url": "https://example.com", "text": "Example" }
1162                            ]
1163                        }
1164                    ]
1165                }))];
1166                assert_eq!(
1167                    render(blocks, SlackReferences::default()),
1168                    "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com/\">Example</a></p>\n"
1169                );
1170            }
1171
1172            #[test]
1173            fn test_with_user_mention() {
1174                let refs = SlackReferences {
1175                    users: HashMap::from([(
1176                        SlackUserId("U123".to_string()),
1177                        Some("john.doe".to_string()),
1178                    )]),
1179                    ..SlackReferences::default()
1180                };
1181                let blocks = vec![rich_text_block(serde_json::json!({
1182                    "type": "rich_text",
1183                    "elements": [
1184                        {
1185                            "type": "rich_text_section",
1186                            "elements": [
1187                                { "type": "user", "user_id": "U123" }
1188                            ]
1189                        }
1190                    ]
1191                }))];
1192                assert_eq!(
1193                    render(blocks, refs),
1194                    "<p><span class=\"text-primary\">@john.doe</span></p>\n"
1195                );
1196            }
1197
1198            #[test]
1199            fn test_with_highlighted_user_mention() {
1200                let refs = SlackReferences {
1201                    users: HashMap::from([(
1202                        SlackUserId("U123".to_string()),
1203                        Some("john.doe".to_string()),
1204                    )]),
1205                    user_id_to_highlight: Some(SlackUserId("U123".to_string())),
1206                    ..SlackReferences::default()
1207                };
1208                let blocks = vec![rich_text_block(serde_json::json!({
1209                    "type": "rich_text",
1210                    "elements": [
1211                        {
1212                            "type": "rich_text_section",
1213                            "elements": [
1214                                { "type": "user", "user_id": "U123" }
1215                            ]
1216                        }
1217                    ]
1218                }))];
1219                assert_eq!(
1220                    render(blocks, refs),
1221                    "<p><span class=\"text-accent\">@john.doe</span></p>\n"
1222                );
1223            }
1224
1225            #[test]
1226            fn test_with_usergroup_mention() {
1227                let refs = SlackReferences {
1228                    usergroups: HashMap::from([(
1229                        SlackUserGroupId("G123".to_string()),
1230                        Some("team-eng".to_string()),
1231                    )]),
1232                    ..SlackReferences::default()
1233                };
1234                let blocks = vec![rich_text_block(serde_json::json!({
1235                    "type": "rich_text",
1236                    "elements": [
1237                        {
1238                            "type": "rich_text_section",
1239                            "elements": [
1240                                { "type": "usergroup", "usergroup_id": "G123" }
1241                            ]
1242                        }
1243                    ]
1244                }))];
1245                assert_eq!(
1246                    render(blocks, refs),
1247                    "<p><span class=\"text-primary\">@team-eng</span></p>\n"
1248                );
1249            }
1250
1251            #[test]
1252            fn test_with_highlighted_usergroup_mention() {
1253                let refs = SlackReferences {
1254                    usergroups: HashMap::from([(
1255                        SlackUserGroupId("G123".to_string()),
1256                        Some("team-eng".to_string()),
1257                    )]),
1258                    usergroup_ids_to_highlight: Some(vec![SlackUserGroupId("G123".to_string())]),
1259                    ..SlackReferences::default()
1260                };
1261                let blocks = vec![rich_text_block(serde_json::json!({
1262                    "type": "rich_text",
1263                    "elements": [
1264                        {
1265                            "type": "rich_text_section",
1266                            "elements": [
1267                                { "type": "usergroup", "usergroup_id": "G123" }
1268                            ]
1269                        }
1270                    ]
1271                }))];
1272                assert_eq!(
1273                    render(blocks, refs),
1274                    "<p><span class=\"text-accent\">@team-eng</span></p>\n"
1275                );
1276            }
1277
1278            #[test]
1279            fn test_with_channel_ref() {
1280                let refs = SlackReferences {
1281                    channels: HashMap::from([(
1282                        SlackChannelId("C123".to_string()),
1283                        Some("general".to_string()),
1284                    )]),
1285                    ..SlackReferences::default()
1286                };
1287                let blocks = vec![rich_text_block(serde_json::json!({
1288                    "type": "rich_text",
1289                    "elements": [
1290                        {
1291                            "type": "rich_text_section",
1292                            "elements": [
1293                                { "type": "channel", "channel_id": "C123" }
1294                            ]
1295                        }
1296                    ]
1297                }))];
1298                assert_eq!(render(blocks, refs), "<p>#general</p>\n");
1299            }
1300
1301            #[test]
1302            fn test_with_unicode_emoji() {
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": "emoji", "name": "wave" }
1310                            ]
1311                        }
1312                    ]
1313                }))];
1314                assert_eq!(
1315                    render(blocks, SlackReferences::default()),
1316                    "<p>\u{1F44B}</p>\n"
1317                );
1318            }
1319
1320            #[test]
1321            fn test_with_custom_emoji() {
1322                let refs = SlackReferences {
1323                    emojis: HashMap::from([(
1324                        SlackEmojiName("custom".to_string()),
1325                        Some(SlackEmojiRef::Url(
1326                            Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1327                        )),
1328                    )]),
1329                    ..SlackReferences::default()
1330                };
1331                let blocks = vec![rich_text_block(serde_json::json!({
1332                    "type": "rich_text",
1333                    "elements": [
1334                        {
1335                            "type": "rich_text_section",
1336                            "elements": [
1337                                { "type": "emoji", "name": "custom" }
1338                            ]
1339                        }
1340                    ]
1341                }))];
1342                assert_eq!(
1343                    render(blocks, refs),
1344                    "<p><img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1345                );
1346            }
1347        }
1348
1349        mod rich_text_section_with_inline_indentation {
1350            use super::*;
1351
1352            #[test]
1353            fn test_with_tab_indentation() {
1354                let blocks = vec![rich_text_block(serde_json::json!({
1355                    "type": "rich_text",
1356                    "elements": [
1357                        {
1358                            "type": "rich_text_section",
1359                            "elements": [
1360                                { "type": "text", "text": "Title", "style": { "bold": true } },
1361                                { "type": "text", "text": "\n• item1\n\t• sub-item1\n• item2" }
1362                            ]
1363                        }
1364                    ]
1365                }))];
1366                let result = render(blocks, SlackReferences::default());
1367                assert!(
1368                    result.contains("\u{2003}"),
1369                    "Tab should be converted to em space, got: {result}"
1370                );
1371                assert!(
1372                    !result.contains('\t'),
1373                    "Raw tab should not remain in output, got: {result}"
1374                );
1375            }
1376
1377            #[test]
1378            fn test_with_space_indentation() {
1379                let blocks = vec![rich_text_block(serde_json::json!({
1380                    "type": "rich_text",
1381                    "elements": [
1382                        {
1383                            "type": "rich_text_section",
1384                            "elements": [
1385                                { "type": "text", "text": "Title", "style": { "bold": true } },
1386                                { "type": "text", "text": "\n• " },
1387                                { "type": "text", "text": "eu-tools:", "style": { "bold": true } },
1388                                { "type": "text", "text": "\n     • standard\n\n• " },
1389                                { "type": "text", "text": "fr-api:", "style": { "bold": true } },
1390                                { "type": "text", "text": "\n     • document_parsing" }
1391                            ]
1392                        }
1393                    ]
1394                }))];
1395                let result = render(blocks, SlackReferences::default());
1396                // After <br />\n, leading spaces must be preserved as &nbsp;
1397                assert!(
1398                    result.contains("&nbsp;"),
1399                    "Leading spaces after line break should be converted to &nbsp;, got: {result}"
1400                );
1401                // The 5 leading spaces before sub-bullets should not collapse
1402                assert!(
1403                    result.contains("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;•"),
1404                    "5 leading spaces should become 5 &nbsp; before the bullet, got: {result}"
1405                );
1406            }
1407        }
1408
1409        mod rich_text_list {
1410            use super::*;
1411
1412            #[test]
1413            fn test_ordered_list() {
1414                let blocks = vec![rich_text_block(serde_json::json!({
1415                    "type": "rich_text",
1416                    "elements": [
1417                        {
1418                            "type": "rich_text_list",
1419                            "style": "ordered",
1420                            "elements": [
1421                                {
1422                                    "type": "rich_text_section",
1423                                    "elements": [{ "type": "text", "text": "Item1" }]
1424                                },
1425                                {
1426                                    "type": "rich_text_section",
1427                                    "elements": [{ "type": "text", "text": "Item2" }]
1428                                }
1429                            ]
1430                        }
1431                    ]
1432                }))];
1433                assert_eq!(
1434                    render(blocks, SlackReferences::default()),
1435                    "<ol>\n<li>Item1</li>\n<li>Item2</li>\n</ol>\n"
1436                );
1437            }
1438
1439            #[test]
1440            fn test_unordered_list() {
1441                let blocks = vec![rich_text_block(serde_json::json!({
1442                    "type": "rich_text",
1443                    "elements": [
1444                        {
1445                            "type": "rich_text_list",
1446                            "style": "bullet",
1447                            "elements": [
1448                                {
1449                                    "type": "rich_text_section",
1450                                    "elements": [{ "type": "text", "text": "Item1" }]
1451                                },
1452                                {
1453                                    "type": "rich_text_section",
1454                                    "elements": [{ "type": "text", "text": "Item2" }]
1455                                }
1456                            ]
1457                        }
1458                    ]
1459                }))];
1460                assert_eq!(
1461                    render(blocks, SlackReferences::default()),
1462                    "<ul>\n<li>Item1</li>\n<li>Item2</li>\n</ul>\n"
1463                );
1464            }
1465
1466            #[test]
1467            fn test_nested_ordered_list() {
1468                let blocks = vec![rich_text_block(serde_json::json!({
1469                    "type": "rich_text",
1470                    "elements": [
1471                        {
1472                            "type": "rich_text_list",
1473                            "style": "ordered",
1474                            "elements": [
1475                                {
1476                                    "type": "rich_text_section",
1477                                    "elements": [{ "type": "text", "text": "Item1" }]
1478                                },
1479                                {
1480                                    "type": "rich_text_section",
1481                                    "elements": [{ "type": "text", "text": "Item2" }]
1482                                }
1483                            ]
1484                        },
1485                        {
1486                            "type": "rich_text_list",
1487                            "style": "ordered",
1488                            "indent": 1,
1489                            "elements": [
1490                                {
1491                                    "type": "rich_text_section",
1492                                    "elements": [{ "type": "text", "text": "Item2.1" }]
1493                                }
1494                            ]
1495                        }
1496                    ]
1497                }))];
1498                assert_eq!(
1499                    render(blocks, SlackReferences::default()),
1500                    "<ol>\n<li>Item1</li>\n<li>Item2\n<ol>\n<li>Item2.1</li>\n</ol>\n</li>\n</ol>\n"
1501                );
1502            }
1503        }
1504
1505        mod rich_text_preformatted {
1506            use super::*;
1507
1508            #[test]
1509            fn test_preformatted() {
1510                let blocks = vec![rich_text_block(serde_json::json!({
1511                    "type": "rich_text",
1512                    "elements": [
1513                        {
1514                            "type": "rich_text_preformatted",
1515                            "elements": [
1516                                { "type": "text", "text": "code here" }
1517                            ]
1518                        }
1519                    ]
1520                }))];
1521                assert_eq!(
1522                    render(blocks, SlackReferences::default()),
1523                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>code here\n</code></pre>\n"
1524                );
1525            }
1526
1527            #[test]
1528            fn test_preformatted_with_newlines() {
1529                let blocks = vec![rich_text_block(serde_json::json!({
1530                    "type": "rich_text",
1531                    "elements": [
1532                        {
1533                            "type": "rich_text_preformatted",
1534                            "elements": [
1535                                { "type": "text", "text": "line1\nline2" }
1536                            ]
1537                        }
1538                    ]
1539                }))];
1540                assert_eq!(
1541                    render(blocks, SlackReferences::default()),
1542                    "<pre style=\"white-space: pre-wrap; word-break: break-word;\"><code>line1\nline2\n</code></pre>\n"
1543                );
1544            }
1545        }
1546
1547        mod rich_text_quote {
1548            use super::*;
1549
1550            #[test]
1551            fn test_quote() {
1552                let blocks = vec![rich_text_block(serde_json::json!({
1553                    "type": "rich_text",
1554                    "elements": [
1555                        {
1556                            "type": "rich_text_quote",
1557                            "elements": [
1558                                { "type": "text", "text": "quoted text" }
1559                            ]
1560                        }
1561                    ]
1562                }))];
1563                assert_eq!(
1564                    render(blocks, SlackReferences::default()),
1565                    "<blockquote>\n<p>quoted text</p>\n</blockquote>\n"
1566                );
1567            }
1568
1569            #[test]
1570            fn test_quote_followed_by_text() {
1571                let blocks = vec![rich_text_block(serde_json::json!({
1572                    "type": "rich_text",
1573                    "elements": [
1574                        {
1575                            "type": "rich_text_quote",
1576                            "elements": [
1577                                { "type": "text", "text": "quoted" }
1578                            ]
1579                        },
1580                        {
1581                            "type": "rich_text_section",
1582                            "elements": [
1583                                { "type": "text", "text": "normal" }
1584                            ]
1585                        }
1586                    ]
1587                }))];
1588                assert_eq!(
1589                    render(blocks, SlackReferences::default()),
1590                    "<blockquote>\n<p>quoted</p>\n</blockquote>\n<p>normal</p>\n"
1591                );
1592            }
1593        }
1594    }
1595
1596    #[test]
1597    fn test_html_escaping() {
1598        let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1599            SlackBlockText::Plain(SlackBlockPlainText::new(
1600                "<script>alert('xss')</script>".to_string(),
1601            )),
1602        ))];
1603        assert_eq!(
1604            render(blocks, SlackReferences::default()),
1605            "<p>&lt;script&gt;alert(\'xss\')&lt;/script&gt;</p>\n"
1606        );
1607    }
1608
1609    mod mrkdwn {
1610        use super::*;
1611
1612        #[test]
1613        fn test_bold() {
1614            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1615                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*bold text*".to_string())),
1616            ))];
1617            assert_eq!(
1618                render(blocks, SlackReferences::default()),
1619                "<p><strong>bold text</strong></p>\n"
1620            );
1621        }
1622
1623        #[test]
1624        fn test_italic() {
1625            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1626                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("_italic text_".to_string())),
1627            ))];
1628            assert_eq!(
1629                render(blocks, SlackReferences::default()),
1630                "<p><em>italic text</em></p>\n"
1631            );
1632        }
1633
1634        #[test]
1635        fn test_code() {
1636            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1637                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("`code`".to_string())),
1638            ))];
1639            assert_eq!(
1640                render(blocks, SlackReferences::default()),
1641                "<p><code>code</code></p>\n"
1642            );
1643        }
1644
1645        #[test]
1646        fn test_strike() {
1647            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1648                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("~strike~".to_string())),
1649            ))];
1650            assert_eq!(
1651                render(blocks, SlackReferences::default()),
1652                "<p><del>strike</del></p>\n"
1653            );
1654        }
1655
1656        #[test]
1657        fn test_nested_code_in_bold() {
1658            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1659                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("*`+0.79%`*".to_string())),
1660            ))];
1661            assert_eq!(
1662                render(blocks, SlackReferences::default()),
1663                "<p><strong><code>+0.79%</code></strong></p>\n"
1664            );
1665        }
1666
1667        #[test]
1668        fn test_link_with_label() {
1669            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1670                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1671                    "<https://example.com|Example>".to_string(),
1672                )),
1673            ))];
1674            assert_eq!(
1675                render(blocks, SlackReferences::default()),
1676                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">Example</a></p>\n"
1677            );
1678        }
1679
1680        #[test]
1681        fn test_link_without_label() {
1682            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1683                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1684                    "<https://example.com>".to_string(),
1685                )),
1686            ))];
1687            assert_eq!(
1688                render(blocks, SlackReferences::default()),
1689                "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">https://example.com</a></p>\n"
1690            );
1691        }
1692
1693        #[test]
1694        fn test_unicode_emoji() {
1695            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1696                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1697                    "hello :ok_hand:".to_string(),
1698                )),
1699            ))];
1700            assert_eq!(
1701                render(blocks, SlackReferences::default()),
1702                "<p>hello \u{1F44C}</p>\n"
1703            );
1704        }
1705
1706        #[test]
1707        fn test_custom_emoji() {
1708            let refs = SlackReferences {
1709                emojis: HashMap::from([(
1710                    SlackEmojiName("custom".to_string()),
1711                    Some(SlackEmojiRef::Url(
1712                        Url::parse("https://emoji.slack-edge.com/custom.png").unwrap(),
1713                    )),
1714                )]),
1715                ..SlackReferences::default()
1716            };
1717            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1718                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("hello :custom:".to_string())),
1719            ))];
1720            assert_eq!(
1721                render(blocks, refs),
1722                "<p>hello <img class=\"slack-emoji\" src=\"https://emoji.slack-edge.com/custom.png\" alt=\":custom:\" /></p>\n"
1723            );
1724        }
1725
1726        #[test]
1727        fn test_line_breaks() {
1728            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1729                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("line1\nline2".to_string())),
1730            ))];
1731            assert_eq!(
1732                render(blocks, SlackReferences::default()),
1733                "<p>line1<br />\nline2</p>\n"
1734            );
1735        }
1736
1737        #[test]
1738        fn test_bullet_list() {
1739            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1740                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1741                    "• item1\n• item2".to_string(),
1742                )),
1743            ))];
1744            assert_eq!(
1745                render(blocks, SlackReferences::default()),
1746                "<p>\u{2022} item1<br />\n\u{2022} item2</p>\n"
1747            );
1748        }
1749
1750        #[test]
1751        fn test_html_escaping_in_mrkdwn() {
1752            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1753                SlackBlockText::MarkDown(SlackBlockMarkDownText::new("a & b < c".to_string())),
1754            ))];
1755            assert_eq!(
1756                render(blocks, SlackReferences::default()),
1757                "<p>a &amp; b &lt; c</p>\n"
1758            );
1759        }
1760
1761        #[test]
1762        fn test_costory_real_world() {
1763            let blocks = vec![SlackBlock::Section(
1764                SlackSectionBlock::new().with_text(SlackBlockText::MarkDown(
1765                    SlackBlockMarkDownText::new(
1766                        "Cloud spend has remained stable *`+0.79%`* compared to the previous week.\nLast week, you've spent *`$60.09K`* :ok_hand:".to_string(),
1767                    ),
1768                )),
1769            )];
1770            assert_eq!(
1771                render(blocks, SlackReferences::default()),
1772                "<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"
1773            );
1774        }
1775
1776        #[test]
1777        fn test_unresolved_emoji_kept_as_literal() {
1778            let blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
1779                SlackBlockText::MarkDown(SlackBlockMarkDownText::new(
1780                    ":unknown_emoji:".to_string(),
1781                )),
1782            ))];
1783            assert_eq!(
1784                render(blocks, SlackReferences::default()),
1785                "<p>:unknown_emoji:</p>\n"
1786            );
1787        }
1788    }
1789
1790    mod render_slack_mrkdwn_text {
1791        use super::*;
1792
1793        #[test]
1794        fn test_link_rendered_as_html() {
1795            let result = render_slack_mrkdwn_text_as_html(
1796                "Check <https://example.com|this link>",
1797                &SlackReferences::default(),
1798                "text-primary",
1799                "text-warning",
1800            );
1801            assert_eq!(
1802                result,
1803                "Check <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://example.com\">this link</a>"
1804            );
1805        }
1806
1807        #[test]
1808        fn test_bold_and_code() {
1809            let result = render_slack_mrkdwn_text_as_html(
1810                "*bold* and `code`",
1811                &SlackReferences::default(),
1812                "text-primary",
1813                "text-warning",
1814            );
1815            assert_eq!(result, "<strong>bold</strong> and <code>code</code>");
1816        }
1817
1818        #[test]
1819        fn test_plain_text_escaped() {
1820            let result = render_slack_mrkdwn_text_as_html(
1821                "a & b",
1822                &SlackReferences::default(),
1823                "text-primary",
1824                "text-warning",
1825            );
1826            assert_eq!(result, "a &amp; b");
1827        }
1828    }
1829}