2026-03-27 15:26:38 +00:00
|
|
|
//! Markdown to Slack mrkdwn conversion.
|
|
|
|
|
|
|
|
|
|
/// Convert Markdown text to Slack mrkdwn format.
|
|
|
|
|
///
|
|
|
|
|
/// Slack uses its own "mrkdwn" syntax which differs from standard Markdown.
|
|
|
|
|
/// This function converts common Markdown constructs so messages render
|
|
|
|
|
/// nicely in Slack instead of showing raw Markdown syntax.
|
|
|
|
|
pub fn markdown_to_slack(text: &str) -> String {
|
2026-03-28 10:39:13 +00:00
|
|
|
use crate::chat::util::normalize_line_breaks;
|
2026-03-27 15:26:38 +00:00
|
|
|
use regex::Regex;
|
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
|
|
2026-03-28 10:39:13 +00:00
|
|
|
let normalized = normalize_line_breaks(text);
|
|
|
|
|
let text = normalized.as_str();
|
|
|
|
|
|
2026-03-27 15:26:38 +00:00
|
|
|
// Regexes are compiled once and reused across calls.
|
|
|
|
|
static RE_FENCED_BLOCK: LazyLock<Regex> =
|
|
|
|
|
LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap());
|
|
|
|
|
static RE_HEADER: LazyLock<Regex> =
|
|
|
|
|
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
|
|
|
|
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
|
|
|
|
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
2026-04-13 14:07:08 +00:00
|
|
|
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
|
|
|
|
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
2026-03-27 15:26:38 +00:00
|
|
|
static RE_LINK: LazyLock<Regex> =
|
|
|
|
|
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
|
|
|
|
|
|
|
|
|
// 1. Protect fenced code blocks by replacing them with placeholders.
|
|
|
|
|
let mut code_blocks: Vec<String> = Vec::new();
|
|
|
|
|
let protected = RE_FENCED_BLOCK.replace_all(text, |caps: ®ex::Captures| {
|
|
|
|
|
let idx = code_blocks.len();
|
|
|
|
|
code_blocks.push(caps[0].to_string());
|
|
|
|
|
format!("\x00CODEBLOCK{idx}\x00")
|
|
|
|
|
});
|
|
|
|
|
let mut out = protected.into_owned();
|
|
|
|
|
|
|
|
|
|
// 2. Headers → bold text.
|
|
|
|
|
out = RE_HEADER.replace_all(&out, "*$1*").into_owned();
|
|
|
|
|
|
|
|
|
|
// 3. Bold+italic (***text***) → bold italic (*_text_*).
|
|
|
|
|
out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned();
|
|
|
|
|
|
|
|
|
|
// 4. Bold (**text**) → Slack bold (*text*).
|
|
|
|
|
out = RE_BOLD.replace_all(&out, "*$1*").into_owned();
|
|
|
|
|
|
|
|
|
|
// 5. Strikethrough (~~text~~) → Slack strikethrough (~text~).
|
|
|
|
|
out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned();
|
|
|
|
|
|
|
|
|
|
// 6. Links [text](url) → Slack mrkdwn format <url|text>.
|
|
|
|
|
out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned();
|
|
|
|
|
|
|
|
|
|
// 7. Restore code blocks.
|
|
|
|
|
for (idx, block) in code_blocks.iter().enumerate() {
|
|
|
|
|
out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_headers_become_bold() {
|
|
|
|
|
assert_eq!(markdown_to_slack("# Title"), "*Title*");
|
|
|
|
|
assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*");
|
|
|
|
|
assert_eq!(markdown_to_slack("### Section"), "*Section*");
|
|
|
|
|
assert_eq!(markdown_to_slack("###### Deep"), "*Deep*");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_bold_converted() {
|
|
|
|
|
assert_eq!(markdown_to_slack("**bold text**"), "*bold text*");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_bold_italic_converted() {
|
|
|
|
|
assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_strikethrough_converted() {
|
|
|
|
|
assert_eq!(markdown_to_slack("~~removed~~"), "~removed~");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_links_converted_to_mrkdwn() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
markdown_to_slack("[click here](https://example.com)"),
|
|
|
|
|
"<https://example.com|click here>"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_inline_code_preserved() {
|
|
|
|
|
assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_fenced_code_block_preserved() {
|
|
|
|
|
let input = "```rust\nlet x = 1;\n```";
|
|
|
|
|
let output = markdown_to_slack(input);
|
2026-04-13 14:07:08 +00:00
|
|
|
assert!(
|
|
|
|
|
output.contains("let x = 1;"),
|
|
|
|
|
"code block content must be preserved"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("```"),
|
|
|
|
|
"fenced code delimiters must be preserved"
|
|
|
|
|
);
|
2026-03-27 15:26:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_code_block_content_not_transformed() {
|
|
|
|
|
let input = "```\n**not bold** # not header\n```";
|
|
|
|
|
let output = markdown_to_slack(input);
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("**not bold**"),
|
|
|
|
|
"markdown inside code blocks must not be transformed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_plain_text_unchanged() {
|
|
|
|
|
let plain = "Hello, this is a plain message with no formatting.";
|
|
|
|
|
assert_eq!(markdown_to_slack(plain), plain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slack_empty_string_unchanged() {
|
|
|
|
|
assert_eq!(markdown_to_slack(""), "");
|
|
|
|
|
}
|
|
|
|
|
}
|