pub mod adf;
use adf::{
Mark, Node,
mark::LinkAttrs,
node::{CodeBlockAttrs, HeadingAttrs},
};
use markdown::{
mdast::{self, Code, InlineCode, Link, Paragraph, Root, Text},
message::Message,
};
use std::fmt::Display;
pub use markdown::ParseOptions;
#[derive(Debug)]
pub enum Error {
Markdown(Message),
InvalidNode,
Fmt(std::fmt::Error),
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Markdown(message) => write!(f, "{message}"),
Error::InvalidNode => write!(f, "invalid node"),
Error::Fmt(error) => write!(f, "fmt error: {error}"),
}
}
}
impl std::error::Error for Error {}
impl From<Message> for Error {
fn from(value: Message) -> Self {
Self::Markdown(value)
}
}
impl From<std::fmt::Error> for Error {
fn from(value: std::fmt::Error) -> Self {
Self::Fmt(value)
}
}
pub fn from_str<T: AsRef<str>>(markdown: T) -> Result<Node, Error> {
Ok(
markdown::to_mdast(markdown.as_ref(), &ParseOptions::default())?
.to_adf(),
)
}
pub trait ToAdf {
fn to_adf(&self) -> Node;
}
impl ToAdf for mdast::Node {
fn to_adf(&self) -> Node {
to_adf(self)
}
}
pub fn to_markdown(node: &Node) -> Result<String, Error> {
let buf = String::new();
Ok(node.to_markdown(buf)?.trim().to_string())
}
#[expect(clippy::too_many_lines)]
fn to_adf(node: &mdast::Node) -> Node {
match node {
mdast::Node::Root(Root {
children,
..
}) => Node::Doc {
content: children.iter().map(to_adf).collect(),
version: 1,
},
mdast::Node::Blockquote(blockquote) => todo!("blockquote"),
mdast::Node::Break(_) => todo!(),
mdast::Node::Code(Code {
value,
lang,
..
}) => Node::codeblock()
.and_attrs(
lang.as_ref()
.map(|l| CodeBlockAttrs::builder().language(l).build()),
)
.content_entry(Node::text().text(value).build())
.build(),
mdast::Node::Definition(definition) => todo!("definition"),
mdast::Node::Delete(delete) => todo!("delete"),
mdast::Node::Emphasis(emphasis) => todo!("emphasis"),
mdast::Node::FootnoteDefinition(footnote_definition) => {
todo!("footnote_definition")
},
mdast::Node::FootnoteReference(footnote_reference) => {
todo!("footnote_reference")
},
mdast::Node::Heading(mdast::Heading {
children,
depth,
..
}) => Node::heading()
.content(children.into_iter().map(ToAdf::to_adf).collect())
.attrs(HeadingAttrs::builder().level(*depth).build())
.build(),
mdast::Node::Html(html) => todo!("html"),
mdast::Node::Image(image) => todo!("image"),
mdast::Node::ImageReference(image_reference) => {
todo!("image_reference")
},
mdast::Node::InlineCode(InlineCode {
value,
..
}) => Node::Text {
text: value.to_string(),
marks: vec![Mark::Code],
},
mdast::Node::InlineMath(inline_math) => todo!("inline_math"),
mdast::Node::Link(Link {
url,
children,
title,
..
}) => {
let Some(mdast::Node::Text(Text {
value,
..
})) = children.first()
else {
panic!("missing text on link");
};
Node::Text {
text: value.to_string(),
marks: vec![Mark::Link {
attrs: LinkAttrs::builder()
.href(url)
.and_title(title.as_ref())
.build(),
}],
}
},
mdast::Node::LinkReference(link_reference) => todo!(),
mdast::Node::List(list) => todo!(),
mdast::Node::ListItem(list_item) => todo!(),
mdast::Node::Math(math) => todo!(),
mdast::Node::MdxFlowExpression(mdx_flow_expression) => todo!(),
mdast::Node::MdxJsxFlowElement(mdx_jsx_flow_element) => todo!(),
mdast::Node::MdxJsxTextElement(mdx_jsx_text_element) => todo!(),
mdast::Node::MdxTextExpression(mdx_text_expression) => todo!(),
mdast::Node::MdxjsEsm(mdxjs_esm) => todo!(),
mdast::Node::Paragraph(Paragraph {
children,
..
}) => Node::paragraph()
.content(children.iter().map(to_adf).collect())
.build(),
mdast::Node::Strong(strong) => todo!("strong"),
mdast::Node::Table(table) => todo!(),
mdast::Node::TableCell(table_cell) => todo!(),
mdast::Node::TableRow(table_row) => todo!(),
mdast::Node::Text(Text {
value,
..
}) => Node::text().text(value).build(),
mdast::Node::ThematicBreak(thematic_break) => todo!("thematic_break"),
mdast::Node::Toml(toml) => todo!(),
mdast::Node::Yaml(yaml) => todo!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use markdown::mdast::Node;
use serde_json::json;
mod to_adf {
use super::*;
use markdown::{ParseOptions, mdast::Root};
use pretty_assertions::assert_eq;
#[test]
fn test_empty_root() {
assert_eq!(
serde_json::to_value(
Node::Root(Root {
children: vec![],
position: None,
})
.to_adf()
)
.unwrap(),
json!({
"content": [],
"type": "doc",
"version": 1,
}),
);
}
#[test]
fn test_paragraph() {
let node = markdown::to_mdast(
"This is a paragraph.",
&ParseOptions::default(),
)
.unwrap();
assert_eq!(
serde_json::to_value(node.to_adf()).unwrap(),
json!({
"content": [{
"content": [{
"text": "This is a paragraph.",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
let node = serde_json::to_value(
markdown::to_mdast("", &ParseOptions::default())
.unwrap()
.to_adf(),
)
.unwrap();
assert_eq!(
node,
json!({
"content": [],
"type": "doc",
"version": 1,
}),
"{:#?}",
node,
);
}
#[test]
fn test_multiline_paragraph() {
let node = serde_json::to_value(
markdown::to_mdast(
"This is a\nmultiline paragraph.",
&ParseOptions::default(),
)
.unwrap()
.to_adf(),
)
.unwrap();
assert_eq!(
node,
json!({
"content": [{
"content": [{
"text": "This is a\nmultiline paragraph.",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
}
#[test]
fn test_multi_paragraph() {
let node = serde_json::to_value(
markdown::to_mdast(
"This is a paragraph.\n\nAnd another one.",
&ParseOptions::default(),
)
.unwrap()
.to_adf(),
)
.unwrap();
assert_eq!(
node,
json!({
"content": [{
"content": [{
"text": "This is a paragraph.",
"type": "text",
}],
"type": "paragraph",
},{
"content": [{
"text": "And another one.",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
}
#[test]
fn test_link() {
let node = serde_json::to_value(
markdown::to_mdast(
"This is a paragraph [with](https://example.com).",
&ParseOptions::default(),
)
.unwrap()
.to_adf(),
)
.unwrap();
assert_eq!(
node,
json!({
"content": [{
"content": [{
"text": "This is a paragraph ",
"type": "text",
},{
"text": "with",
"type": "text",
"marks": [{
"attrs": {
"href": "https://example.com",
},
"type": "link",
}],
},{
"text": ".",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
}
#[test]
fn test_link_with_title() {
let node = serde_json::to_value(markdown::to_mdast(
r#"This is a paragraph [with](https://example.com "my title")."#,
&ParseOptions::default(),
)
.unwrap().to_adf()).unwrap();
assert_eq!(
node,
json!({
"content": [{
"content": [{
"text": "This is a paragraph ",
"type": "text",
},{
"text": "with",
"type": "text",
"marks": [{
"attrs": {
"href": "https://example.com",
"title": "my title",
},
"type": "link",
}],
},{
"text": ".",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
}
#[test]
fn test_inline_code() {
let node = serde_json::to_value(
markdown::to_mdast(
"inline `codehighlight` block",
&ParseOptions::default(),
)
.unwrap()
.to_adf(),
)
.unwrap();
assert_eq!(
node,
json!({
"content": [{
"content": [{
"text": "inline ",
"type": "text",
},{
"type": "text",
"text": "codehighlight",
"marks": [{
"type": "code",
}],
},{
"text": " block",
"type": "text",
}],
"type": "paragraph",
}],
"type": "doc",
"version": 1,
}),
"{node:#?}",
);
}
}
}