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
15pub 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
205struct 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 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 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
335fn 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 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
540fn 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
549fn 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 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 html.push_str(&format!("<li>{}", items[i].content));
573 if i + 1 < items.len() && items[i + 1].indent > base_indent {
575 html.push('\n');
576 } 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
588fn 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
597fn 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 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 if in_code {
627 output.push_str(&encode_char(ch));
628 i += 1;
629 continue;
630 }
631
632 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 if inner.starts_with('@') || inner.starts_with('!') || inner.starts_with('#') {
638 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 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 output.push_str("<");
660 i += 1;
661 continue;
662 }
663
664 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 if !name.is_empty() && !name.contains(' ') {
670 let emoji_html =
671 render_emoji(&SlackEmojiName(name.clone()), &renderer.slack_references);
672 output.push_str(&emoji_html);
675 i += end + 2; continue;
677 }
678 }
679 output.push(':');
681 i += 1;
682 continue;
683 }
684
685 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 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 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 if ch == '\n' {
726 output.push_str("<br />\n");
727 i += 1;
728 continue;
729 }
730
731 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 '&' => "&".to_string(),
742 '<' => "<".to_string(),
743 '>' => ">".to_string(),
744 '"' => """.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(" ");
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 assert!(
1398 result.contains(" "),
1399 "Leading spaces after line break should be converted to , got: {result}"
1400 );
1401 assert!(
1403 result.contains(" •"),
1404 "5 leading spaces should become 5 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><script>alert(\'xss\')</script></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 & b < 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 & b");
1827 }
1828 }
1829}