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