use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde_json::{json, Value};
struct NodeBuilder {
node_type: &'static str,
attrs: Option<Value>,
content: Vec<Value>,
auto: bool,
}
impl NodeBuilder {
fn new(node_type: &'static str) -> Self {
Self {
node_type,
attrs: None,
content: Vec::new(),
auto: false,
}
}
fn into_value(self) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("type".into(), Value::String(self.node_type.into()));
if let Some(attrs) = self.attrs {
obj.insert("attrs".into(), attrs);
}
if !self.content.is_empty() {
obj.insert("content".into(), Value::Array(self.content));
}
Value::Object(obj)
}
}
pub fn markdown_to_adf(text: &str) -> Value {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(text, options);
let mut stack: Vec<NodeBuilder> = vec![NodeBuilder::new("doc")];
let mut marks: Vec<Value> = Vec::new();
let mut table = TableState::default();
for event in parser {
match event {
Event::Start(tag) => handle_start(tag, &mut stack, &mut marks, &mut table),
Event::End(tag) => handle_end(tag, &mut stack, &mut marks, &mut table),
Event::Text(t) => push_text(&mut stack, &t, &marks),
Event::Code(t) => {
let mut m: Vec<Value> = marks
.iter()
.filter(|mk| mk["type"] == "link")
.cloned()
.collect();
m.push(json!({ "type": "code" }));
push_text(&mut stack, &t, &m);
}
Event::SoftBreak | Event::HardBreak => push_break(&mut stack),
Event::Html(h) | Event::InlineHtml(h) => push_text(&mut stack, &h, &marks),
Event::Rule => {
close_auto_paragraph(&mut stack);
if !in_restricted_parent(&stack) {
append_child(&mut stack, NodeBuilder::new("rule").into_value());
}
}
_ => {}
}
}
close_auto_paragraph(&mut stack);
let mut doc = stack.pop().expect("doc node always present");
if doc.content.is_empty() {
doc.content.push(NodeBuilder::new("paragraph").into_value());
}
json!({
"type": "doc",
"version": 1,
"content": doc.content,
})
}
#[derive(Default)]
struct TableState {
in_head: bool,
degraded: bool,
}
fn handle_start(
tag: Tag,
stack: &mut Vec<NodeBuilder>,
marks: &mut Vec<Value>,
table: &mut TableState,
) {
match tag {
Tag::Paragraph => {
close_auto_paragraph(stack);
stack.push(NodeBuilder::new("paragraph"));
}
Tag::Heading { level, .. } => {
close_auto_paragraph(stack);
if in_restricted_parent(stack) {
stack.push(NodeBuilder::new("paragraph"));
} else {
let mut n = NodeBuilder::new("heading");
n.attrs = Some(json!({ "level": heading_level(level) }));
stack.push(n);
}
}
Tag::BlockQuote(_) => {
close_auto_paragraph(stack);
if in_restricted_parent(stack) {
stack.push(NodeBuilder::new("__transparent__"));
} else {
stack.push(NodeBuilder::new("blockquote"));
}
}
Tag::CodeBlock(kind) => {
close_auto_paragraph(stack);
let mut n = NodeBuilder::new("codeBlock");
if let CodeBlockKind::Fenced(lang) = kind {
if !lang.is_empty() {
n.attrs = Some(json!({ "language": lang.to_string() }));
}
}
stack.push(n);
}
Tag::List(start) => {
close_auto_paragraph(stack);
match start {
Some(n) => {
let mut node = NodeBuilder::new("orderedList");
if n != 1 {
node.attrs = Some(json!({ "order": n }));
}
stack.push(node);
}
None => stack.push(NodeBuilder::new("bulletList")),
}
}
Tag::Item => {
close_auto_paragraph(stack);
stack.push(NodeBuilder::new("listItem"));
}
Tag::Table(_) => {
close_auto_paragraph(stack);
table.degraded = in_restricted_parent(stack);
stack.push(NodeBuilder::new(if table.degraded {
"__transparent__"
} else {
"table"
}));
}
Tag::TableHead => {
close_auto_paragraph(stack);
table.in_head = true;
stack.push(NodeBuilder::new(if table.degraded {
"__transparent__"
} else {
"tableRow"
}));
}
Tag::TableRow => {
close_auto_paragraph(stack);
stack.push(NodeBuilder::new(if table.degraded {
"__transparent__"
} else {
"tableRow"
}));
}
Tag::TableCell => {
close_auto_paragraph(stack);
let cell_type = if table.degraded {
"paragraph"
} else if table.in_head {
"tableHeader"
} else {
"tableCell"
};
stack.push(NodeBuilder::new(cell_type));
}
Tag::Emphasis => marks.push(json!({ "type": "em" })),
Tag::Strong => marks.push(json!({ "type": "strong" })),
Tag::Strikethrough => marks.push(json!({ "type": "strike" })),
Tag::Link { dest_url, .. } => {
if dest_url.is_empty() {
marks.push(Value::Null);
} else {
marks.push(json!({ "type": "link", "attrs": { "href": dest_url.to_string() } }));
}
}
_ => {}
}
}
fn in_restricted_parent(stack: &[NodeBuilder]) -> bool {
matches!(
stack.last().map(|n| n.node_type),
Some("listItem") | Some("blockquote") | Some("tableCell") | Some("tableHeader")
)
}
fn heading_level(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn handle_end(
tag: TagEnd,
stack: &mut Vec<NodeBuilder>,
marks: &mut Vec<Value>,
table: &mut TableState,
) {
match tag {
TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::CodeBlock => pop_and_append(stack),
TagEnd::Item | TagEnd::BlockQuote(_) => {
close_auto_paragraph(stack);
ensure_nonempty_block_container(stack);
pop_and_append(stack);
}
TagEnd::TableCell => {
close_auto_paragraph(stack);
if !table.degraded {
ensure_nonempty_block_container(stack);
}
pop_and_append(stack);
}
TagEnd::List(_) | TagEnd::TableRow => {
close_auto_paragraph(stack);
pop_and_append(stack);
}
TagEnd::Table => {
close_auto_paragraph(stack);
pop_and_append(stack);
table.degraded = false;
}
TagEnd::TableHead => {
close_auto_paragraph(stack);
pop_and_append(stack);
table.in_head = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
marks.pop();
}
_ => {}
}
}
fn top_accepts_inline(stack: &[NodeBuilder]) -> bool {
matches!(
stack.last().map(|n| n.node_type),
Some("paragraph") | Some("heading") | Some("codeBlock")
)
}
fn ensure_inline_container(stack: &mut Vec<NodeBuilder>) {
if !top_accepts_inline(stack) {
let mut p = NodeBuilder::new("paragraph");
p.auto = true;
stack.push(p);
}
}
fn close_auto_paragraph(stack: &mut Vec<NodeBuilder>) {
let is_auto = stack
.last()
.map(|n| n.node_type == "paragraph" && n.auto)
.unwrap_or(false);
if is_auto {
pop_and_append(stack);
}
}
fn append_child(stack: &mut [NodeBuilder], node: Value) {
if let Some(top) = stack.last_mut() {
top.content.push(node);
}
}
fn pop_and_append(stack: &mut Vec<NodeBuilder>) {
if stack.len() <= 1 {
return;
}
let node = stack.pop().unwrap();
if node.node_type == "__transparent__" {
if let Some(parent) = stack.last_mut() {
parent.content.extend(node.content);
}
return;
}
append_child(stack, node.into_value());
}
fn ensure_nonempty_block_container(stack: &mut [NodeBuilder]) {
if let Some(top) = stack.last_mut() {
if top.content.is_empty() {
top.content.push(NodeBuilder::new("paragraph").into_value());
}
}
}
fn push_text(stack: &mut Vec<NodeBuilder>, text: &str, marks: &[Value]) {
if text.is_empty() {
return;
}
ensure_inline_container(stack);
let top = stack.last_mut().expect("inline container present");
if top.node_type == "codeBlock" {
if let Some(last) = top.content.last_mut() {
if let Some(existing) = last.get("text").and_then(Value::as_str) {
let merged = format!("{existing}{text}");
last["text"] = Value::String(merged);
return;
}
}
top.content.push(json!({ "type": "text", "text": text }));
return;
}
let mut node = json!({ "type": "text", "text": text });
let active: Vec<Value> = marks.iter().filter(|m| !m.is_null()).cloned().collect();
if !active.is_empty() {
node["marks"] = Value::Array(active);
}
top.content.push(node);
}
fn push_break(stack: &mut Vec<NodeBuilder>) {
if stack.last().map(|n| n.node_type) == Some("codeBlock") {
return;
}
ensure_inline_container(stack);
append_child(stack, json!({ "type": "hardBreak" }));
}
#[cfg(test)]
mod tests {
use super::*;
fn content(doc: &Value) -> &Vec<Value> {
doc["content"].as_array().unwrap()
}
#[test]
fn plain_text_is_single_paragraph() {
let doc = markdown_to_adf("just plain text");
assert_eq!(doc["type"], "doc");
assert_eq!(doc["version"], 1);
let c = content(&doc);
assert_eq!(c.len(), 1);
assert_eq!(c[0]["type"], "paragraph");
assert_eq!(c[0]["content"][0]["text"], "just plain text");
}
#[test]
fn empty_input_yields_empty_paragraph() {
let doc = markdown_to_adf("");
let c = content(&doc);
assert_eq!(c.len(), 1);
assert_eq!(c[0]["type"], "paragraph");
}
#[test]
fn heading_levels() {
let doc = markdown_to_adf("# Title\n\n### Sub");
let c = content(&doc);
assert_eq!(c[0]["type"], "heading");
assert_eq!(c[0]["attrs"]["level"], 1);
assert_eq!(c[0]["content"][0]["text"], "Title");
assert_eq!(c[1]["attrs"]["level"], 3);
}
#[test]
fn bold_and_italic_marks() {
let doc = markdown_to_adf("**bold** and *italic*");
let para = &content(&doc)[0];
let inline = para["content"].as_array().unwrap();
assert_eq!(inline[0]["text"], "bold");
assert_eq!(inline[0]["marks"][0]["type"], "strong");
let italic = inline.iter().find(|n| n["text"] == "italic").unwrap();
assert_eq!(italic["marks"][0]["type"], "em");
}
#[test]
fn inline_code_mark() {
let doc = markdown_to_adf("call `foo()` now");
let inline = content(&doc)[0]["content"].as_array().unwrap();
let code = inline.iter().find(|n| n["text"] == "foo()").unwrap();
assert_eq!(code["marks"][0]["type"], "code");
}
#[test]
fn link_mark_with_href() {
let doc = markdown_to_adf("[site](https://example.com)");
let inline = content(&doc)[0]["content"].as_array().unwrap();
assert_eq!(inline[0]["text"], "site");
assert_eq!(inline[0]["marks"][0]["type"], "link");
assert_eq!(
inline[0]["marks"][0]["attrs"]["href"],
"https://example.com"
);
}
#[test]
fn bullet_list_items_wrapped_in_paragraph() {
let doc = markdown_to_adf("- one\n- two");
let list = &content(&doc)[0];
assert_eq!(list["type"], "bulletList");
let items = list["content"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["type"], "listItem");
assert_eq!(items[0]["content"][0]["type"], "paragraph");
assert_eq!(items[0]["content"][0]["content"][0]["text"], "one");
}
#[test]
fn ordered_list_with_nondefault_start() {
let doc = markdown_to_adf("3. third\n4. fourth");
let list = &content(&doc)[0];
assert_eq!(list["type"], "orderedList");
assert_eq!(list["attrs"]["order"], 3);
}
#[test]
fn ordered_list_default_start_has_no_order_attr() {
let doc = markdown_to_adf("1. a\n2. b");
let list = &content(&doc)[0];
assert_eq!(list["type"], "orderedList");
assert!(list.get("attrs").is_none());
}
#[test]
fn fenced_code_block_keeps_language_and_newlines() {
let doc = markdown_to_adf("```rust\nlet x = 1;\nlet y = 2;\n```");
let cb = &content(&doc)[0];
assert_eq!(cb["type"], "codeBlock");
assert_eq!(cb["attrs"]["language"], "rust");
assert_eq!(cb["content"][0]["text"], "let x = 1;\nlet y = 2;\n");
assert!(cb["content"][0].get("marks").is_none());
}
#[test]
fn inline_code_inside_emphasis_drops_em_keeps_code() {
let doc = markdown_to_adf("*em `x` more*");
let inline = content(&doc)[0]["content"].as_array().unwrap();
let code = inline.iter().find(|n| n["text"] == "x").unwrap();
let marks = code["marks"].as_array().unwrap();
assert_eq!(marks.len(), 1);
assert_eq!(marks[0]["type"], "code");
}
#[test]
fn heading_in_list_item_downgrades_to_paragraph() {
let doc = markdown_to_adf("- # Title");
let item = &content(&doc)[0]["content"][0];
assert_eq!(item["type"], "listItem");
assert_eq!(item["content"][0]["type"], "paragraph");
assert_eq!(item["content"][0]["content"][0]["text"], "Title");
}
#[test]
fn heading_in_blockquote_downgrades_to_paragraph() {
let doc = markdown_to_adf("> # Quote");
let bq = &content(&doc)[0];
assert_eq!(bq["type"], "blockquote");
assert_eq!(bq["content"][0]["type"], "paragraph");
}
#[test]
fn blockquote_in_list_item_is_flattened() {
let doc = markdown_to_adf("- > quoted");
let item = &content(&doc)[0]["content"][0];
assert_eq!(item["type"], "listItem");
assert_eq!(item["content"][0]["type"], "paragraph");
assert_eq!(item["content"][0]["content"][0]["text"], "quoted");
}
#[test]
fn empty_link_destination_keeps_plain_text() {
let doc = markdown_to_adf("[label]()");
let text = &content(&doc)[0]["content"][0];
assert_eq!(text["text"], "label");
assert!(text.get("marks").is_none());
}
#[test]
fn gfm_table_converts_to_adf_table() {
let doc = markdown_to_adf("| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |");
let table = &content(&doc)[0];
assert_eq!(table["type"], "table");
let rows = table["content"].as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["type"], "tableRow");
let header_cells = rows[0]["content"].as_array().unwrap();
assert_eq!(header_cells[0]["type"], "tableHeader");
assert_eq!(header_cells[0]["content"][0]["content"][0]["text"], "A");
assert_eq!(rows[1]["type"], "tableRow");
let body_cells = rows[1]["content"].as_array().unwrap();
assert_eq!(body_cells[0]["type"], "tableCell");
assert_eq!(body_cells[0]["content"][0]["content"][0]["text"], "1");
assert_eq!(body_cells[1]["content"][0]["content"][0]["text"], "2");
}
#[test]
fn empty_table_cell_gets_empty_paragraph() {
let doc = markdown_to_adf("| a | |\n|---|---|\n| 1 | 2 |");
let header_cells = content(&doc)[0]["content"][0]["content"]
.as_array()
.unwrap();
assert_eq!(header_cells[1]["type"], "tableHeader");
assert_eq!(header_cells[1]["content"][0]["type"], "paragraph");
assert!(header_cells[1]["content"][0].get("content").is_none());
}
#[test]
fn table_in_list_item_degrades_to_paragraphs() {
let doc = markdown_to_adf("- item\n\n | a |\n |---|\n | 1 |");
let list_item = &content(&doc)[0]["content"][0];
assert_eq!(list_item["type"], "listItem");
let blocks = list_item["content"].as_array().unwrap();
assert!(
blocks.iter().all(|b| b["type"] == "paragraph"),
"listItem must contain only paragraphs, got {blocks:?}"
);
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| b["content"][0]["text"].as_str())
.collect();
assert_eq!(texts, vec!["item", "a", "1"]);
}
#[test]
fn table_in_blockquote_degrades_to_paragraphs() {
let doc = markdown_to_adf("> | a |\n> |---|\n> | 1 |");
let quote = &content(&doc)[0];
assert_eq!(quote["type"], "blockquote");
let blocks = quote["content"].as_array().unwrap();
assert!(
blocks.iter().all(|b| b["type"] == "paragraph"),
"blockquote must contain only paragraphs, got {blocks:?}"
);
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| b["content"][0]["text"].as_str())
.collect();
assert_eq!(texts, vec!["a", "1"]);
}
#[test]
fn table_after_degraded_table_still_renders_as_table() {
let doc = markdown_to_adf("> | a |\n> |---|\n> | 1 |\n\n| b |\n|---|\n| 2 |");
let c = content(&doc);
assert_eq!(c[0]["type"], "blockquote");
assert_eq!(c[1]["type"], "table");
assert_eq!(c[1]["content"][0]["content"][0]["type"], "tableHeader");
}
#[test]
fn roundtrip_issue_example() {
let input = "Summary\nThis is a description of the work.\n\nWhat was added\n- Cloud NAT with a reserved static IP\n- A small VM for SFTP access";
let doc = markdown_to_adf(input);
let c = content(&doc);
assert_eq!(c[0]["type"], "paragraph");
let first_inline = c[0]["content"].as_array().unwrap();
assert!(first_inline.iter().any(|n| n["type"] == "hardBreak"));
let list = c.last().unwrap();
assert_eq!(list["type"], "bulletList");
assert_eq!(list["content"].as_array().unwrap().len(), 2);
}
}