use std::collections::{BTreeMap, HashMap};
use markdown::{ParseOptions, mdast as md, to_mdast};
pub fn to_bbcode(md_text: &str) -> String {
let root = to_mdast(md_text, &ParseOptions::gfm()).unwrap();
let definitions = root
.children()
.expect("Markdown root node should always have children")
.iter()
.filter_map(|node| match node {
md::Node::Definition(def) => Some((&*def.identifier, &*def.url)),
_ => None,
})
.collect::<HashMap<_, _>>();
let mut converter = BBCodeConverter::new(&definitions);
let content = converter.walk_node(&root, 0).unwrap_or_default();
if !converter.footnote_defs.is_empty() {
let notes = converter
.footnote_defs
.iter()
.map(|(idx, text)| format!("{} {}", BBCodeConverter::superscript(*idx), text))
.collect::<Vec<_>>()
.join("[br]");
format!("{content}[br][br]{notes}")
} else {
content
}
}
pub struct BBCodeConverter<'a> {
link_reference_map: &'a HashMap<&'a str, &'a str>,
footnote_map: HashMap<String, usize>,
footnote_defs: BTreeMap<usize, String>,
current_footnote_index: usize,
}
fn join_if_not_empty(strings: &[String], sep: &str) -> Option<String> {
if strings.is_empty() {
None
} else {
Some(strings.join(sep))
}
}
impl<'a> BBCodeConverter<'a> {
pub fn new(link_reference_map: &'a HashMap<&'a str, &'a str>) -> Self {
Self {
link_reference_map,
footnote_map: HashMap::new(),
footnote_defs: BTreeMap::new(),
current_footnote_index: 0,
}
}
pub fn walk_node(&mut self, node: &md::Node, level: usize) -> Option<String> {
use md::Node::*;
let result = match node {
Root(md::Root { children, .. }) => {
let block_strs: Vec<_> = children
.iter()
.filter_map(|child| self.walk_node(child, level))
.collect();
join_if_not_empty(&block_strs, "[br][br]")?
}
Paragraph(md::Paragraph { children, .. }) => self.walk_inline_nodes(children, level),
InlineCode(md::InlineCode { value, .. }) => format!("[code]{value}[/code]"),
Delete(md::Delete { children, .. }) => {
let inner = self.walk_inline_nodes(children, level);
format!("[s]{inner}[/s]")
}
Emphasis(md::Emphasis { children, .. }) => {
let inner = self.walk_inline_nodes(children, level);
format!("[i]{inner}[/i]")
}
Strong(md::Strong { children, .. }) => {
let inner = self.walk_inline_nodes(children, level);
format!("[b]{inner}[/b]")
}
Text(md::Text { value, .. }) => value.replace("\n", " "),
Heading(md::Heading { children, .. }) => {
let inner = self.walk_inline_nodes(children, level);
format!("[b]{inner}[/b]")
}
Blockquote(md::Blockquote { children, .. }) => {
let child_blocks: Vec<_> = children
.iter()
.filter_map(|child| self.walk_node(child, level))
.collect();
let content = child_blocks.join("[br]");
let mut out = String::new();
for (i, line) in content.split("[br]").enumerate() {
if i > 0 {
out.push_str("[br]");
}
out.push_str("> ");
out.push_str(line);
}
out
}
Code(md::Code { value, lang, .. }) => {
let maybe_lang = lang
.as_ref()
.map(|l| format!(" lang={l}"))
.unwrap_or_default();
format!("[codeblock{maybe_lang}]{value}[/codeblock]")
}
List(md::List {
ordered,
start,
children,
..
}) => {
let indent = " ".repeat(level * 4);
let mut counter = start.unwrap_or(1) - 1;
let mut lines = Vec::new();
for item_node in children.iter() {
if let md::Node::ListItem(item) = item_node {
let item_str = self.walk_nodes_as_block(&item.children, level + 1);
let bullet = if *ordered {
counter += 1;
format!("{counter}.")
} else {
"•".to_string()
};
let checkbox = match item.checked {
Some(true) => "[x] ",
Some(false) => "[ ] ",
None => "",
};
lines.push(format!("{indent}{bullet} {checkbox}{item_str}"));
}
}
join_if_not_empty(&lines, "[br]")?
}
FootnoteReference(md::FootnoteReference { label, .. }) => {
if let Some(label) = label {
let idx = *self.footnote_map.entry(label.clone()).or_insert_with(|| {
self.current_footnote_index += 1;
self.current_footnote_index
});
Self::superscript(idx)
} else {
return None;
}
}
FootnoteDefinition(md::FootnoteDefinition {
label, children, ..
}) => {
if let Some(label) = label {
let idx = *self.footnote_map.entry(label.clone()).or_insert_with(|| {
self.current_footnote_index += 1;
self.current_footnote_index
});
let def_content = self.walk_nodes_as_block(children, level);
self.footnote_defs.insert(idx, def_content);
}
return None;
}
Image(md::Image { url, .. }) => format!("[url={url}]{url}[/url]"),
ImageReference(md::ImageReference { identifier, .. }) => {
let url = self.link_reference_map.get(&**identifier).unwrap_or(&"");
format!("[url={url}]{url}[/url]")
}
Link(md::Link { url, children, .. }) => {
let inner = self.walk_inline_nodes(children, level);
format!("[url={url}]{inner}[/url]")
}
LinkReference(md::LinkReference {
identifier,
children,
..
}) => {
let url = self.link_reference_map.get(&**identifier).unwrap_or(&"");
let inner = self.walk_inline_nodes(children, level);
format!("[url={url}]{inner}[/url]")
}
Table(md::Table { children, .. }) => {
let rows: Vec<String> = children
.iter()
.filter_map(|row| self.walk_node(row, level))
.collect();
join_if_not_empty(&rows, "[br]")?
}
md::Node::TableRow(md::TableRow { children, .. }) => {
let cells: Vec<String> = children
.iter()
.filter_map(|cell| self.walk_node(cell, level))
.collect();
cells.join(" | ")
}
md::Node::TableCell(md::TableCell { children, .. }) => {
self.walk_inline_nodes(children, level)
}
Html(md::Html { value, .. }) => value.clone(),
Break(_) => format!("[br]{}", " ".repeat(level * 4)),
_ => {
let children = node.children()?;
self.walk_inline_nodes(children, level)
}
};
Some(result)
}
fn walk_nodes_as_block(&mut self, nodes: &[md::Node], level: usize) -> String {
let mut pieces = Vec::new();
for node in nodes {
if let Some(s) = self.walk_node(node, level) {
pieces.push(s);
}
}
pieces.join("[br]")
}
fn walk_inline_nodes(&mut self, children: &[md::Node], level: usize) -> String {
let mut out = String::new();
for child in children {
if let Some(s) = self.walk_node(child, level) {
out.push_str(&s);
}
}
out
}
pub fn superscript(idx: usize) -> String {
const SUPS: &[char] = &['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
idx.to_string()
.chars()
.filter_map(|c| c.to_digit(10).map(|d| SUPS[d as usize]))
.collect()
}
}