madfun/
lib.rs

1//! # `madfun`
2//!
3//! **WARNING**: This crate is incomplete, and does not include full support for
4//! all Markdown blocks. Known working Markdown primitives:
5//!
6//! * code
7//! * inlinecode
8//! * links
9//! * paragraphs
10//! * text
11//!
12//! <hr/>
13//!
14//! Would you like to use `Ma`rkdown to post `A`tlassian `D`ocument `F`ormatted
15//! content while still having `fun`? Then this tool's for you!
16//!
17//! ## How It Works
18//!
19//! `madfun` works by taking input in
20//! [Markdown](https://www.markdownguide.org/), either as text or a parsed
21//! abstract syntax tree
22//! ([AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)) and converting
23//! it to an `adf:Node` tree that conforms to Atlassian's
24//! [ADF](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/).
25//! This type implements serde's `Serialize` and `Deserialize` traits, so it's
26//! easy to convert to a JSON representation via `serde_json`.
27//!
28//! Once you've got that, it should be simple (lol) to use any HTTP or Atlassian
29//! client to send it wherever it's going.
30//!
31//! ## Usage
32//!
33//! The core of `madfun` is the [`ToAdf`] trait; this exposes methods for
34//! converting text or pre-parsed Markdown (via the `markdown` crate) into a
35//! `serde_json::Value` that can then be serialized as you desire.
36//!
37//! If you have Markdown text:
38//!
39//! ```
40//! let adf = madfun::from_str(
41//!     "Here is my Markdown content",
42//! ).unwrap();
43//! ```
44//!
45//! If you've already got the Markdown parsed into a `markdown::mdast::Node`,
46//! then you can use the infallible [`ToAdf::to_adf`] method:
47//!
48//! ```
49//! use madfun::ToAdf;
50//!
51//! let mdast = markdown::to_mdast(
52//!     "Here is my Markdown content",
53//!     &markdown::ParseOptions::default(),
54//! ).unwrap();
55//!
56//! let adf = mdast.to_adf();
57//! ```
58//!
59//! ## Limitations
60//!
61//! `madfun` is currently unidirectional; it takes Markdown and renders ADF
62//! JSON. Unfortunately, it can't (yet?) take ADF as returned from the Atlassian
63//! APIs and convert it back to Markdown, though in theory this should be doable
64//! (if not actually isomorphic).
65//!
66//! ## LICENSE
67//!
68//! `madfun` is dual-licensed as MIT or Apache-2.0. Have fun.
69
70pub mod adf;
71
72use adf::{Mark, Node, mark::LinkAttrs, node::CodeBlockAttrs};
73use markdown::{
74    mdast::{self, Code, InlineCode, Link, Paragraph, Root, Text},
75    message::Message,
76};
77use std::fmt::Display;
78
79pub use markdown::ParseOptions;
80
81#[derive(Debug)]
82pub enum Error {
83    Markdown(Message),
84}
85
86impl Display for Error {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Error::Markdown(message) => write!(f, "{message}"),
90        }
91    }
92}
93
94impl std::error::Error for Error {}
95
96impl From<Message> for Error {
97    fn from(value: Message) -> Self {
98        Self::Markdown(value)
99    }
100}
101
102/// Generate ADF from Markdown text.
103///
104/// # Errors
105///
106/// Returns an error if the Markdown is invalid.
107pub fn from_str<T: AsRef<str>>(markdown: T) -> Result<Node, Error> {
108    Ok(
109        markdown::to_mdast(markdown.as_ref(), &ParseOptions::default())?
110            .to_adf(),
111    )
112}
113
114pub trait ToAdf {
115    /// Generate ADF from a pre-parsed `markdown::Node`.
116    fn to_adf(&self) -> Node;
117}
118
119impl ToAdf for mdast::Node {
120    fn to_adf(&self) -> Node {
121        to_adf(self)
122    }
123}
124
125fn to_adf(node: &mdast::Node) -> Node {
126    match node {
127        mdast::Node::Root(Root {
128            children,
129            ..
130        }) => Node::Doc {
131            content: children.iter().map(to_adf).collect(),
132            version: 1,
133        },
134        mdast::Node::Blockquote(blockquote) => todo!(),
135        mdast::Node::Break(_) => todo!(),
136        mdast::Node::Code(Code {
137            value,
138            lang,
139            ..
140        }) => Node::codeblock()
141            .and_attrs(
142                lang.as_ref()
143                    .map(|l| CodeBlockAttrs::builder().language(l).build()),
144            )
145            .content_entry(Node::text().text(value).build())
146            .build(),
147        mdast::Node::Definition(definition) => todo!(),
148        mdast::Node::Delete(delete) => todo!(),
149        mdast::Node::Emphasis(emphasis) => todo!(),
150        mdast::Node::FootnoteDefinition(footnote_definition) => todo!(),
151        mdast::Node::FootnoteReference(footnote_reference) => todo!(),
152        mdast::Node::Heading(heading) => todo!(),
153        mdast::Node::Html(html) => todo!(),
154        mdast::Node::Image(image) => todo!(),
155        mdast::Node::ImageReference(image_reference) => todo!(),
156        mdast::Node::InlineCode(InlineCode {
157            value,
158            ..
159        }) => Node::Text {
160            text: value.to_string(),
161            marks: vec![Mark::Code],
162        },
163        mdast::Node::InlineMath(inline_math) => todo!(),
164        mdast::Node::Link(Link {
165            url,
166            children,
167            title,
168            ..
169        }) => {
170            let Some(mdast::Node::Text(Text {
171                value,
172                ..
173            })) = children.first()
174            else {
175                // TODO: Don't panic (grab your towel).
176                panic!("missing text on link");
177            };
178
179            Node::Text {
180                text: value.to_string(),
181                marks: vec![Mark::Link {
182                    attrs: LinkAttrs::builder()
183                        .href(url)
184                        .and_title(title.as_ref())
185                        .build(),
186                }],
187            }
188        },
189        mdast::Node::LinkReference(link_reference) => todo!(),
190        mdast::Node::List(list) => todo!(),
191        mdast::Node::ListItem(list_item) => todo!(),
192        mdast::Node::Math(math) => todo!(),
193        mdast::Node::MdxFlowExpression(mdx_flow_expression) => todo!(),
194        mdast::Node::MdxJsxFlowElement(mdx_jsx_flow_element) => todo!(),
195        mdast::Node::MdxJsxTextElement(mdx_jsx_text_element) => todo!(),
196        mdast::Node::MdxTextExpression(mdx_text_expression) => todo!(),
197        mdast::Node::MdxjsEsm(mdxjs_esm) => todo!(),
198        mdast::Node::Paragraph(Paragraph {
199            children,
200            ..
201        }) => Node::paragraph()
202            .content(children.iter().map(to_adf).collect())
203            .build(),
204        mdast::Node::Strong(strong) => todo!(),
205        mdast::Node::Table(table) => todo!(),
206        mdast::Node::TableCell(table_cell) => todo!(),
207        mdast::Node::TableRow(table_row) => todo!(),
208        mdast::Node::Text(Text {
209            value,
210            ..
211        }) => Node::text().text(value).build(),
212        mdast::Node::ThematicBreak(thematic_break) => todo!(),
213        mdast::Node::Toml(toml) => todo!(),
214        mdast::Node::Yaml(yaml) => todo!(),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use markdown::mdast::Node;
222    use serde_json::json;
223
224    mod to_adf {
225        use super::*;
226        use markdown::{ParseOptions, mdast::Root};
227        use pretty_assertions::assert_eq;
228
229        #[test]
230        fn test_empty_root() {
231            assert_eq!(
232                serde_json::to_value(
233                    Node::Root(Root {
234                        children: vec![],
235                        position: None,
236                    })
237                    .to_adf()
238                )
239                .unwrap(),
240                json!({
241                    "content": [],
242                    "type": "doc",
243                    "version": 1,
244                }),
245            );
246        }
247
248        #[test]
249        fn test_paragraph() {
250            let node = markdown::to_mdast(
251                "This is a paragraph.",
252                &ParseOptions::default(),
253            )
254            .unwrap();
255            assert_eq!(
256                serde_json::to_value(node.to_adf()).unwrap(),
257                json!({
258                    "content": [{
259                        "content": [{
260                            "text": "This is a paragraph.",
261                            "type": "text",
262                        }],
263                        "type": "paragraph",
264                    }],
265                    "type": "doc",
266                    "version": 1,
267                }),
268                "{node:#?}",
269            );
270
271            let node = serde_json::to_value(
272                markdown::to_mdast("", &ParseOptions::default())
273                    .unwrap()
274                    .to_adf(),
275            )
276            .unwrap();
277            assert_eq!(
278                node,
279                json!({
280                    "content": [],
281                    "type": "doc",
282                    "version": 1,
283                }),
284                "{:#?}",
285                node,
286            );
287        }
288
289        #[test]
290        fn test_multiline_paragraph() {
291            let node = serde_json::to_value(
292                markdown::to_mdast(
293                    "This is a\nmultiline paragraph.",
294                    &ParseOptions::default(),
295                )
296                .unwrap()
297                .to_adf(),
298            )
299            .unwrap();
300            assert_eq!(
301                node,
302                json!({
303                    "content": [{
304                        "content": [{
305                            "text": "This is a\nmultiline paragraph.",
306                            "type": "text",
307                        }],
308                        "type": "paragraph",
309                    }],
310                    "type": "doc",
311                    "version": 1,
312                }),
313                "{node:#?}",
314            );
315        }
316
317        #[test]
318        fn test_multi_paragraph() {
319            let node = serde_json::to_value(
320                markdown::to_mdast(
321                    "This is a paragraph.\n\nAnd another one.",
322                    &ParseOptions::default(),
323                )
324                .unwrap()
325                .to_adf(),
326            )
327            .unwrap();
328            assert_eq!(
329                node,
330                json!({
331                    "content": [{
332                        "content": [{
333                            "text": "This is a paragraph.",
334                            "type": "text",
335                        }],
336                        "type": "paragraph",
337                    },{
338                        "content": [{
339                            "text": "And another one.",
340                            "type": "text",
341                        }],
342                        "type": "paragraph",
343                    }],
344                    "type": "doc",
345                    "version": 1,
346                }),
347                "{node:#?}",
348            );
349        }
350
351        #[test]
352        fn test_link() {
353            let node = serde_json::to_value(
354                markdown::to_mdast(
355                    "This is a paragraph [with](https://example.com).",
356                    &ParseOptions::default(),
357                )
358                .unwrap()
359                .to_adf(),
360            )
361            .unwrap();
362            assert_eq!(
363                node,
364                json!({
365                    "content": [{
366                        "content": [{
367                            "text": "This is a paragraph ",
368                            "type": "text",
369                        },{
370                            "text": "with",
371                            "type": "text",
372                            "marks": [{
373                                "attrs": {
374                                    "href": "https://example.com",
375                                },
376                                "type": "link",
377                            }],
378                        },{
379                            "text": ".",
380                            "type": "text",
381                        }],
382                        "type": "paragraph",
383                    }],
384                    "type": "doc",
385                    "version": 1,
386                }),
387                "{node:#?}",
388            );
389        }
390
391        #[test]
392        fn test_link_with_title() {
393            let node = serde_json::to_value(markdown::to_mdast(
394                r#"This is a paragraph [with](https://example.com "my title")."#,
395                &ParseOptions::default(),
396            )
397            .unwrap().to_adf()).unwrap();
398            assert_eq!(
399                node,
400                json!({
401                    "content": [{
402                        "content": [{
403                            "text": "This is a paragraph ",
404                            "type": "text",
405                        },{
406                            "text": "with",
407                            "type": "text",
408                            "marks": [{
409                                "attrs": {
410                                    "href": "https://example.com",
411                                    "title": "my title",
412                                },
413                                "type": "link",
414                            }],
415                        },{
416                            "text": ".",
417                            "type": "text",
418                        }],
419                        "type": "paragraph",
420                    }],
421                    "type": "doc",
422                    "version": 1,
423                }),
424                "{node:#?}",
425            );
426        }
427
428        #[test]
429        fn test_inline_code() {
430            let node = serde_json::to_value(
431                markdown::to_mdast(
432                    "inline `codehighlight` block",
433                    &ParseOptions::default(),
434                )
435                .unwrap()
436                .to_adf(),
437            )
438            .unwrap();
439            assert_eq!(
440                node,
441                json!({
442                    "content": [{
443                        "content": [{
444                            "text": "inline ",
445                            "type": "text",
446                        },{
447                            "type": "text",
448                            "text": "codehighlight",
449                            "marks": [{
450                                "type": "code",
451                            }],
452                        },{
453                            "text": " block",
454                            "type": "text",
455                        }],
456                        "type": "paragraph",
457                    }],
458                    "type": "doc",
459                    "version": 1,
460                }),
461                "{node:#?}",
462            );
463        }
464    }
465}