Skip to main content

slack_markdown_converter/
lib.rs

1//! A standalone Rust library for converting standard Markdown to Slack mrkdwn format.
2//!
3//! # Examples
4//! ```rust
5//! use slack_markdown_converter::{SlackMarkdownConverter, TableRenderMode};
6//!
7//! let converter = SlackMarkdownConverter::new()
8//!     .with_table_mode(TableRenderMode::CodeBlock);
9//! let input = "# Hello This is **bold** and [a link](https://example.com)";
10//! let output = converter.convert(input);
11//! println!("{}", output);
12//! ```
13
14use regex::{Captures, Regex};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TableRenderMode {
18    /// Render Markdown tables as aligned code blocks.
19    CodeBlock,
20    /// Render Markdown tables as bullets.
21    Bulleted,
22}
23
24#[derive(Debug, Clone)]
25pub struct SlackMarkdownConverter {
26    pub table_mode: TableRenderMode,
27    /// Global max width for each table column when rendering code-block tables.
28    pub max_table_col_width: usize,
29}
30
31impl Default for SlackMarkdownConverter {
32    fn default() -> Self {
33        Self {
34            table_mode: TableRenderMode::CodeBlock,
35            max_table_col_width: 32,
36        }
37    }
38}
39
40impl SlackMarkdownConverter {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    pub fn with_table_mode(mut self, mode: TableRenderMode) -> Self {
46        self.table_mode = mode;
47        self
48    }
49
50    pub fn with_max_table_col_width(mut self, width: usize) -> Self {
51        self.max_table_col_width = width.max(4);
52        self
53    }
54
55    pub fn convert(&self, input: &str) -> String {
56        if input.is_empty() {
57            return String::new();
58        }
59        let had_trailing_newline = input.ends_with('\n');
60        let normalized = input.replace("\r\n", "\n").replace('\r', "\n");
61        let blocks = split_blocks_preserving_fenced_code(&normalized);
62
63        let mut out_blocks = Vec::new();
64
65        for block in blocks {
66            if block.trim().is_empty() {
67                continue;
68            }
69
70            if is_fenced_code_block(&block) {
71                out_blocks.push(normalize_fenced_code_block(&block));
72                continue;
73            }
74
75            if let Some(table) = MarkdownTable::parse(&block) {
76                let rendered = match self.table_mode {
77                    TableRenderMode::CodeBlock => {
78                        table.to_slack_code_block_with_max_width(self.max_table_col_width)
79                    }
80                    TableRenderMode::Bulleted => table.to_bulleted_text(),
81                };
82                out_blocks.push(rendered);
83                continue;
84            }
85
86            out_blocks.push(self.convert_text_block(&block));
87        }
88
89        let result = normalize_spacing(&out_blocks.join("\n\n"));
90        if had_trailing_newline && !result.ends_with('\n') {
91            format!("{}\n", result)
92        } else {
93            result
94        }
95    }
96
97    fn convert_text_block(&self, block: &str) -> String {
98        let protected = protect_inline_code(block);
99
100        let mut lines = Vec::new();
101        for line in protected.lines() {
102            lines.push(convert_line(line));
103        }
104
105        let s = lines.join("\n");
106        convert_inline_markdown(&s)
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MarkdownTable {
112    pub headers: Vec<String>,
113    pub alignments: Vec<ColumnAlignment>,
114    pub rows: Vec<Vec<String>>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ColumnAlignment {
119    Left,
120    Center,
121    Right,
122}
123
124impl MarkdownTable {
125    pub fn parse(block: &str) -> Option<Self> {
126        let lines: Vec<&str> = block
127            .lines()
128            .map(str::trim)
129            .filter(|line| !line.is_empty())
130            .collect();
131
132        if lines.len() < 2 {
133            return None;
134        }
135
136        let header = parse_table_row(lines[0])?;
137        let separator = parse_table_row(lines[1])?;
138
139        if header.is_empty() || separator.len() != header.len() {
140            return None;
141        }
142
143        let mut alignments = Vec::with_capacity(separator.len());
144        for cell in &separator {
145            let alignment = parse_alignment_cell(cell)?;
146            alignments.push(alignment);
147        }
148
149        let mut rows = Vec::new();
150        for line in &lines[2..] {
151            let row = parse_table_row(line)?;
152            if row.len() != header.len() {
153                return None;
154            }
155            rows.push(row);
156        }
157
158        Some(Self {
159            headers: header
160                .into_iter()
161                .map(|c| convert_inline_markdown_for_table_cell(&c))
162                .collect(),
163            alignments,
164            rows: rows
165                .into_iter()
166                .map(|row| {
167                    row.into_iter()
168                        .map(|c| convert_inline_markdown_for_table_cell(&c))
169                        .collect()
170                })
171                .collect(),
172        })
173    }
174
175    pub fn to_slack_code_block_with_max_width(&self, max_col_width: usize) -> String {
176        let cols = self.headers.len();
177
178        let truncated_headers: Vec<String> = self
179            .headers
180            .iter()
181            .map(|s| truncate_for_table(s, max_col_width))
182            .collect();
183
184        let truncated_rows: Vec<Vec<String>> = self
185            .rows
186            .iter()
187            .map(|row| {
188                row.iter()
189                    .map(|cell| truncate_for_table(cell, max_col_width))
190                    .collect()
191            })
192            .collect();
193
194        let mut widths = vec![0usize; cols];
195
196        for (i, h) in truncated_headers.iter().enumerate() {
197            widths[i] = widths[i].max(display_width(h)).min(max_col_width);
198        }
199
200        for row in &truncated_rows {
201            for (i, cell) in row.iter().enumerate() {
202                widths[i] = widths[i].max(display_width(cell)).min(max_col_width);
203            }
204        }
205
206        let header_line = format_table_row(&truncated_headers, &widths, &self.alignments);
207        let separator = widths
208            .iter()
209            .map(|w| "-".repeat(*w))
210            .collect::<Vec<_>>()
211            .join("-+-");
212
213        let mut out = String::new();
214        out.push_str("```");
215        out.push('\n');
216        out.push_str(&header_line);
217        out.push('\n');
218        out.push_str(&separator);
219
220        for row in &truncated_rows {
221            out.push('\n');
222            out.push_str(&format_table_row(row, &widths, &self.alignments));
223        }
224
225        out.push('\n');
226        out.push_str("```");
227        out
228    }
229
230    pub fn to_slack_code_block_with_column_widths(&self, max_widths: &[usize]) -> String {
231        let cols = self.headers.len();
232        assert_eq!(cols, max_widths.len());
233
234        let truncated_headers: Vec<String> = self
235            .headers
236            .iter()
237            .enumerate()
238            .map(|(i, s)| truncate_for_table(s, max_widths[i]))
239            .collect();
240
241        let truncated_rows: Vec<Vec<String>> = self
242            .rows
243            .iter()
244            .map(|row| {
245                row.iter()
246                    .enumerate()
247                    .map(|(i, cell)| truncate_for_table(cell, max_widths[i]))
248                    .collect()
249            })
250            .collect();
251
252        let mut widths = vec![0usize; cols];
253
254        for (i, h) in truncated_headers.iter().enumerate() {
255            widths[i] = display_width(h).min(max_widths[i]);
256        }
257
258        for row in &truncated_rows {
259            for (i, cell) in row.iter().enumerate() {
260                widths[i] = widths[i].max(display_width(cell)).min(max_widths[i]);
261            }
262        }
263
264        let header_line = format_table_row(&truncated_headers, &widths, &self.alignments);
265        let separator = widths
266            .iter()
267            .map(|w| "-".repeat(*w))
268            .collect::<Vec<_>>()
269            .join("-+-");
270
271        let mut out = String::new();
272        out.push_str("```");
273        out.push('\n');
274        out.push_str(&header_line);
275        out.push('\n');
276        out.push_str(&separator);
277
278        for row in &truncated_rows {
279            out.push('\n');
280            out.push_str(&format_table_row(row, &widths, &self.alignments));
281        }
282
283        out.push('\n');
284        out.push_str("```");
285        out
286    }
287
288    pub fn to_bulleted_text(&self) -> String {
289        let mut out = String::new();
290
291        for row in &self.rows {
292            out.push_str("• ");
293            for (i, cell) in row.iter().enumerate() {
294                if i > 0 {
295                    out.push_str(" | ");
296                }
297                out.push_str(&format!("*{}:* {}", self.headers[i], cell));
298            }
299            out.push('\n');
300        }
301
302        out.trim_end().to_string()
303    }
304}
305
306fn split_blocks_preserving_fenced_code(input: &str) -> Vec<String> {
307    let mut blocks = Vec::new();
308    let mut current = Vec::new();
309    let mut in_fence = false;
310
311    for line in input.split('\n') {
312        let trimmed = line.trim_start();
313
314        if trimmed.starts_with("```") {
315            in_fence = !in_fence;
316            current.push(line.to_string());
317            continue;
318        }
319
320        if !in_fence && line.trim().is_empty() {
321            if !current.is_empty() {
322                blocks.push(current.join("\n"));
323                current.clear();
324            }
325        } else {
326            current.push(line.to_string());
327        }
328    }
329
330    if !current.is_empty() {
331        blocks.push(current.join("\n"));
332    }
333
334    blocks
335}
336
337fn is_fenced_code_block(block: &str) -> bool {
338    let trimmed = block.trim();
339    if !trimmed.starts_with("```") || !trimmed.ends_with("```") {
340        return false;
341    }
342
343    trimmed.lines().count() >= 2
344}
345
346fn normalize_fenced_code_block(block: &str) -> String {
347    let trimmed = block.trim();
348    let mut lines: Vec<&str> = trimmed.lines().collect();
349    if lines.len() < 2 {
350        return block.to_string();
351    }
352
353    let first = lines[0];
354    if !first.starts_with("```") {
355        return block.to_string();
356    }
357
358    let last = lines.last().unwrap();
359    if last.trim() == "```" {
360        lines.pop();
361    }
362
363    let mut out = String::new();
364    out.push_str(first);
365    out.push('\n');
366    if lines.len() > 1 {
367        out.push_str(&lines[1..].join("\n"));
368        out.push('\n');
369    }
370    out.push_str("```");
371    out
372}
373
374fn convert_line(line: &str) -> String {
375    if line.trim().is_empty() {
376        return String::new();
377    }
378
379    if let Some(h) = convert_heading_line(line) {
380        return h;
381    }
382
383    if is_horizontal_rule(line) {
384        return "---".to_string();
385    }
386
387    if let Some(list) = convert_list_line(line) {
388        return list;
389    }
390
391    line.to_string()
392}
393
394fn convert_heading_line(line: &str) -> Option<String> {
395    let trimmed = line.trim_start();
396    let hashes = trimmed.chars().take_while(|c| *c == '#').count();
397
398    if !(1..=6).contains(&hashes) {
399        return None;
400    }
401
402    let rest = trimmed[hashes..].trim();
403    if rest.is_empty() {
404        return None;
405    }
406
407    let stripped = rest
408        .strip_prefix("**")
409        .and_then(|s| s.strip_suffix("**"))
410        .or_else(|| rest.strip_prefix("__").and_then(|s| s.strip_suffix("__")))
411        .unwrap_or(rest);
412
413    Some(format!("**{}**", stripped))
414}
415
416fn is_horizontal_rule(line: &str) -> bool {
417    let trimmed = line.trim();
418    if trimmed.len() < 3 {
419        return false;
420    }
421
422    let chars: Vec<char> = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
423    if chars.len() < 3 {
424        return false;
425    }
426
427    let first = chars[0];
428    if first != '-' && first != '*' && first != '_' {
429        return false;
430    }
431
432    chars.iter().all(|c| *c == first)
433}
434
435fn convert_list_line(line: &str) -> Option<String> {
436    let indent = line.chars().take_while(|c| c.is_whitespace()).count();
437    let trimmed = line.trim_start();
438    let indent_prefix = " ".repeat(indent);
439
440    for marker in ["- [ ] ", "* [ ] ", "+ [ ] "] {
441        if let Some(rest) = trimmed.strip_prefix(marker) {
442            return Some(format!("{indent_prefix}• ☐ {rest}"));
443        }
444    }
445
446    for marker in ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] "] {
447        if let Some(rest) = trimmed.strip_prefix(marker) {
448            return Some(format!("{indent_prefix}• ☑ {rest}"));
449        }
450    }
451
452    for marker in ["- ", "* ", "+ "] {
453        if let Some(rest) = trimmed.strip_prefix(marker) {
454            return Some(format!("{indent_prefix}• {rest}"));
455        }
456    }
457
458    if let Some((num, rest)) = parse_ordered_list_marker(trimmed) {
459        return Some(format!("{indent_prefix}{num}. {rest}"));
460    }
461
462    None
463}
464
465fn parse_ordered_list_marker(s: &str) -> Option<(usize, &str)> {
466    let bytes = s.as_bytes();
467    let mut i = 0usize;
468
469    while i < bytes.len() && bytes[i].is_ascii_digit() {
470        i += 1;
471    }
472
473    if i == 0 || i >= bytes.len() {
474        return None;
475    }
476
477    let marker = bytes[i] as char;
478    if marker != '.' && marker != ')' {
479        return None;
480    }
481
482    let n = s[..i].parse::<usize>().ok()?;
483    let rest = s[i + 1..].trim_start();
484    if rest.is_empty() {
485        return None;
486    }
487
488    Some((n, rest))
489}
490
491fn protect_inline_code(input: &str) -> String {
492    let re = Regex::new(r"`([^`\n]+)`").unwrap();
493    re.replace_all(input, |caps: &Captures| {
494        let payload = caps[1]
495            .replace('\x01', "")
496            .replace('\x02', "")
497            .replace('\x03', "")
498            .replace('\x04', "");
499        format!("\x01{}\x02", payload)
500    })
501    .to_string()
502}
503
504fn restore_inline_code(input: &str) -> String {
505    let re = Regex::new(r"\x01(.*?)\x02").unwrap();
506    re.replace_all(input, |caps: &Captures| format!("`{}`", &caps[1]))
507        .to_string()
508}
509
510fn protect_bold_segments(input: &str) -> String {
511    let re1 = Regex::new(r"\*\*(.+?)\*\*").unwrap();
512    let s = re1.replace_all(input, "\x03$1\x04").to_string();
513
514    let re2 = Regex::new(r"__(.+?)__").unwrap();
515    re2.replace_all(&s, "\x03$1\x04").to_string()
516}
517
518fn restore_bold_segments(input: &str) -> String {
519    let re = Regex::new(r"\x03(.*?)\x04").unwrap();
520    re.replace_all(input, "*$1*").to_string()
521}
522
523fn convert_inline_markdown(input: &str) -> String {
524    let mut s = protect_inline_code(input);
525    s = protect_bold_segments(&s);
526
527    s = convert_links(&s);
528    s = convert_strike(&s);
529    s = convert_italic_underscores(&s);
530    s = convert_italic_asterisks(&s);
531    s = restore_bold_segments(&s);
532    s = convert_html_breaks(&s);
533    s = restore_inline_code(&s);
534
535    s
536}
537
538fn convert_links(input: &str) -> String {
539    let re = Regex::new(r"\[([^\]]+)\]\((https?://[^)\s]+)\)").unwrap();
540    re.replace_all(input, "<$2|$1>").to_string()
541}
542
543fn convert_strike(input: &str) -> String {
544    let re = Regex::new(r"~~(.+?)~~").unwrap();
545    re.replace_all(input, "~$1~").to_string()
546}
547
548fn convert_italic_underscores(input: &str) -> String {
549    input.to_string()
550}
551
552fn convert_italic_asterisks(input: &str) -> String {
553    let re = Regex::new(
554        r"(?P<pre>^|[\s\(\[\{>])\*(?P<body>[^*\n][^*\n]*?)\*(?P<post>$|[\s\.\,\!\?\:\;\)\]\}<])",
555    )
556    .unwrap();
557    re.replace_all(input, "${pre}_$body_${post}").to_string()
558}
559
560fn convert_html_breaks(input: &str) -> String {
561    let re = Regex::new(r"(?i)<br\s*/?>").unwrap();
562    re.replace_all(input, " / ").to_string()
563}
564
565fn normalize_spacing(input: &str) -> String {
566    let had_trailing_newline = input.ends_with('\n');
567
568    let mut out = String::new();
569    let mut blank_count = 0usize;
570
571    for line in input.lines() {
572        if line.trim().is_empty() {
573            blank_count += 1;
574            if blank_count <= 1 && !out.ends_with('\n') {
575                out.push('\n');
576            }
577        } else {
578            blank_count = 0;
579            if !out.is_empty() && !out.ends_with('\n') {
580                out.push('\n');
581            }
582            out.push_str(line);
583        }
584    }
585
586    let trimmed_leading = out.trim_start_matches('\n').to_string();
587    if had_trailing_newline && !trimmed_leading.ends_with('\n') {
588        format!("{trimmed_leading}\n")
589    } else {
590        trimmed_leading
591    }
592}
593
594fn parse_table_row(line: &str) -> Option<Vec<String>> {
595    let trimmed = line.trim();
596    if !trimmed.contains('|') {
597        return None;
598    }
599
600    let core = trimmed
601        .strip_prefix('|')
602        .unwrap_or(trimmed)
603        .strip_suffix('|')
604        .unwrap_or(trimmed);
605
606    let cols = split_table_cells(core)
607        .into_iter()
608        .map(|s| s.trim().to_string())
609        .collect::<Vec<_>>();
610
611    if cols.len() < 2 {
612        return None;
613    }
614
615    Some(cols)
616}
617
618fn split_table_cells(s: &str) -> Vec<String> {
619    let mut cells = Vec::new();
620    let mut current = String::new();
621    let mut chars = s.chars().peekable();
622    let mut in_backticks = false;
623
624    while let Some(ch) = chars.next() {
625        match ch {
626            '\\' => {
627                if let Some(next) = chars.next() {
628                    current.push(next);
629                } else {
630                    current.push('\\');
631                }
632            }
633            '`' => {
634                in_backticks = !in_backticks;
635                current.push(ch);
636            }
637            '|' if !in_backticks => {
638                cells.push(current);
639                current = String::new();
640            }
641            _ => current.push(ch),
642        }
643    }
644
645    cells.push(current);
646    cells
647}
648
649fn parse_alignment_cell(cell: &str) -> Option<ColumnAlignment> {
650    let s = cell.trim();
651    if s.is_empty() {
652        return None;
653    }
654
655    if !s.chars().all(|c| c == '-' || c == ':') {
656        return None;
657    }
658
659    let dash_count = s.chars().filter(|c| *c == '-').count();
660    if dash_count < 3 {
661        return None;
662    }
663
664    let starts = s.starts_with(':');
665    let ends = s.ends_with(':');
666
667    match (starts, ends) {
668        (true, true) => Some(ColumnAlignment::Center),
669        (false, true) => Some(ColumnAlignment::Right),
670        _ => Some(ColumnAlignment::Left),
671    }
672}
673
674fn format_table_row(row: &[String], widths: &[usize], alignments: &[ColumnAlignment]) -> String {
675    row.iter()
676        .enumerate()
677        .map(|(i, cell)| align_cell(cell, widths[i], alignments[i]))
678        .collect::<Vec<_>>()
679        .join(" | ")
680}
681
682fn align_cell(cell: &str, width: usize, alignment: ColumnAlignment) -> String {
683    let len = display_width(cell);
684    let pad = width.saturating_sub(len);
685
686    match alignment {
687        ColumnAlignment::Left => format!("{cell}{}", " ".repeat(pad)),
688        ColumnAlignment::Right => format!("{}{cell}", " ".repeat(pad)),
689        ColumnAlignment::Center => {
690            let left = pad / 2;
691            let right = pad - left;
692            format!("{}{}{}", " ".repeat(left), cell, " ".repeat(right))
693        }
694    }
695}
696
697fn display_width(s: &str) -> usize {
698    s.chars().count()
699}
700
701fn truncate_for_table(s: &str, max_width: usize) -> String {
702    let text = s.trim();
703
704    if max_width <= 1 {
705        return "…".to_string();
706    }
707
708    let chars: Vec<char> = text.chars().collect();
709    if chars.len() <= max_width {
710        return text.to_string();
711    }
712
713    let keep = max_width - 1;
714    let mut out: String = chars.into_iter().take(keep).collect();
715    out.push('…');
716    out
717}
718
719fn convert_inline_markdown_for_table_cell(cell: &str) -> String {
720    convert_inline_markdown(cell)
721}