use serde_json::{json, Value};
const MAX_RENDER_DEPTH: usize = 128;
pub fn adf_to_text(value: &Value) -> String {
let mut output = String::new();
render_node(value, &mut output, 0);
output.trim_end().to_string()
}
fn render_node(node: &Value, out: &mut String, depth: usize) {
if depth > MAX_RENDER_DEPTH {
return;
}
let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
match node_type {
"doc" => render_children(node, out, depth),
"paragraph" => {
render_children(node, out, depth);
out.push('\n');
}
"text" => {
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
out.push_str(text);
}
}
"mention" => {
let mention_text = node
.get("attrs")
.and_then(|a| a.get("text"))
.and_then(|v| v.as_str())
.or_else(|| {
node.get("attrs")
.and_then(|a| a.get("id"))
.and_then(|v| v.as_str())
})
.unwrap_or("@unknown");
out.push_str(mention_text);
}
"hardBreak" => {
out.push('\n');
}
"heading" => {
let level = node
.get("attrs")
.and_then(|a| a.get("level"))
.and_then(|v| v.as_u64())
.unwrap_or(1);
let hashes = "#".repeat(level as usize);
out.push_str(&hashes);
out.push(' ');
render_children(node, out, depth);
out.push('\n');
}
"bulletList" => {
render_list(node, out, depth, false);
}
"orderedList" => {
render_list(node, out, depth, true);
}
"listItem" => {
render_children(node, out, depth);
}
"codeBlock" => {
let language = node
.get("attrs")
.and_then(|a| a.get("language"))
.and_then(|v| v.as_str())
.unwrap_or("");
out.push_str("```");
out.push_str(language);
out.push('\n');
render_children(node, out, depth);
out.push_str("```\n");
}
"blockquote" => {
let mut inner = String::new();
render_children(node, &mut inner, depth);
for line in inner.lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
}
"rule" => {
out.push_str("---\n");
}
"table" => {
render_table(node, out, depth);
}
_ => {
render_children(node, out, depth);
}
}
}
fn render_children(node: &Value, out: &mut String, depth: usize) {
if let Some(children) = node.get("content").and_then(|v| v.as_array()) {
for child in children {
render_node(child, out, depth);
}
}
}
fn render_list(node: &Value, out: &mut String, depth: usize, ordered: bool) {
let indent = " ".repeat(depth);
if let Some(items) = node.get("content").and_then(|v| v.as_array()) {
for (i, item) in items.iter().enumerate() {
let bullet = if ordered {
format!("{}{}. ", indent, i + 1)
} else {
format!("{}- ", indent)
};
out.push_str(&bullet);
let mut item_text = String::new();
render_node(item, &mut item_text, depth + 1);
out.push_str(item_text.trim());
out.push('\n');
}
}
}
fn render_table(node: &Value, out: &mut String, depth: usize) {
let indent = " ".repeat(depth);
let mut rows: Vec<Vec<String>> = Vec::new();
if let Some(row_nodes) = node.get("content").and_then(|v| v.as_array()) {
for row in row_nodes {
let mut cells: Vec<String> = Vec::new();
if let Some(cell_nodes) = row.get("content").and_then(|v| v.as_array()) {
for cell in cell_nodes {
let mut cell_text = String::new();
render_children(cell, &mut cell_text, depth + 1);
let normalized = cell_text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" <br> ");
cells.push(normalized);
}
}
if !cells.is_empty() {
rows.push(cells);
}
}
}
if rows.is_empty() {
return;
}
let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
for row in &mut rows {
while row.len() < col_count {
row.push(String::new());
}
}
let mut widths = vec![3usize; col_count];
for row in &rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
for (idx, row) in rows.iter().enumerate() {
out.push_str(&indent);
out.push('|');
for (i, cell) in row.iter().enumerate() {
out.push(' ');
out.push_str(cell);
let pad = widths[i].saturating_sub(cell.len());
out.push_str(&" ".repeat(pad));
out.push(' ');
out.push('|');
}
out.push('\n');
if idx == 0 {
out.push_str(&indent);
out.push('|');
for width in &widths {
out.push(' ');
out.push_str(&"-".repeat(*width));
out.push(' ');
out.push('|');
}
out.push('\n');
}
}
}
pub fn inject_mentions(node: &mut Value, mention_map: &[(String, String)]) {
if mention_map.is_empty() {
return;
}
inject_mentions_node(node, mention_map, 0);
}
fn inject_mentions_node(node: &mut Value, mention_map: &[(String, String)], depth: usize) {
if depth > MAX_RENDER_DEPTH {
return;
}
if let Some(Value::Array(content)) = node.get_mut("content") {
let old = std::mem::take(content);
let mut new_content: Vec<Value> = Vec::new();
for child in old {
let child_type = child
.get("type")
.and_then(|t| t.as_str())
.map(str::to_owned);
let child_text = child
.get("text")
.and_then(|t| t.as_str())
.map(str::to_owned);
if child_type.as_deref() == Some("text") {
if let Some(text) = child_text {
new_content.extend(expand_text_mentions(&text, mention_map));
continue;
}
}
new_content.push(child);
}
*content = new_content;
}
if let Some(Value::Array(content)) = node.get_mut("content") {
for child in content.iter_mut() {
inject_mentions_node(child, mention_map, depth + 1);
}
}
}
fn expand_text_mentions(text: &str, mention_map: &[(String, String)]) -> Vec<Value> {
let mut nodes: Vec<Value> = vec![json!({ "type": "text", "text": text })];
for (display_name, account_id) in mention_map {
let pattern = format!("@{display_name}");
let mut next: Vec<Value> = Vec::new();
for node in nodes {
let node_type = node.get("type").and_then(|t| t.as_str()).map(str::to_owned);
let node_text = node.get("text").and_then(|t| t.as_str()).map(str::to_owned);
if node_type.as_deref() == Some("text") {
if let Some(t) = node_text {
if t.contains(pattern.as_str()) {
let parts: Vec<&str> = t.split(pattern.as_str()).collect();
for (i, part) in parts.iter().enumerate() {
if !part.is_empty() {
next.push(json!({ "type": "text", "text": *part }));
}
if i < parts.len() - 1 {
next.push(json!({
"type": "mention",
"attrs": { "id": account_id, "text": pattern }
}));
}
}
continue;
}
}
}
next.push(node);
}
nodes = next;
}
nodes
}
pub fn mentioned_account_ids(value: &Value) -> Vec<String> {
let mut out = Vec::new();
collect_mentioned_account_ids(value, &mut out, 0);
out
}
fn collect_mentioned_account_ids(node: &Value, out: &mut Vec<String>, depth: usize) {
if depth > MAX_RENDER_DEPTH {
return;
}
if node.get("type").and_then(|v| v.as_str()) == Some("mention") {
if let Some(id) = node
.get("attrs")
.and_then(|attrs| attrs.get("id"))
.and_then(|id| id.as_str())
{
if !out.iter().any(|existing| existing == id) {
out.push(id.to_string());
}
}
}
if let Some(children) = node.get("content").and_then(|v| v.as_array()) {
for child in children {
collect_mentioned_account_ids(child, out, depth + 1);
}
}
}
pub fn plain_text_to_adf(text: &str) -> Value {
let mut content: Vec<Value> = text
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
json!({
"type": "paragraph",
"content": [{ "type": "text", "text": line }]
})
})
.collect();
if content.is_empty() {
content.push(json!({ "type": "paragraph", "content": [] }));
}
json!({ "version": 1, "type": "doc", "content": content })
}
pub fn markdown_to_adf(markdown: &str) -> Value {
use comrak::{parse_document, Arena, Options};
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
let root = parse_document(&arena, markdown, &options);
let mut content: Vec<Value> = Vec::new();
let mut unsupported: Vec<&'static str> = Vec::new();
convert_node(root, &mut content, &mut unsupported);
if !unsupported.is_empty() {
content.push(json!({
"type": "blockquote",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": format!(
"Unsupported Markdown constructs were flattened during conversion: {}",
unsupported.join(", ")
)
}]
}]
}));
}
json!({
"version": 1,
"type": "doc",
"content": content
})
}
fn convert_node<'a>(
node: &'a comrak::nodes::AstNode<'a>,
out: &mut Vec<Value>,
unsupported: &mut Vec<&'static str>,
) {
use comrak::nodes::{ListType, NodeValue};
match &node.data.borrow().value {
NodeValue::Document => {
for child in node.children() {
convert_node(child, out, unsupported);
}
}
NodeValue::Paragraph => {
let inline_content = collect_inline_children(node, unsupported);
if !inline_content.is_empty() {
out.push(json!({
"type": "paragraph",
"content": inline_content
}));
}
}
NodeValue::Heading(heading) => {
let inline_content = collect_inline_children(node, unsupported);
out.push(json!({
"type": "heading",
"attrs": { "level": heading.level },
"content": inline_content
}));
}
NodeValue::List(list) => {
let mut items: Vec<Value> = Vec::new();
for child in node.children() {
let mut item_content: Vec<Value> = Vec::new();
convert_node(child, &mut item_content, unsupported);
items.push(json!({
"type": "listItem",
"content": item_content
}));
}
let list_type = match list.list_type {
ListType::Ordered => "orderedList",
ListType::Bullet => "bulletList",
};
out.push(json!({
"type": list_type,
"content": items
}));
}
NodeValue::Item(_) => {
for child in node.children() {
convert_node(child, out, unsupported);
}
}
NodeValue::CodeBlock(code) => {
let language = code.info.trim().to_string();
let text = code.literal.trim_end_matches('\n').to_string();
out.push(json!({
"type": "codeBlock",
"attrs": { "language": language },
"content": [{ "type": "text", "text": text }]
}));
}
NodeValue::BlockQuote => {
let mut inner: Vec<Value> = Vec::new();
for child in node.children() {
convert_node(child, &mut inner, unsupported);
}
out.push(json!({
"type": "blockquote",
"content": inner
}));
}
NodeValue::Table(_) => {
out.push(render_markdown_table(node, unsupported));
}
NodeValue::ThematicBreak => {
out.push(json!({ "type": "rule" }));
}
NodeValue::HtmlBlock(html) => {
note_unsupported(unsupported, "html block");
let text = html.literal.trim_end_matches('\n');
if !text.is_empty() {
out.push(json!({
"type": "codeBlock",
"attrs": { "language": "html" },
"content": [{ "type": "text", "text": text }]
}));
}
}
NodeValue::DescriptionList
| NodeValue::DescriptionItem(_)
| NodeValue::DescriptionTerm
| NodeValue::DescriptionDetails => {
note_unsupported(unsupported, "description list");
for child in node.children() {
convert_node(child, out, unsupported);
}
}
NodeValue::FootnoteDefinition(_) => {
note_unsupported(unsupported, "footnote");
for child in node.children() {
convert_node(child, out, unsupported);
}
}
NodeValue::LineBreak | NodeValue::SoftBreak => {}
_ => {
for child in node.children() {
convert_node(child, out, unsupported);
}
}
}
}
fn collect_inline_children<'a>(
node: &'a comrak::nodes::AstNode<'a>,
unsupported: &mut Vec<&'static str>,
) -> Vec<Value> {
let mut inline_content: Vec<Value> = Vec::new();
for child in node.children() {
collect_inline(child, &mut inline_content, unsupported);
}
inline_content
}
fn collect_inline<'a>(
node: &'a comrak::nodes::AstNode<'a>,
out: &mut Vec<Value>,
unsupported: &mut Vec<&'static str>,
) {
use comrak::nodes::NodeValue;
match &node.data.borrow().value {
NodeValue::Text(text) => {
out.push(json!({ "type": "text", "text": text }));
}
NodeValue::SoftBreak => {
out.push(json!({ "type": "text", "text": " " }));
}
NodeValue::LineBreak => {
out.push(json!({ "type": "hardBreak" }));
}
NodeValue::Code(code) => {
out.push(json!({
"type": "text",
"text": code.literal,
"marks": [{ "type": "code" }]
}));
}
NodeValue::Strong => {
apply_mark(node, out, unsupported, json!({ "type": "strong" }));
}
NodeValue::Emph => {
apply_mark(node, out, unsupported, json!({ "type": "em" }));
}
NodeValue::Strikethrough => {
apply_mark(node, out, unsupported, json!({ "type": "strike" }));
}
NodeValue::Link(link) => {
apply_mark(
node,
out,
unsupported,
json!({
"type": "link",
"attrs": { "href": link.url.clone() }
}),
);
}
NodeValue::Image(link) => {
let mut inner: Vec<Value> = Vec::new();
for child in node.children() {
collect_inline(child, &mut inner, unsupported);
}
let label = inner
.iter()
.filter_map(|item| item.get("text").and_then(|value| value.as_str()))
.collect::<Vec<_>>()
.join("");
let text = if label.trim().is_empty() {
link.url.clone()
} else {
format!("{} ({})", label.trim(), link.url)
};
out.push(json!({ "type": "text", "text": text }));
}
NodeValue::HtmlInline(html) => {
note_unsupported(unsupported, "inline html");
out.push(json!({ "type": "text", "text": html }));
}
_ => {
for child in node.children() {
collect_inline(child, out, unsupported);
}
}
}
}
fn apply_mark<'a>(
node: &'a comrak::nodes::AstNode<'a>,
out: &mut Vec<Value>,
unsupported: &mut Vec<&'static str>,
mark: Value,
) {
let mut inner: Vec<Value> = Vec::new();
for child in node.children() {
collect_inline(child, &mut inner, unsupported);
}
for mut item in inner {
let marks = item.get("marks").cloned().unwrap_or_else(|| json!([]));
let mut marks_arr = marks.as_array().cloned().unwrap_or_default();
marks_arr.push(mark.clone());
item["marks"] = json!(marks_arr);
out.push(item);
}
}
fn render_markdown_table<'a>(
table: &'a comrak::nodes::AstNode<'a>,
unsupported: &mut Vec<&'static str>,
) -> Value {
let mut rows: Vec<Value> = Vec::new();
for (row_idx, row) in table.children().enumerate() {
let mut cells: Vec<Value> = Vec::new();
for cell in row.children() {
let mut block_content: Vec<Value> = Vec::new();
let inline_content = collect_inline_children(cell, unsupported);
if !inline_content.is_empty() {
block_content.push(json!({
"type": "paragraph",
"content": inline_content
}));
}
for child in cell.children() {
convert_node(child, &mut block_content, unsupported);
}
if block_content.is_empty() {
block_content.push(json!({ "type": "paragraph", "content": [] }));
}
cells.push(json!({
"type": if row_idx == 0 { "tableHeader" } else { "tableCell" },
"content": block_content
}));
}
if !cells.is_empty() {
rows.push(json!({ "type": "tableRow", "content": cells }));
}
}
json!({ "type": "table", "content": rows })
}
fn note_unsupported(unsupported: &mut Vec<&'static str>, value: &'static str) {
if !unsupported.contains(&value) {
unsupported.push(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adf_to_text_paragraph() {
let adf = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello world" }]
}]
});
assert_eq!(adf_to_text(&adf), "Hello world");
}
#[test]
fn test_adf_to_text_heading() {
let adf = json!({
"type": "doc",
"content": [{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Title" }]
}]
});
assert!(adf_to_text(&adf).starts_with("## Title"));
}
#[test]
fn test_markdown_to_adf_paragraph() {
let adf = markdown_to_adf("Hello world");
assert_eq!(adf["type"], "doc");
assert_eq!(adf["content"][0]["type"], "paragraph");
}
#[test]
fn test_markdown_to_adf_heading() {
let adf = markdown_to_adf("# My Heading");
assert_eq!(adf["content"][0]["type"], "heading");
assert_eq!(adf["content"][0]["attrs"]["level"], 1);
}
#[test]
fn test_markdown_to_adf_table_support() {
let adf = markdown_to_adf("| Name | Status |\n| --- | --- |\n| API | Done |");
assert_eq!(adf["content"][0]["type"], "table");
assert_eq!(
adf["content"][0]["content"][0]["content"][0]["type"],
"tableHeader"
);
assert_eq!(
adf["content"][0]["content"][1]["content"][1]["type"],
"tableCell"
);
assert_eq!(
adf["content"][0]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
"Name"
);
assert_eq!(
adf["content"][0]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
"Done"
);
}
#[test]
fn test_markdown_to_adf_list_link_and_code_block() {
let adf = markdown_to_adf("- [docs](https://example.com)\n\n```rust\nfn main() {}\n```");
assert_eq!(adf["content"][0]["type"], "bulletList");
assert_eq!(adf["content"][1]["type"], "codeBlock");
}
#[test]
fn test_markdown_to_adf_unsupported_note() {
let adf = markdown_to_adf("<div>raw html</div>");
assert_eq!(adf["content"][0]["type"], "codeBlock");
assert_eq!(adf["content"][1]["type"], "blockquote");
}
#[test]
fn test_adf_table_to_markdown_table() {
let adf = json!({
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{"type": "tableHeader", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Name"}]}]},
{"type": "tableHeader", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Status"}]}]}
]
},
{
"type": "tableRow",
"content": [
{"type": "tableCell", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "API"}]}]},
{"type": "tableCell", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Done"}]}]}
]
}
]
});
let text = adf_to_text(&adf);
assert!(text.contains("| Name "));
assert!(text.contains("| API"));
}
#[test]
fn test_mentioned_account_ids_collects_nested_mentions() {
let adf = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hi " },
{ "type": "mention", "attrs": { "id": "acct-1", "text": "@Mulham" } },
{ "type": "text", "text": " and " },
{ "type": "mention", "attrs": { "id": "acct-2", "text": "@Team" } },
{ "type": "mention", "attrs": { "id": "acct-1", "text": "@Mulham" } }
]
}]
});
assert_eq!(mentioned_account_ids(&adf), vec!["acct-1", "acct-2"]);
}
#[test]
fn test_expand_text_mentions_single_mention_mid_sentence() {
let nodes = expand_text_mentions(
"fix @Alice done",
&[("Alice".to_string(), "acct-1".to_string())],
);
assert_eq!(
nodes,
vec![
json!({ "type": "text", "text": "fix " }),
json!({ "type": "mention", "attrs": { "id": "acct-1", "text": "@Alice" } }),
json!({ "type": "text", "text": " done" }),
]
);
}
#[test]
fn test_expand_text_mentions_handles_start_and_end_mentions() {
let start = expand_text_mentions(
"@Alice done",
&[("Alice".to_string(), "acct-1".to_string())],
);
let end = expand_text_mentions(
"done @Alice",
&[("Alice".to_string(), "acct-1".to_string())],
);
assert_eq!(
start,
vec![
json!({ "type": "mention", "attrs": { "id": "acct-1", "text": "@Alice" } }),
json!({ "type": "text", "text": " done" }),
]
);
assert_eq!(
end,
vec![
json!({ "type": "text", "text": "done " }),
json!({ "type": "mention", "attrs": { "id": "acct-1", "text": "@Alice" } }),
]
);
}
#[test]
fn test_expand_text_mentions_handles_multiple_mentions_and_spaces() {
let nodes = expand_text_mentions(
"Ping @Alice and @Bob Marley today",
&[
("Alice".to_string(), "acct-1".to_string()),
("Bob Marley".to_string(), "acct-2".to_string()),
],
);
assert_eq!(
nodes,
vec![
json!({ "type": "text", "text": "Ping " }),
json!({ "type": "mention", "attrs": { "id": "acct-1", "text": "@Alice" } }),
json!({ "type": "text", "text": " and " }),
json!({ "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob Marley" } }),
json!({ "type": "text", "text": " today" }),
]
);
}
#[test]
fn test_expand_text_mentions_no_match_leaves_text_node_intact() {
let nodes = expand_text_mentions(
"hello world",
&[("Alice".to_string(), "acct-1".to_string())],
);
assert_eq!(
nodes,
vec![json!({ "type": "text", "text": "hello world" })]
);
}
#[test]
fn test_inject_mentions_empty_map_returns_early_without_changes() {
let mut adf = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "hello @Alice" }]
}]
});
let original = adf.clone();
inject_mentions(&mut adf, &[]);
assert_eq!(adf, original);
}
#[test]
fn test_inject_mentions_traverses_nested_adf_nodes() {
let mut adf = json!({
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "hello @Alice" }]
},
{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "cc @Bob Marley" }]
}]
}]
}
]
});
inject_mentions(
&mut adf,
&[
("Alice".to_string(), "acct-1".to_string()),
("Bob Marley".to_string(), "acct-2".to_string()),
],
);
assert_eq!(
adf["content"][0]["content"][0],
json!({ "type": "text", "text": "hello " })
);
assert_eq!(
adf["content"][0]["content"][1],
json!({ "type": "mention", "attrs": { "id": "acct-1", "text": "@Alice" } })
);
assert_eq!(
adf["content"][1]["content"][0]["content"][0]["content"][0],
json!({ "type": "text", "text": "cc " })
);
assert_eq!(
adf["content"][1]["content"][0]["content"][0]["content"][1],
json!({ "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob Marley" } })
);
}
}