- 1
//! GFM-to-Discord markdown projection. - 2
//! - 3
//! Discord's message markdown is close to GFM already (bold, italic, - 4
//! strikethrough, inline/fenced code, blockquotes, and `#`/`##`/`###` - 5
//! headings all match). The two real gaps are links — `[text](url)` is not - 6
//! turned into a hyperlink in a plain message, only inside embeds — and - 7
//! tables, which Discord has no syntax for at all. - 8
pub fn markdown_to_discord(markdown: &str) -> String { - 9
let mut out = String::with_capacity(markdown.len() + 64); - 10
let mut in_fence = false; - 11
let mut table = Vec::new(); - 12
let mut blank_pending = false; - 13
- 14
for line in markdown.lines() { - 15
let trimmed = line.trim_start(); - 16
- 17
if trimmed.starts_with("```") { - 18
flush_table(&mut out, &mut table); - 19
out.push_str(line); - 20
out.push('\n'); - 21
in_fence = !in_fence; - 22
blank_pending = false; - 23
continue; - 24
} - 25
if in_fence { - 26
out.push_str(line); - 27
out.push('\n'); - 28
continue; - 29
} - 30
- 31
if trimmed.is_empty() { - 32
flush_table(&mut out, &mut table); - 33
blank_pending = !out.is_empty(); - 34
continue; - 35
} - 36
- 37
if is_spoiler(trimmed) { - 38
flush_table(&mut out, &mut table); - 39
if blank_pending && !out.is_empty() { - 40
out.push('\n'); - 41
} - 42
blank_pending = false; - 43
out.push_str(trimmed); - 44
out.push('\n'); - 45
continue; - 46
} - 47
- 48
if trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1 { - 49
if blank_pending { - 50
out.push('\n'); - 51
} - 52
blank_pending = false; - 53
table.push(trimmed.to_string()); - 54
continue; - 55
} - 56
flush_table(&mut out, &mut table); - 57
- 58
if is_horizontal_rule(trimmed) { - 59
if blank_pending { - 60
out.push('\n'); - 61
} - 62
blank_pending = false; - 63
out.push_str("──────────\n"); - 64
continue; - 65
} - 66
- 67
if let Some(rest) = strip_task_item(trimmed) { - 68
if blank_pending && !out.is_empty() { - 69
out.push('\n'); - 70
} - 71
blank_pending = false; - 72
out.push_str(&format!("{rest}\n")); - 73
continue; - 74
} - 75
- 76
if let Some(after) = trimmed.strip_prefix('#') - 77
&& after.starts_with(' ') - 78
{ - 79
let text = after.trim().trim_start_matches('#').trim(); - 80
if !text.is_empty() { - 81
if blank_pending && !out.is_empty() { - 82
out.push('\n'); - 83
} - 84
blank_pending = false; - 85
out.push_str("<b>"); - 86
out.push_str(&replace_links(text)); - 87
out.push_str("</b>\n"); - 88
continue; - 89
} - 90
} - 91
- 92
if let Some(rest) = strip_task_item(trimmed) { - 93
if blank_pending && !out.is_empty() { - 94
out.push('\n'); - 95
} - 96
blank_pending = false; - 97
out.push_str(&format!("{rest}\n")); - 98
continue; - 99
} - 100
- 101
if blank_pending && !out.is_empty() { - 102
out.push('\n'); - 103
} - 104
blank_pending = false; - 105
out.push_str(&replace_links(line)); - 106
out.push('\n'); - 107
} - 108
flush_table(&mut out, &mut table); - 109
out.trim_end_matches('\n').to_string() - 110
} - 111
- 112
fn is_horizontal_rule(trimmed: &str) -> bool { - 113
let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect(); - 114
compact.len() >= 3 - 115
&& (compact.chars().all(|c| c == '-') - 116
|| compact.chars().all(|c| c == '*') - 117
|| compact.chars().all(|c| c == '_')) - 118
} - 119
- 120
fn is_spoiler(trimmed: &str) -> bool { - 121
trimmed.starts_with("||") && trimmed.ends_with("||") && trimmed.len() > 4 - 122
} - 123
- 124
/// Discord suppresses the hyperlink for a plain-message `[text](url)`; it - 125
/// only renders the text and the raw URL runs together. Render both the - 126
/// label and a wrapped URL (`<...>` suppresses Discord's own link-preview - 127
/// embed) so the destination is still visible and doesn't spam an embed. - 128
fn replace_links(value: &str) -> String { - 129
let mut out = String::new(); - 130
let mut rest = value; - 131
while let Some(open) = rest.find('[') { - 132
let Some(relative_text_end) = rest[open..].find("](") else { - 133
break; - 134
}; - 135
let text_end = open + relative_text_end; - 136
let Some(relative_url_end) = rest[text_end..].find(')') else { - 137
break; - 138
}; - 139
let url_end = text_end + relative_url_end; - 140
let url = &rest[text_end + 2..url_end]; - 141
if url.contains(['<', '>', ' ']) { - 142
out.push_str(&rest[..open + 1]); - 143
rest = &rest[open + 1..]; - 144
continue; - 145
} - 146
out.push_str(&rest[..open]); - 147
out.push_str(&rest[open + 1..text_end]); - 148
out.push_str(" (<"); - 149
out.push_str(url); - 150
out.push_str(">)"); - 151
rest = &rest[url_end + 1..]; - 152
} - 153
out.push_str(rest); - 154
out - 155
} - 156
- 157
fn flush_table(out: &mut String, rows: &mut Vec<String>) { - 158
if rows.is_empty() { - 159
return; - 160
} - 161
let parsed: Vec<Vec<String>> = rows - 162
.iter() - 163
.filter(|row| !is_separator_row(row)) - 164
.map(|row| { - 165
row.trim() - 166
.trim_matches('|') - 167
.split('|') - 168
.map(|cell| cell.trim().to_string()) - 169
.collect() - 170
}) - 171
.collect(); - 172
- 173
if parsed.is_empty() { - 174
rows.clear(); - 175
return; - 176
} - 177
- 178
let columns = parsed.iter().map(|row| row.len()).max().unwrap_or(0); - 179
let mut widths = vec![0usize; columns]; - 180
for row in &parsed { - 181
for (index, cell) in row.iter().enumerate() { - 182
widths[index] = widths[index].max(cell.chars().count()); - 183
} - 184
} - 185
- 186
out.push_str("```\n"); - 187
for (row_index, row) in parsed.iter().enumerate() { - 188
for (index, width) in widths.iter().enumerate() { - 189
let cell = row.get(index).map(String::as_str).unwrap_or(""); - 190
out.push_str(cell); - 191
out.push_str(&" ".repeat(width.saturating_sub(cell.chars().count()))); - 192
if index + 1 < columns { - 193
out.push_str(" "); - 194
} - 195
} - 196
out.push('\n'); - 197
if row_index == 0 { - 198
let underline: usize = widths.iter().sum::<usize>() + (columns.saturating_sub(1) * 2); - 199
out.push_str(&"─".repeat(underline)); - 200
out.push('\n'); - 201
} - 202
} - 203
out.push_str("```\n"); - 204
rows.clear(); - 205
} - 206
- 207
fn is_separator_row(row: &str) -> bool { - 208
row.replace(['|', '-', ' ', ':'], "").is_empty() - 209
} - 210
- 211
fn strip_task_item(trimmed: &str) -> Option<String> { - 212
trimmed - 213
.strip_prefix("- [ ] ") - 214
.map(|rest| format!("☐ {}", rest)) - 215
.or_else(|| { - 216
trimmed - 217
.strip_prefix("- [x] ") - 218
.map(|rest| format!("☑ {}", rest)) - 219
}) - 220
} - 221
- 222
#[cfg(test)] - 223
mod tests { - 224
use super::*; - 225
- 226
#[test] - 227
fn passes_through_native_markdown_unchanged() { - 228
let out = markdown_to_discord("**bold** *italic* ~~strike~~ `code` ||spoiler||"); - 229
assert!(out.contains("**bold**")); - 230
assert!(out.contains("*italic*")); - 231
assert!(out.contains("~~strike~~")); - 232
assert!(out.contains("`code`")); - 233
assert!(out.contains("||spoiler||")); - 234
} - 235
- 236
#[test] - 237
fn preserves_headings_and_blockquotes_and_lists() { - 238
let out = markdown_to_discord("# Title\n\n> quoted\n- item\n1. first"); - 239
assert!(out.contains("<b>")); - 240
assert!(out.contains("Title")); - 241
assert!(out.contains("> quoted")); - 242
assert!(out.contains("- item")); - 243
assert!(out.contains("1. first")); - 244
} - 245
- 246
#[test] - 247
fn rewrites_links_with_a_visible_suppressed_url() { - 248
let out = markdown_to_discord("[site](https://example.com)"); - 249
assert_eq!(out, "site (<https://example.com>)"); - 250
} - 251
- 252
#[test] - 253
fn renders_table_as_aligned_code_block() { - 254
let out = markdown_to_discord("| A | B |\n| - | - |\n| 1 | 22 |"); - 255
assert!(out.contains("```")); - 256
assert!(out.contains("A")); - 257
assert!(out.contains("22")); - 258
} - 259
- 260
#[test] - 261
fn preserves_fenced_code_blocks_untouched() { - 262
let out = markdown_to_discord("```rust\nfn main() {}\n```"); - 263
assert!(out.contains("```rust")); - 264
assert!(out.contains("fn main() {}")); - 265
} - 266
- 267
#[test] - 268
fn renders_horizontal_rule() { - 269
let out = markdown_to_discord("above\n\n---\n\nbelow"); - 270
assert!(out.contains("──────────")); - 271
} - 272
} - 273
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.