- 1
//! GFM-to-Slack `mrkdwn` projection. - 2
//! - 3
//! Slack's `mrkdwn` dialect diverges from GFM in exactly the spots that - 4
//! matter most: bold uses a single `*`, italic uses `_`, links use - 5
//! `<url|text>`, there is no heading syntax and no table syntax at all. - 6
//! This module renders a conservative GFM subset into text that actually - 7
//! looks right when Slack's client parses it. - 8
- 9
/// Convert a conservative GFM subset to Slack `mrkdwn`. - 10
pub fn markdown_to_mrkdwn(markdown: &str) -> String { - 11
let mut out = String::with_capacity(markdown.len() + 64); - 12
let mut in_fence = false; - 13
let mut fence = Vec::new(); - 14
let mut table = Vec::new(); - 15
let mut blank_pending = false; - 16
- 17
for line in markdown.lines() { - 18
let trimmed = line.trim_start(); - 19
- 20
if trimmed.starts_with("```") { - 21
flush_table(&mut out, &mut table); - 22
if in_fence { - 23
out.push_str("```\n"); - 24
out.push_str(&fence.join("\n")); - 25
out.push_str("\n```\n"); - 26
fence.clear(); - 27
} - 28
in_fence = !in_fence; - 29
blank_pending = false; - 30
continue; - 31
} - 32
if in_fence { - 33
fence.push(line.to_string()); - 34
continue; - 35
} - 36
- 37
if trimmed.is_empty() { - 38
flush_table(&mut out, &mut table); - 39
blank_pending = !out.is_empty(); - 40
continue; - 41
} - 42
- 43
if is_spoiler(trimmed) { - 44
flush_table(&mut out, &mut table); - 45
if blank_pending && !out.is_empty() { - 46
out.push('\n'); - 47
} - 48
blank_pending = false; - 49
out.push_str(trimmed); - 50
out.push('\n'); - 51
continue; - 52
} - 53
- 54
if trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1 { - 55
if blank_pending { - 56
out.push('\n'); - 57
} - 58
blank_pending = false; - 59
table.push(trimmed.to_string()); - 60
continue; - 61
} - 62
flush_table(&mut out, &mut table); - 63
- 64
if is_horizontal_rule(trimmed) { - 65
if blank_pending { - 66
out.push('\n'); - 67
} - 68
blank_pending = false; - 69
out.push_str("──────────\n"); - 70
continue; - 71
} - 72
- 73
if let Some(after) = trimmed.strip_prefix('#') - 74
&& after.starts_with(' ') - 75
{ - 76
let text = after.trim().trim_start_matches('#').trim(); - 77
if !text.is_empty() { - 78
if blank_pending && !out.is_empty() { - 79
out.push('\n'); - 80
} - 81
blank_pending = false; - 82
out.push('*'); - 83
out.push_str(&inline_mrkdwn(text)); - 84
out.push_str("*\n"); - 85
continue; - 86
} - 87
} - 88
- 89
if let Some(quote) = trimmed.strip_prefix("> ") { - 90
blank_pending = false; - 91
out.push_str("> "); - 92
out.push_str(&inline_mrkdwn(quote)); - 93
out.push('\n'); - 94
continue; - 95
} - 96
- 97
let indent = line.len() - trimmed.len(); - 98
let depth = indent / 2; - 99
- 100
if let Some(rest) = strip_task_item(trimmed) { - 101
blank_pending = false; - 102
out.push_str(&" ".repeat(depth)); - 103
out.push_str(bullet_for_depth(depth)); - 104
out.push(' '); - 105
out.push_str(&inline_mrkdwn(&rest)); - 106
out.push('\n'); - 107
continue; - 108
} - 109
- 110
if let Some(item) = trimmed - 111
.strip_prefix("- ") - 112
.or_else(|| trimmed.strip_prefix("* ")) - 113
.or_else(|| trimmed.strip_prefix("+ ")) - 114
{ - 115
blank_pending = false; - 116
out.push_str(&" ".repeat(depth)); - 117
out.push_str(bullet_for_depth(depth)); - 118
out.push(' '); - 119
out.push_str(&inline_mrkdwn(item)); - 120
out.push('\n'); - 121
continue; - 122
} - 123
- 124
if let Some((number, rest)) = split_ordered_item(trimmed) { - 125
blank_pending = false; - 126
out.push_str(&" ".repeat(depth)); - 127
out.push_str(&number); - 128
out.push_str(". "); - 129
out.push_str(&inline_mrkdwn(rest)); - 130
out.push('\n'); - 131
continue; - 132
} - 133
- 134
if blank_pending && !out.is_empty() { - 135
out.push('\n'); - 136
} - 137
blank_pending = false; - 138
out.push_str(&inline_mrkdwn(line)); - 139
out.push('\n'); - 140
} - 141
if in_fence { - 142
out.push_str("```\n"); - 143
out.push_str(&fence.join("\n")); - 144
out.push_str("\n```\n"); - 145
} - 146
flush_table(&mut out, &mut table); - 147
out.trim_end_matches('\n').to_string() - 148
} - 149
- 150
fn is_horizontal_rule(trimmed: &str) -> bool { - 151
let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect(); - 152
compact.len() >= 3 - 153
&& (compact.chars().all(|c| c == '-') - 154
|| compact.chars().all(|c| c == '*') - 155
|| compact.chars().all(|c| c == '_')) - 156
} - 157
- 158
fn is_spoiler(trimmed: &str) -> bool { - 159
trimmed.starts_with("||") && trimmed.ends_with("||") && trimmed.len() > 4 - 160
} - 161
- 162
fn split_ordered_item(trimmed: &str) -> Option<(String, &str)> { - 163
let digits_end = trimmed.find(|c: char| !c.is_ascii_digit())?; - 164
if digits_end == 0 { - 165
return None; - 166
} - 167
let (number, rest) = trimmed.split_at(digits_end); - 168
let rest = rest - 169
.strip_prefix(". ") - 170
.or_else(|| rest.strip_prefix(") "))?; - 171
Some((number.to_string(), rest)) - 172
} - 173
- 174
fn bullet_for_depth(depth: usize) -> &'static str { - 175
match depth % 3 { - 176
0 => "•", - 177
1 => "◦", - 178
_ => "▪", - 179
} - 180
} - 181
- 182
fn strip_task_item(trimmed: &str) -> Option<String> { - 183
trimmed - 184
.strip_prefix("- [ ] ") - 185
.map(|rest| format!("☐ {}", rest)) - 186
.or_else(|| { - 187
trimmed - 188
.strip_prefix("- [x] ") - 189
.map(|rest| format!("☑ {}", rest)) - 190
}) - 191
} - 192
- 193
fn flush_table(out: &mut String, rows: &mut Vec<String>) { - 194
if rows.is_empty() { - 195
return; - 196
} - 197
let parsed: Vec<Vec<String>> = rows - 198
.iter() - 199
.filter(|row| !is_separator_row(row)) - 200
.map(|row| { - 201
row.trim() - 202
.trim_matches('|') - 203
.split('|') - 204
.map(|cell| cell.trim().to_string()) - 205
.collect() - 206
}) - 207
.collect(); - 208
- 209
if parsed.is_empty() { - 210
rows.clear(); - 211
return; - 212
} - 213
- 214
let columns = parsed.iter().map(|row| row.len()).max().unwrap_or(0); - 215
let mut widths = vec![0usize; columns]; - 216
for row in &parsed { - 217
for (index, cell) in row.iter().enumerate() { - 218
widths[index] = widths[index].max(cell.chars().count()); - 219
} - 220
} - 221
- 222
out.push_str("```\n"); - 223
for (row_index, row) in parsed.iter().enumerate() { - 224
for (index, width) in widths.iter().enumerate() { - 225
let cell = row.get(index).map(String::as_str).unwrap_or(""); - 226
out.push_str(cell); - 227
out.push_str(&" ".repeat(width.saturating_sub(cell.chars().count()))); - 228
if index + 1 < columns { - 229
out.push_str(" "); - 230
} - 231
} - 232
out.push('\n'); - 233
if row_index == 0 { - 234
let underline: usize = widths.iter().sum::<usize>() + (columns.saturating_sub(1) * 2); - 235
out.push_str(&"─".repeat(underline)); - 236
out.push('\n'); - 237
} - 238
} - 239
out.push_str("```\n"); - 240
rows.clear(); - 241
} - 242
- 243
fn is_separator_row(row: &str) -> bool { - 244
row.replace(['|', '-', ' ', ':'], "").is_empty() - 245
} - 246
- 247
/// Slack's mrkdwn has only three literal characters that must be escaped - 248
/// outside of code spans: `&`, `<`, `>`. - 249
fn escape_mrkdwn(value: &str) -> String { - 250
value - 251
.replace('&', "&") - 252
.replace('<', "<") - 253
.replace('>', ">") - 254
} - 255
- 256
fn inline_mrkdwn(value: &str) -> String { - 257
let mut codes = Vec::new(); - 258
let value = replace_code_spans(value, &mut codes); - 259
let mut value = escape_mrkdwn(&value); - 260
value = replace_links(&value); - 261
value = replace_pairs(&value, "~~", "~", "~"); - 262
// Slack has no native spoiler syntax; ||spoiler|| passes through as - 263
// literal text (best-effort — the line-level handler also passes it - 264
// through so the markup is preserved rather than lost). - 265
// GFM bold (** or __) -> Slack bold (*); GFM italic (* or _) -> Slack - 266
// italic (_). Bold must be swapped before italic so a leftover single - 267
// `*`/`_` is read as the italic marker, not a stray bold delimiter. - 268
value = replace_pairs(&value, "**", "\u{1}", "\u{1}"); - 269
value = replace_pairs(&value, "__", "\u{1}", "\u{1}"); - 270
value = replace_spaced_pairs(&value, "*", "_", "_"); - 271
value = replace_spaced_pairs(&value, "_", "_", "_"); - 272
value = value.replace('\u{1}', "*"); - 273
for (index, code) in codes.iter().enumerate() { - 274
value = value.replace( - 275
&format!("\u{0}{index}\u{0}"), - 276
&format!("`{}`", code.replace('`', "'")), - 277
); - 278
} - 279
value - 280
} - 281
- 282
fn replace_code_spans(value: &str, sink: &mut Vec<String>) -> String { - 283
let mut out = String::new(); - 284
let mut rest = value; - 285
while let Some((before, after)) = rest.split_once('`') { - 286
match after.split_once('`') { - 287
Some((content, tail)) => { - 288
sink.push(content.to_string()); - 289
out.push_str(before); - 290
out.push_str(&format!("\u{0}{}\u{0}", sink.len() - 1)); - 291
rest = tail; - 292
} - 293
None => { - 294
out.push_str(before); - 295
out.push('`'); - 296
rest = after; - 297
} - 298
} - 299
} - 300
out.push_str(rest); - 301
out - 302
} - 303
- 304
fn replace_links(value: &str) -> String { - 305
let mut out = String::new(); - 306
let mut rest = value; - 307
while let Some(open) = rest.find('[') { - 308
let Some(relative_text_end) = rest[open..].find("](") else { - 309
break; - 310
}; - 311
let text_end = open + relative_text_end; - 312
let Some(relative_url_end) = rest[text_end..].find(')') else { - 313
break; - 314
}; - 315
let url_end = text_end + relative_url_end; - 316
let url = &rest[text_end + 2..url_end]; - 317
if url.contains(['"', '<', '>', ' ']) { - 318
out.push_str(&rest[..open + 1]); - 319
rest = &rest[open + 1..]; - 320
continue; - 321
} - 322
out.push_str(&rest[..open]); - 323
out.push('<'); - 324
out.push_str(url); - 325
out.push('|'); - 326
out.push_str(&rest[open + 1..text_end]); - 327
out.push('>'); - 328
rest = &rest[url_end + 1..]; - 329
} - 330
out.push_str(rest); - 331
out - 332
} - 333
- 334
fn replace_pairs(value: &str, marker: &str, open: &str, close: &str) -> String { - 335
let mut out = String::new(); - 336
let mut rest = value; - 337
loop { - 338
match rest.split_once(marker) { - 339
Some((before, after)) => match after.split_once(marker) { - 340
Some((inner, tail)) if !inner.is_empty() => { - 341
out.push_str(before); - 342
out.push_str(open); - 343
out.push_str(inner); - 344
out.push_str(close); - 345
rest = tail; - 346
} - 347
_ => { - 348
out.push_str(before); - 349
out.push_str(marker); - 350
rest = after; - 351
} - 352
}, - 353
None => { - 354
out.push_str(rest); - 355
return out; - 356
} - 357
} - 358
} - 359
} - 360
- 361
fn replace_spaced_pairs(value: &str, marker: &str, open: &str, close: &str) -> String { - 362
let mut out = String::new(); - 363
let mut rest = value; - 364
loop { - 365
match rest.split_once(marker) { - 366
Some((before, after)) => { - 367
let opens = after.chars().next().is_some_and(|c| !c.is_whitespace()); - 368
let boundary = before.is_empty() - 369
|| before.chars().last().is_some_and(|c| !c.is_alphanumeric()); - 370
match after.split_once(marker) { - 371
Some((inner, tail)) if opens && boundary && !inner.trim().is_empty() => { - 372
out.push_str(before); - 373
out.push_str(open); - 374
out.push_str(inner.trim_end_matches(marker)); - 375
out.push_str(close); - 376
rest = tail; - 377
} - 378
_ => { - 379
out.push_str(before); - 380
out.push_str(marker); - 381
rest = after; - 382
} - 383
} - 384
} - 385
None => { - 386
out.push_str(rest); - 387
return out; - 388
} - 389
} - 390
} - 391
} - 392
- 393
#[cfg(test)] - 394
mod tests { - 395
use super::*; - 396
- 397
#[test] - 398
fn converts_bold_and_italic_conventions() { - 399
let out = markdown_to_mrkdwn("**bold** and *italic* and _also italic_"); - 400
assert!(out.contains("*bold*")); - 401
assert!(out.contains("_italic_")); - 402
assert!(out.contains("_also italic_")); - 403
} - 404
- 405
#[test] - 406
fn converts_links_to_angle_pipe_form() { - 407
let out = markdown_to_mrkdwn("[site](https://example.com)"); - 408
assert_eq!(out, "<https://example.com|site>"); - 409
} - 410
- 411
#[test] - 412
fn headings_become_bold_lines() { - 413
let out = markdown_to_mrkdwn("# Title\n\nbody"); - 414
assert!(out.starts_with("*Title*")); - 415
} - 416
- 417
#[test] - 418
fn renders_bullets_and_ordered_items() { - 419
let out = markdown_to_mrkdwn("- one\n- two\n1. first\n2. second"); - 420
assert!(out.contains("• one")); - 421
assert!(out.contains("• two")); - 422
assert!(out.contains("1. first")); - 423
assert!(out.contains("2. second")); - 424
} - 425
- 426
#[test] - 427
fn renders_table_as_aligned_code_block() { - 428
let out = markdown_to_mrkdwn("| A | B |\n| - | - |\n| 1 | 22 |"); - 429
assert!(out.contains("```")); - 430
assert!(out.contains("A")); - 431
assert!(out.contains("22")); - 432
} - 433
- 434
#[test] - 435
fn strikethrough_and_code_spans_convert() { - 436
let out = markdown_to_mrkdwn("~~gone~~ and `code`"); - 437
assert!(out.contains("~gone~")); - 438
assert!(out.contains("`code`")); - 439
} - 440
- 441
#[test] - 442
fn escapes_ampersand_and_angle_brackets_outside_code() { - 443
let out = markdown_to_mrkdwn("A & B < C > D"); - 444
assert!(out.contains("&")); - 445
assert!(out.contains("<")); - 446
assert!(out.contains(">")); - 447
} - 448
} - 449
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.