madfun/adf/node/mod.rs
1use super::{Mark, mark::LinkAttrs};
2use crate::Error;
3use buildstructor::{Builder, buildstructor};
4use serde::{Deserialize, Serialize};
5use std::fmt::Write;
6
7#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(tag = "type", rename_all = "camelCase")]
9pub enum Node {
10 /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/codeBlock/>
11 ///
12 /// ```json
13 /// {
14 /// "type": "codeBlock",
15 /// "attrs": {
16 /// "language": "javascript"
17 /// },
18 /// "content": [
19 /// {
20 /// "type": "text",
21 /// "text": "var foo = {};\nvar bar = [];"
22 /// }
23 /// ]
24 /// }
25 CodeBlock {
26 #[serde(skip_serializing_if = "Option::is_none")]
27 attrs: Option<CodeBlockAttrs>,
28
29 #[serde(skip_serializing_if = "Vec::is_empty")]
30 content: Vec<Self>,
31 },
32
33 /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/doc/>
34 ///
35 /// ```json
36 /// {
37 /// "version": 1,
38 /// "type": "doc",
39 /// "content": [
40 /// {
41 /// "type": "paragraph",
42 /// "content": [
43 /// {
44 /// "type": "text",
45 /// "text": "Hello world"
46 /// }
47 /// ]
48 /// }
49 /// ]
50 /// }
51 /// ```
52 Doc {
53 version: u64,
54 content: Vec<Self>,
55 },
56
57 /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/heading/>
58 ///
59 /// ```json
60 /// {
61 /// "type": "heading",
62 /// "attrs": {
63 /// "level": 1
64 /// },
65 /// "content": [
66 /// {
67 /// "type": "text",
68 /// "text": "Heading 1"
69 /// }
70 /// ]
71 /// }
72 /// ```
73 Heading {
74 content: Vec<Self>,
75 attrs: HeadingAttrs,
76 },
77
78 /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/paragraph/>
79 ///
80 /// ```json
81 /// {
82 /// "type": "paragraph",
83 /// "content": [
84 /// {
85 /// "type": "text",
86 /// "text": "Hello world"
87 /// }
88 /// ]
89 /// }
90 /// ```
91 Paragraph {
92 #[serde(skip_serializing_if = "Option::is_none")]
93 attrs: Option<ParagraphAttrs>,
94 content: Vec<Self>,
95 },
96
97 /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/text/>
98 ///
99 /// ```json
100 /// {
101 /// "type": "text",
102 /// "text": "Hello world"
103 /// }
104 /// ```
105 Text {
106 text: String,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 marks: Vec<Mark>,
109 },
110}
111
112#[buildstructor]
113impl Node {
114 /// Create a new builder for a CodeBlock discriminant.
115 #[must_use]
116 #[builder(entry = "codeblock")]
117 pub fn new_codeblock(
118 content: Vec<Node>,
119 attrs: Option<CodeBlockAttrs>,
120 ) -> Self {
121 Self::CodeBlock {
122 attrs,
123 content,
124 }
125 }
126
127 /// Create a new builder for a Doc discriminant.
128 #[must_use]
129 #[builder(entry = "doc")]
130 pub fn new_doc(content: Vec<Node>) -> Self {
131 Self::Doc {
132 content,
133 version: 1,
134 }
135 }
136
137 /// Create a new builder for a Heading discriminant.
138 #[must_use]
139 #[builder(entry = "heading")]
140 pub fn new_heading(content: Vec<Node>, attrs: HeadingAttrs) -> Self {
141 Self::Heading {
142 attrs,
143 content,
144 }
145 }
146
147 /// Create a new builder for a Paragraph discriminant.
148 #[must_use]
149 #[builder(entry = "paragraph")]
150 pub fn new_paragraph(
151 content: Vec<Node>,
152 attrs: Option<ParagraphAttrs>,
153 ) -> Self {
154 Self::Paragraph {
155 attrs,
156 content,
157 }
158 }
159
160 /// Create a new builder for a Text discriminant.
161 #[must_use]
162 #[builder(entry = "text")]
163 pub fn new_text(text: String, marks: Vec<Mark>) -> Self {
164 Self::Text {
165 text,
166 marks,
167 }
168 }
169
170 /// # Errors
171 ///
172 /// Returns an error if writing to the buffer fails or the ADF is malformed
173 /// (nodes where they don't belong).
174 pub fn to_markdown(&self, mut buf: String) -> Result<String, Error> {
175 Ok(match self {
176 Node::CodeBlock {
177 attrs,
178 content,
179 } => {
180 writeln!(
181 &mut buf,
182 "```{}",
183 attrs.as_ref().map_or("", |a| &*a.language),
184 )?;
185 for node in content {
186 match node {
187 Node::Text {
188 text,
189 marks,
190 } => {
191 if !marks.is_empty() {
192 return Err(Error::InvalidNode);
193 }
194
195 writeln!(&mut buf, "{text}",)?;
196 },
197 _ => return Err(Error::InvalidNode),
198 }
199 }
200
201 // Write the closing backticks.
202 writeln!(&mut buf, "```\n")?;
203
204 buf
205 },
206 Node::Doc {
207 content,
208 ..
209 } => {
210 for node in content {
211 buf = node.to_markdown(buf)?;
212 }
213
214 buf
215 },
216 Node::Heading {
217 content,
218 attrs,
219 } => {
220 write!(&mut buf, "{}", "#".repeat(attrs.level as usize))?;
221
222 for node in content {
223 buf = node.to_markdown(buf)?;
224 }
225
226 write!(&mut buf, "\n\n")?;
227
228 buf
229 },
230 Node::Paragraph {
231 content,
232 ..
233 } => {
234 for node in content {
235 buf = node.to_markdown(buf)?;
236 }
237
238 write!(&mut buf, "\n\n")?;
239
240 buf
241 },
242 Node::Text {
243 text,
244 marks,
245 } => {
246 // Go through any marks and print the corresponding opening
247 // character.
248 for mark in marks {
249 match mark {
250 Mark::Code => write!(&mut buf, "`")?,
251 Mark::Link {
252 ..
253 } => {
254 write!(&mut buf, "[")?;
255 },
256 }
257 }
258
259 // Print the main, actual content.
260 write!(&mut buf, "{text}")?;
261
262 // Go through the marks again and print any corresponding
263 // closing characters. We go through in reverse order to
264 // (hopefully) produce sane nested markup.
265 for mark in marks.iter().rev() {
266 match mark {
267 Mark::Code => write!(&mut buf, "`")?,
268 Mark::Link {
269 attrs:
270 LinkAttrs {
271 href,
272 title,
273 ..
274 },
275 } => {
276 write!(&mut buf, "]({href}")?;
277
278 if let Some(title) = title {
279 write!(&mut buf, " \"{title}\"")?;
280 }
281
282 write!(&mut buf, ")")?;
283 },
284 }
285 }
286
287 buf
288 },
289 })
290 }
291}
292
293#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct ParagraphAttrs {
295 #[serde(rename = "locallid")]
296 local_id: String,
297}
298
299#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct CodeBlockAttrs {
301 language: String,
302}
303
304#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305pub struct HeadingAttrs {
306 level: u8,
307
308 #[serde(rename = "locallid")]
309 local_id: Option<String>,
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use serde_json::json;
316
317 mod codeblock {
318 use super::*;
319
320 mod serialize {
321 use super::*;
322 use pretty_assertions::assert_eq;
323
324 #[test]
325 fn test_empty() {
326 // Empty of content.
327 let adf = serde_json::to_value(Node::CodeBlock {
328 attrs: None,
329 content: vec![],
330 })
331 .unwrap();
332 assert_eq!(
333 adf,
334 json!({
335 "type": "codeBlock",
336 }),
337 "{adf:?}"
338 );
339 }
340
341 #[test]
342 fn test_content() {
343 // With content.
344 let adf = serde_json::to_value(Node::CodeBlock {
345 attrs: None,
346 content: vec![Node::Text {
347 text: "Hello world".into(),
348 marks: vec![],
349 }],
350 })
351 .unwrap();
352 assert_eq!(
353 adf,
354 json!({
355 "type": "codeBlock",
356 "content": [
357 {
358 "type": "text",
359 "text": "Hello world"
360 }
361 ]
362 }),
363 "{adf:?}"
364 );
365 }
366
367 #[test]
368 fn test_with_attrs() {
369 let adf = serde_json::to_value(Node::CodeBlock {
370 attrs: Some(CodeBlockAttrs {
371 language: "foobar".into(),
372 }),
373 content: vec![],
374 })
375 .unwrap();
376 assert_eq!(
377 adf,
378 json!({
379 "attrs": {
380 "language": "foobar",
381 },
382 "type": "codeBlock",
383 }),
384 "{adf:?}"
385 );
386 }
387 }
388 }
389
390 mod doc {
391 use super::*;
392
393 mod serialize {
394 use super::*;
395 use pretty_assertions::assert_eq;
396
397 #[test]
398 fn test_empty() {
399 // Empty of content.
400 let adf = serde_json::to_value(Node::Doc {
401 version: 1,
402 content: vec![],
403 })
404 .unwrap();
405 assert_eq!(
406 adf,
407 json!({
408 "version": 1,
409 "type": "doc",
410 "content": []
411 }),
412 "{adf:?}"
413 );
414 }
415
416 #[test]
417 fn test_with_content() {
418 let adf = serde_json::to_value(Node::Doc {
419 version: 1,
420 content: vec![Node::Paragraph {
421 attrs: None,
422 content: vec![Node::Text {
423 text: "Hello world".into(),
424 marks: vec![],
425 }],
426 }],
427 })
428 .unwrap();
429 assert_eq!(
430 adf,
431 json!({
432 "version": 1,
433 "type": "doc",
434 "content": [
435 {
436 "type": "paragraph",
437 "content": [
438 {
439 "type": "text",
440 "text": "Hello world"
441 }
442 ]
443 }
444 ]
445 }),
446 "{adf:?}"
447 );
448 }
449 }
450
451 mod markdown {
452 use super::*;
453 use pretty_assertions::assert_eq;
454
455 #[test]
456 fn test_roundtrip_in_doc() {
457 assert_eq!(
458 crate::from_str(concat!(
459 "Some paragraph text.\n",
460 "```\nsome code here\n```\n\n",
461 "And now some paragraph text with a ",
462 "[link](https://example.com).",
463 ),)
464 .unwrap(),
465 Node::doc()
466 .content(vec![
467 Node::Paragraph {
468 attrs: None,
469 content: vec![Node::Text {
470 text: "Some paragraph text.".into(),
471 marks: vec![],
472 },],
473 },
474 Node::CodeBlock {
475 attrs: None,
476 content: vec![Node::Text {
477 text: "some code here".into(),
478 marks: vec![],
479 },],
480 },
481 Node::Paragraph {
482 attrs: None,
483 content: vec![
484 Node::Text {
485 text: "And now some paragraph text with a ".into(),
486 marks: vec![],
487 },
488 Node::Text {
489 text: "link".into(),
490 marks: vec![
491 Mark::Link {
492 attrs: LinkAttrs {
493 collection: None,
494 href: "https://example.com".into(),
495 id: None,
496 occurrence_key: None,
497 title: None,
498 },
499 },
500 ],
501 },
502 Node::Text {
503 text: ".".into(),
504 marks: vec![],
505 },
506 ],
507 },
508 ])
509 .build(),
510 );
511 }
512 }
513 }
514
515 mod paragraph {
516 use super::*;
517
518 mod serialize {
519 use super::*;
520 use pretty_assertions::assert_eq;
521
522 #[test]
523 fn test_empty() {
524 // Empty of content.
525 let adf = serde_json::to_value(Node::Paragraph {
526 attrs: None,
527 content: vec![],
528 })
529 .unwrap();
530 assert_eq!(
531 adf,
532 json!({
533 "type": "paragraph",
534 "content": []
535 }),
536 "{adf:?}"
537 );
538 }
539
540 #[test]
541 fn test_content() {
542 // With content.
543 let adf = serde_json::to_value(Node::Paragraph {
544 attrs: None,
545 content: vec![Node::Text {
546 text: "Hello world".into(),
547 marks: vec![],
548 }],
549 })
550 .unwrap();
551 assert_eq!(
552 adf,
553 json!({
554 "type": "paragraph",
555 "content": [
556 {
557 "type": "text",
558 "text": "Hello world"
559 }
560 ]
561 }),
562 "{adf:?}"
563 );
564 }
565
566 #[test]
567 fn test_with_attrs() {
568 let adf = serde_json::to_value(Node::Paragraph {
569 attrs: Some(ParagraphAttrs {
570 local_id: "foobar".into(),
571 }),
572 content: vec![],
573 })
574 .unwrap();
575 assert_eq!(
576 adf,
577 json!({
578 "attrs": {
579 "locallid": "foobar",
580 },
581 "type": "paragraph",
582 "content": [],
583 }),
584 "{adf:?}"
585 );
586 }
587 }
588 }
589
590 mod to_markdown {
591 use super::*;
592
593 mod codeblock {
594 use super::*;
595 use crate::ToAdf;
596 use markdown::mdast;
597 use pretty_assertions::assert_eq;
598
599 #[test]
600 fn test_from_adf_no_lang() {
601 assert_eq!(
602 crate::to_markdown(
603 &Node::codeblock()
604 .content_entry(
605 Node::text().text("let a = 1;").build()
606 )
607 .build(),
608 )
609 .unwrap(),
610 "```\nlet a = 1;\n```"
611 );
612 }
613
614 #[test]
615 fn test_from_adf_with_lang() {
616 assert_eq!(
617 crate::to_markdown(
618 &Node::codeblock()
619 .content_entry(
620 Node::text().text("let a = 1;").build()
621 )
622 .attrs(
623 CodeBlockAttrs::builder()
624 .language("rust")
625 .build()
626 )
627 .build(),
628 )
629 .unwrap(),
630 "```rust\nlet a = 1;\n```"
631 );
632 }
633
634 #[test]
635 fn test_from_mdast_no_lang() {
636 assert_eq!(
637 crate::to_markdown(
638 &mdast::Node::Code(mdast::Code {
639 value: "let a = 1;".into(),
640 position: None,
641 lang: None,
642 meta: None,
643 })
644 .to_adf()
645 )
646 .unwrap(),
647 "```\nlet a = 1;\n```"
648 );
649 }
650
651 #[test]
652 fn test_from_mdast_with_lang() {
653 assert_eq!(
654 crate::to_markdown(
655 &mdast::Node::Code(mdast::Code {
656 value: "let a = 1;".into(),
657 position: None,
658 lang: Some("rust".into()),
659 meta: None,
660 })
661 .to_adf()
662 )
663 .unwrap(),
664 "```rust\nlet a = 1;\n```"
665 );
666 }
667
668 #[test]
669 fn test_roundtrip_no_lang() {
670 assert_eq!(
671 crate::to_markdown(
672 &crate::from_str("```\nlet a = 1;\n```\n").unwrap()
673 )
674 .unwrap(),
675 "```\nlet a = 1;\n```"
676 );
677 }
678
679 #[test]
680 fn test_roundtrip_with_lang() {
681 assert_eq!(
682 crate::to_markdown(
683 &crate::from_str("```rust\nlet a = 1;\n```").unwrap()
684 )
685 .unwrap(),
686 "```rust\nlet a = 1;\n```"
687 );
688 }
689
690 #[test]
691 fn test_roundtrip_in_doc() {
692 assert_eq!(
693 crate::to_markdown(
694 &crate::from_str(concat!(
695 "Some paragraph text.\n\n",
696 "```\nsome code here\n```\n\n",
697 "And now some paragraph text with a ",
698 "[link](https://example.com).",
699 ),)
700 .unwrap()
701 )
702 .unwrap(),
703 concat!(
704 "Some paragraph text.\n\n",
705 "```\nsome code here\n```\n\n",
706 "And now some paragraph text with a ",
707 "[link](https://example.com).",
708 ),
709 );
710 }
711 }
712
713 mod text {
714 use super::*;
715 use pretty_assertions::assert_eq;
716
717 #[test]
718 fn test_plain() {
719 assert_eq!(
720 crate::to_markdown(
721 &Node::paragraph()
722 .content_entry(
723 Node::text().text("This is text.").build()
724 )
725 .build(),
726 )
727 .unwrap(),
728 "This is text."
729 );
730 }
731
732 #[test]
733 fn test_link() {
734 assert_eq!(
735 crate::to_markdown(
736 &crate::from_str("[a link](https://example.com)")
737 .unwrap()
738 )
739 .unwrap(),
740 "[a link](https://example.com)"
741 );
742 }
743 }
744
745 mod paragraph {
746 use super::*;
747 use crate::ToAdf;
748 use markdown::mdast;
749 use pretty_assertions::assert_eq;
750
751 #[test]
752 fn test_from_adf_oneline() {
753 assert_eq!(
754 crate::to_markdown(
755 &Node::paragraph()
756 .content_entry(
757 Node::text().text("Have a paragraph.").build()
758 )
759 .build(),
760 )
761 .unwrap(),
762 "Have a paragraph."
763 );
764 }
765
766 #[test]
767 fn test_from_adf_multiline() {
768 assert_eq!(
769 crate::to_markdown(
770 &Node::paragraph()
771 .content_entry(
772 Node::text().text("Have a\nparagraph").build()
773 )
774 .build(),
775 )
776 .unwrap(),
777 "Have a\nparagraph"
778 );
779 }
780
781 #[test]
782 fn test_from_mdast() {
783 assert_eq!(
784 crate::to_markdown(
785 &mdast::Node::Paragraph(mdast::Paragraph {
786 children: vec![mdast::Node::Text(mdast::Text {
787 value: "This is a paragraph.".into(),
788 position: None,
789 })],
790 position: None,
791 })
792 .to_adf()
793 )
794 .unwrap(),
795 "This is a paragraph."
796 );
797 }
798
799 #[test]
800 fn test_roundtrip() {
801 assert_eq!(
802 crate::to_markdown(
803 &crate::from_str("Hello, world").unwrap()
804 )
805 .unwrap(),
806 "Hello, world"
807 );
808 }
809
810 #[test]
811 fn test_roundtrip_multi_paragraph() {
812 assert_eq!(
813 crate::to_markdown(
814 &crate::from_str("Hello, world\n\nHi there").unwrap()
815 )
816 .unwrap(),
817 "Hello, world\n\nHi there"
818 );
819 }
820 }
821 }
822}