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