madfun 0.1.0

Autogenerate Atlassian Document Format (ADF) from Markdown
Documentation
//! # `madfun`
//!
//! **WARNING**: This crate is incomplete, and does not include full support for
//! all Markdown blocks. Known working blocks:
//!
//! * links
//! * paragraphs
//! * text
//!
//! <hr/>
//!
//! Would you like to use `Ma`rkdown to post `A`tlassian `D`ocument `F`ormat while
//! still having `fun`? Then this tool's for you!
//!
//! ## How It Works
//!
//! `madfun` works by taking input in
//! [Markdown](https://www.markdownguide.org/), either as text or a parsed
//! abstract syntax tree
//! ([AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)) and converting
//! it to a `serde_json::Value` that conforms to Atlassian's
//! [ADF](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/).
//!
//! Once you've got that, it should be simple to use any HTTP or Atlassian
//! client to send it wherever it's going.
//!
//! ## Usage
//!
//! The core of `madfun` is the [`ToAdf`] trait; this exposes methods for
//! converting text or pre-parsed Markdown (via the `markdown` crate) into a
//! `serde_json::Value` that can then be serialized as you desire.
//!
//! If you have Markdown text:
//!
//! ```
//! let adf: serde_json::Value = madfun::from_str(
//!     "Here is my Markdown content",
//!     &markdown::ParseOptions::default(),
//! ).unwrap();
//! ```
//!
//! If you've already got the Markdown parsed into a `markdown::Node`, then you
//! can use the infallible [`ToAdf::to_adf`] method:
//!
//! ```
//! use madfun::ToAdf;
//!
//! let mdast = markdown::to_mdast(
//!     "Here is my Markdown content",
//!     &markdown::ParseOptions::default(),
//! ).unwrap();
//!
//! let adf: serde_json::Value = mdast.to_adf();
//! ```
//!
//! ## Limitations
//!
//! `madfun` is currently unidirectional; it takes Markdown and renders ADF
//! JSON. Unfortunately, it can't (yet?) take ADF as returned from the Atlassian
//! APIs and convert it back to Markdown, though in theory this should be doable
//! (if not actually isomorphic).
//!
//! ## LICENSE
//!
//! `madfun` is dual-licensed as MIT or Apache-2.0. Have fun.

use markdown::{
    ParseOptions,
    mdast::{Link, Node, Paragraph, Root, Text},
};
use serde_json::{Value, json};

/// Generate ADF from Markdown text.
///
/// # Errors
///
/// Returns an error if the Markdown is invalid.
pub fn from_str<T: AsRef<str>>(
    markdown: T,
    options: &ParseOptions,
) -> Result<Value, markdown::message::Message> {
    Ok(markdown::to_mdast(markdown.as_ref(), options)?.to_adf())
}

pub trait ToAdf {
    /// Generate ADF from a pre-parsed `markdown::Node`.
    fn to_adf(&self) -> serde_json::Value;
}

impl ToAdf for Node {
    fn to_adf(&self) -> Value {
        json!({
            "body": {
                "content": to_adf(self),
                "type": "doc",
                "version": 1,
            }
        })
    }
}

