//! GFM-to-Discord markdown projection.
//!
//! Discord's message markdown is close to GFM already (bold, italic,
//! strikethrough, inline/fenced code, blockquotes, and `#`/`##`/`###`
//! headings all match). The two real gaps are links — `[text](url)` is not
//! turned into a hyperlink in a plain message, only inside embeds — and
//! tables, which Discord has no syntax for at all.
pub fn markdown_to_discord(markdown: &str) -> String {
let mut out = String::with_capacity(markdown.len() + 64);
let mut in_fence = false;
let mut table = Vec::new();
let mut blank_pending = false;
for line in markdown.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
flush_table(&mut out, &mut table);
out.push_str(line);
out.push('\n');
in_fence = !in_fence;
blank_pending = false;
continue;
}
if in_fence {
out.push_str(line);
out.push('\n');
continue;
}
if trimmed.is_empty() {
flush_table(&mut out, &mut table);
blank_pending = !out.is_empty();
continue;
}
if is_spoiler(trimmed) {
flush_table(&mut out, &mut table);
if blank_pending && !out.is_empty() {
out.push('\n');
}
blank_pending = false;
out.push_str(trimmed);
out.push('\n');
continue;
}
if trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1 {
if blank_pending {
out.push('\n');
}
blank_pending = false;
table.push(trimmed.to_string());
continue;
}
flush_table(&mut out, &mut table);
if is_horizontal_rule(trimmed) {
if blank_pending {
out.push('\n');
}
blank_pending = false;
out.push_str("──────────\n");
continue;
}
if let Some(rest) = strip_task_item(trimmed) {
if blank_pending && !out.is_empty() {
out.push('\n');
}
blank_pending = false;
out.push_str(&format!("{rest}\n"));
continue;
}
if let Some(after) = trimmed.strip_prefix('#')
&& after.starts_with(' ')
{
let text = after.trim().trim_start_matches('#').trim();
if !text.is_empty() {
if blank_pending && !out.is_empty() {
out.push('\n');
}
blank_pending = false;
out.push_str("");
out.push_str(&replace_links(text));
out.push_str("\n");
continue;
}
}
if let Some(rest) = strip_task_item(trimmed) {
if blank_pending && !out.is_empty() {
out.push('\n');
}
blank_pending = false;
out.push_str(&format!("{rest}\n"));
continue;
}
if blank_pending && !out.is_empty() {
out.push('\n');
}
blank_pending = false;
out.push_str(&replace_links(line));
out.push('\n');
}
flush_table(&mut out, &mut table);
out.trim_end_matches('\n').to_string()
}
fn is_horizontal_rule(trimmed: &str) -> bool {
let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
compact.len() >= 3
&& (compact.chars().all(|c| c == '-')
|| compact.chars().all(|c| c == '*')
|| compact.chars().all(|c| c == '_'))
}
fn is_spoiler(trimmed: &str) -> bool {
trimmed.starts_with("||") && trimmed.ends_with("||") && trimmed.len() > 4
}
/// Discord suppresses the hyperlink for a plain-message `[text](url)`; it
/// only renders the text and the raw URL runs together. Render both the
/// label and a wrapped URL (`<...>` suppresses Discord's own link-preview
/// embed) so the destination is still visible and doesn't spam an embed.
fn replace_links(value: &str) -> String {
let mut out = String::new();
let mut rest = value;
while let Some(open) = rest.find('[') {
let Some(relative_text_end) = rest[open..].find("](") else {
break;
};
let text_end = open + relative_text_end;
let Some(relative_url_end) = rest[text_end..].find(')') else {
break;
};
let url_end = text_end + relative_url_end;
let url = &rest[text_end + 2..url_end];
if url.contains(['<', '>', ' ']) {
out.push_str(&rest[..open + 1]);
rest = &rest[open + 1..];
continue;
}
out.push_str(&rest[..open]);
out.push_str(&rest[open + 1..text_end]);
out.push_str(" (<");
out.push_str(url);
out.push_str(">)");
rest = &rest[url_end + 1..];
}
out.push_str(rest);
out
}
fn flush_table(out: &mut String, rows: &mut Vec) {
if rows.is_empty() {
return;
}
let parsed: Vec> = rows
.iter()
.filter(|row| !is_separator_row(row))
.map(|row| {
row.trim()
.trim_matches('|')
.split('|')
.map(|cell| cell.trim().to_string())
.collect()
})
.collect();
if parsed.is_empty() {
rows.clear();
return;
}
let columns = parsed.iter().map(|row| row.len()).max().unwrap_or(0);
let mut widths = vec![0usize; columns];
for row in &parsed {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(cell.chars().count());
}
}
out.push_str("```\n");
for (row_index, row) in parsed.iter().enumerate() {
for (index, width) in widths.iter().enumerate() {
let cell = row.get(index).map(String::as_str).unwrap_or("");
out.push_str(cell);
out.push_str(&" ".repeat(width.saturating_sub(cell.chars().count())));
if index + 1 < columns {
out.push_str(" ");
}
}
out.push('\n');
if row_index == 0 {
let underline: usize = widths.iter().sum::() + (columns.saturating_sub(1) * 2);
out.push_str(&"─".repeat(underline));
out.push('\n');
}
}
out.push_str("```\n");
rows.clear();
}
fn is_separator_row(row: &str) -> bool {
row.replace(['|', '-', ' ', ':'], "").is_empty()
}
fn strip_task_item(trimmed: &str) -> Option {
trimmed
.strip_prefix("- [ ] ")
.map(|rest| format!("☐ {}", rest))
.or_else(|| {
trimmed
.strip_prefix("- [x] ")
.map(|rest| format!("☑ {}", rest))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passes_through_native_markdown_unchanged() {
let out = markdown_to_discord("**bold** *italic* ~~strike~~ `code` ||spoiler||");
assert!(out.contains("**bold**"));
assert!(out.contains("*italic*"));
assert!(out.contains("~~strike~~"));
assert!(out.contains("`code`"));
assert!(out.contains("||spoiler||"));
}
#[test]
fn preserves_headings_and_blockquotes_and_lists() {
let out = markdown_to_discord("# Title\n\n> quoted\n- item\n1. first");
assert!(out.contains(""));
assert!(out.contains("Title"));
assert!(out.contains("> quoted"));
assert!(out.contains("- item"));
assert!(out.contains("1. first"));
}
#[test]
fn rewrites_links_with_a_visible_suppressed_url() {
let out = markdown_to_discord("[site](https://example.com)");
assert_eq!(out, "site ()");
}
#[test]
fn renders_table_as_aligned_code_block() {
let out = markdown_to_discord("| A | B |\n| - | - |\n| 1 | 22 |");
assert!(out.contains("```"));
assert!(out.contains("A"));
assert!(out.contains("22"));
}
#[test]
fn preserves_fenced_code_blocks_untouched() {
let out = markdown_to_discord("```rust\nfn main() {}\n```");
assert!(out.contains("```rust"));
assert!(out.contains("fn main() {}"));
}
#[test]
fn renders_horizontal_rule() {
let out = markdown_to_discord("above\n\n---\n\nbelow");
assert!(out.contains("──────────"));
}
}