fn to_adf(node: &Node) -> Value {
    match node {
        Node::Root(Root {
            children,
            ..
        }) => Value::Array(children.iter().map(to_adf).collect()),
        Node::Blockquote(blockquote) => todo!(),
        Node::Break(_) => todo!(),
        Node::Code(code) => todo!(),
        Node::Definition(definition) => todo!(),
        Node::Delete(delete) => todo!(),
        Node::Emphasis(emphasis) => todo!(),
        Node::FootnoteDefinition(footnote_definition) => todo!(),
        Node::FootnoteReference(footnote_reference) => todo!(),
        Node::Heading(heading) => todo!(),
        Node::Html(html) => todo!(),
        Node::Image(image) => todo!(),
        Node::ImageReference(image_reference) => todo!(),
        Node::InlineCode(inline_code) => todo!(),
        Node::InlineMath(inline_math) => todo!(),
        Node::Link(Link {
            url,
            title,
            children,
            ..
        }) => {
            let Some(Node::Text(Text {
                value,
                ..
            })) = children.first()
            else {
                panic!("missing text on link");
            };

            json!({
                "type": "text",
                "text": value,
                "marks": [{
                    "type": "link",
                    "attrs": [{
                        "href": url,
                        "title": title,
                    }],
                }],
            })
        },
        Node::LinkReference(link_reference) => todo!(),
        Node::List(list) => todo!(),
        Node::ListItem(list_item) => todo!(),
        Node::Math(math) => todo!(),
        Node::MdxFlowExpression(mdx_flow_expression) => todo!(),
        Node::MdxJsxFlowElement(mdx_jsx_flow_element) => todo!(),
        Node::MdxJsxTextElement(mdx_jsx_text_element) => todo!(),
        Node::MdxTextExpression(mdx_text_expression) => todo!(),
        Node::MdxjsEsm(mdxjs_esm) => todo!(),
        Node::Paragraph(Paragraph {
            children,
            ..
        }) => json!({
            "content": Value::Array(children.iter().map(to_adf).collect()),
            "type": "paragraph",
        }),
        Node::Strong(strong) => todo!(),
        Node::Table(table) => todo!(),
        Node::TableCell(table_cell) => todo!(),
        Node::TableRow(table_row) => todo!(),
        Node::Text(Text {
            value,
            ..
        }) => json!({
            "text": value,
            "type": "text",
        }),
        Node::ThematicBreak(thematic_break) => todo!(),
        Node::Toml(toml) => todo!(),
        Node::Yaml(yaml) => todo!(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    mod to_adf {
        use super::*;
        use markdown::{
            ParseOptions,
            mdast::{Paragraph, Root},
        };
        use pretty_assertions::assert_eq;

        #[test]
        fn test_empty_root() {
            assert_eq!(
                Node::Root(Root {
                    children: vec![],
                    position: None,
                })
                .to_adf(),
                json!({
                    "body": {
                        "content": [],
                        "type": "doc",
                        "version": 1,
                    },
                }),
            );
        }

        #[test]
        fn test_paragraph() {
            let node = markdown::to_mdast(
                "This is a paragraph.",
                &ParseOptions::default(),
            )
            .unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "content": [{
                            "content": [{
                                "text": "This is a paragraph.",
                                "type": "text",
                            }],
                            "type": "paragraph",
                        }],
                        "type": "doc",
                        "version": 1,
                    },
                }),
                "{node:#?}",
            );

            let node =
                markdown::to_mdast("", &ParseOptions::default()).unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "content": [],
                        "type": "doc",
                        "version": 1,
                    },
                }),
                "{:#?}",
                node,
            );
        }

        #[test]
        fn test_multiline_paragraph() {
            let node = markdown::to_mdast(
                "This is a\nmultiline paragraph.",
                &ParseOptions::default(),
            )
            .unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "content": [{
                            "content": [{
                                "text": "This is a\nmultiline paragraph.",
                                "type": "text",
                            }],
                            "type": "paragraph",
                        }],
                        "type": "doc",
                        "version": 1,
                    },
                }),
                "{node:#?}",
            );
        }

        #[test]
        fn test_multi_paragraph() {
            let node = markdown::to_mdast(
                "This is a paragraph.\n\nAnd another one.",
                &ParseOptions::default(),
            )
            .unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "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 = markdown::to_mdast(
                "This is a paragraph [with](https://example.com).",
                &ParseOptions::default(),
            )
            .unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "content": [{
                            "content": [{
                                "text": "This is a paragraph ",
                                "type": "text",
                            },{
                                "text": "with",
                                "type": "text",
                                "marks": [{
                                    "attrs": [{
                                        "href": "https://example.com",
                                        "title": null,
                                    }],
                                    "type": "link",
                                }],
                            },{
                                "text": ".",
                                "type": "text",
                            }],
                            "type": "paragraph",
                        }],
                        "type": "doc",
                        "version": 1,
                    },
                }),
                "{node:#?}",
            );
        }

        #[test]
        fn test_link_with_title() {
            let node = markdown::to_mdast(
                r#"This is a paragraph [with](https://example.com "my title")."#,
                &ParseOptions::default(),
            )
            .unwrap();
            assert_eq!(
                node.to_adf(),
                json!({
                    "body": {
                        "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:#?}",
            );
        }
    }
}