1use anyhow::{bail, Result};
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::{format_kv, parse_attrs, Attrs};
13use crate::atlassian::directive::{
14 is_container_close, try_parse_container_open, try_parse_inline_directive,
15 try_parse_leaf_directive,
16};
17
18const MAX_NESTING_DEPTH: usize = 64;
31
32const LIST_SEPARATE_ATTR: &str = "list";
40
41pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
43 debug!(
44 "markdown_to_adf: input {} bytes, {} lines",
45 markdown.len(),
46 markdown.lines().count()
47 );
48 let mut doc = AdfDocument::new();
49 let mut parser = MarkdownParser::new(markdown);
50 doc.content = parser.parse_blocks()?;
51 let split = split_emphasis_code_marks(&mut doc.content);
52 if split > 0 {
53 debug!(
54 "markdown_to_adf: dropped {split} emphasis mark(s) from inline-code runs \
55 (ADF forbids combining them with `code`; issue #1391)"
56 );
57 }
58 debug!(
59 "markdown_to_adf: produced {} top-level ADF nodes",
60 doc.content.len()
61 );
62 Ok(doc)
63}
64
65fn ordered_list_start(node: &AdfNode) -> u32 {
68 node.attrs
69 .as_ref()
70 .and_then(|a| a.get("order"))
71 .and_then(serde_json::Value::as_u64)
72 .and_then(|v| u32::try_from(v).ok())
73 .unwrap_or(1)
74}
75
76fn list_item_count(node: &AdfNode) -> usize {
78 node.content.as_ref().map_or(0, Vec::len)
79}
80
81fn is_list_node_type(node_type: &str) -> bool {
83 matches!(node_type, "bulletList" | "orderedList" | "taskList")
84}
85
86fn is_sibling_marker(line: &str, content_indent: usize) -> bool {
90 is_list_start(line) && leading_spaces(line) < content_indent
91}
92
93struct MarkdownParser<'a> {
95 lines: Vec<&'a str>,
96 pos: usize,
97 depth: usize,
102}
103
104impl<'a> MarkdownParser<'a> {
105 fn new(input: &'a str) -> Self {
106 Self::with_depth(input, 0)
107 }
108
109 fn with_depth(input: &'a str, depth: usize) -> Self {
111 Self {
112 lines: input.lines().collect(),
113 pos: 0,
114 depth,
115 }
116 }
117
118 fn at_end(&self) -> bool {
119 self.pos >= self.lines.len()
120 }
121
122 fn current_line(&self) -> &'a str {
123 debug_assert!(!self.at_end(), "current_line called at end of input");
124 self.lines[self.pos]
125 }
126
127 fn advance(&mut self) {
128 self.pos += 1;
129 }
130
131 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
139 self.collect_hardbreak_continuations_with_content_indent(full_text, 0);
142 }
143
144 fn collect_item_hardbreak_continuations(
152 &mut self,
153 full_text: &mut String,
154 marker_indent: usize,
155 ) {
156 self.collect_hardbreak_continuations_with_content_indent(full_text, marker_indent + 2);
157 }
158
159 fn collect_hardbreak_continuations_with_content_indent(
160 &mut self,
161 full_text: &mut String,
162 content_indent: usize,
163 ) {
164 while has_trailing_hard_break(full_text) && !self.at_end() {
165 if !self.try_append_hardbreak_continuation(full_text, content_indent) {
166 break;
167 }
168 }
169 }
170
171 fn try_append_hardbreak_continuation(
178 &mut self,
179 full_text: &mut String,
180 content_indent: usize,
181 ) -> bool {
182 let line = self.current_line();
189 if is_sibling_marker(line, content_indent) {
190 return false;
191 }
192 match line
193 .strip_prefix(" ")
194 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
195 {
196 Some(stripped) => {
197 full_text.push('\n');
198 full_text.push_str(stripped);
199 self.advance();
200 true
201 }
202 None => false,
203 }
204 }
205
206 fn collect_item_sub_lines(&mut self, marker_indent: usize) -> Vec<String> {
252 let mut sub_lines: Vec<String> = Vec::new();
253 while !self.at_end() {
254 let next = self.current_line();
255 if next.trim().is_empty() && next.strip_prefix(" ").is_none() {
256 let mut lookahead = self.pos;
257 while lookahead < self.lines.len() && self.lines[lookahead].trim().is_empty() {
258 lookahead += 1;
259 }
260 let continues = lookahead < self.lines.len() && {
261 let after = self.lines[lookahead];
262 after.strip_prefix(" ").is_some()
263 && !is_sibling_marker(after, marker_indent + 2)
264 };
265 if !continues {
266 break;
267 }
268 while self.pos < lookahead {
269 sub_lines.push(String::new());
270 self.advance();
271 }
272 continue;
273 }
274 let Some(stripped) = next.strip_prefix(" ") else {
275 break;
276 };
277 if is_sibling_marker(next, marker_indent + 2) {
278 break;
279 }
280 sub_lines.push(stripped.to_string());
281 self.advance();
282 }
283
284 let boundary = sub_lines
288 .iter()
289 .position(|l| is_list_start(l))
290 .unwrap_or(sub_lines.len());
291 let extra = sub_lines[..boundary]
292 .iter()
293 .filter(|l| !l.trim().is_empty())
294 .map(|l| leading_spaces(l))
295 .min()
296 .unwrap_or(0);
297 if extra > 0 {
298 for line in &mut sub_lines[..boundary] {
299 if !line.trim().is_empty() {
300 line.replace_range(..extra, "");
301 }
302 }
303 }
304
305 sub_lines
306 }
307
308 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
309 if self.depth > MAX_NESTING_DEPTH {
310 bail!(
311 "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
312 (nested blockquotes, lists, panels, expands, layouts, or table \
313 cells); flatten the document structure"
314 );
315 }
316
317 let mut blocks = Vec::new();
318
319 while !self.at_end() {
320 let line = self.current_line();
321
322 if line.trim().is_empty() {
323 self.advance();
324 continue;
325 }
326
327 let mut node = if let Some(node) = self.try_heading() {
328 node
329 } else if let Some(node) = self.try_horizontal_rule() {
330 node
331 } else if let Some(node) = self.try_container_directive()? {
332 node
333 } else if let Some(node) = self.try_code_block()? {
334 node
335 } else if let Some(node) = self.try_table()? {
336 node
337 } else if let Some(node) = self.try_blockquote()? {
338 node
339 } else if let Some(node) = self.try_list()? {
340 node
341 } else if let Some(node) = self.try_leaf_directive() {
342 node
343 } else if let Some(node) = self.try_image() {
344 node
345 } else {
346 self.parse_paragraph()?
347 };
348
349 self.try_apply_block_attrs(&mut node);
351 blocks.push(node);
352 }
353
354 Ok(blocks)
355 }
356
357 fn try_heading(&mut self) -> Option<AdfNode> {
358 let line = self.current_line();
359 let trimmed = line.trim_start();
360
361 if !trimmed.starts_with('#') {
362 return None;
363 }
364
365 let level = trimmed.chars().take_while(|&c| c == '#').count();
366 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
367 return None;
368 }
369
370 let mut full_text = trimmed[level + 1..].to_string();
371 self.advance();
372 self.collect_hardbreak_continuations(&mut full_text);
374 let mut inline_nodes = parse_inline(&full_text);
375 if strip_heading_code_marks(&mut inline_nodes) > 0 {
380 warn!(
381 "Stripped inline `code` from heading (ADF forbids code marks on \
382 headings; kept the text as plain): {}",
383 full_text.trim()
384 );
385 }
386
387 #[allow(clippy::cast_possible_truncation)]
388 Some(AdfNode::heading(level as u8, inline_nodes))
389 }
390
391 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
392 let line = self.current_line().trim();
393 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
394 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
395 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
396
397 if is_rule && line.len() >= 3 {
398 self.advance();
399 Some(AdfNode::rule())
400 } else {
401 None
402 }
403 }
404
405 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
406 let line = self.current_line();
407 if !is_code_fence_opener(line) {
408 return Ok(None);
409 }
410
411 let language = line[3..].trim();
412 let language = if language == "\"\"" {
413 Some(String::new())
415 } else if language.is_empty() {
416 None
417 } else {
418 Some(language.to_string())
419 };
420
421 self.advance();
422 let mut code_lines = Vec::new();
423
424 while !self.at_end() {
425 let line = self.current_line();
426 if line.starts_with("```") {
427 self.advance();
428 break;
429 }
430 code_lines.push(line);
431 self.advance();
432 }
433
434 let code_text = code_lines.join("\n");
435
436 if language.as_deref() == Some("adf-unsupported") {
438 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
439 return Ok(Some(node));
440 }
441 }
442
443 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
444 }
445
446 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
447 let line = self.current_line();
448 if !line.starts_with('>') {
449 return Ok(None);
450 }
451
452 let mut quote_lines = Vec::new();
453 while !self.at_end() {
454 let line = self.current_line();
455 if let Some(rest) = line.strip_prefix("> ") {
456 quote_lines.push(rest);
457 self.advance();
458 } else if let Some(rest) = line.strip_prefix('>') {
459 quote_lines.push(rest);
460 self.advance();
461 } else {
462 break;
463 }
464 }
465
466 let quote_text = quote_lines.join("\n");
467 let mut inner_parser = MarkdownParser::with_depth("e_text, self.depth + 1);
468 let inner_blocks = inner_parser.parse_blocks()?;
469
470 Ok(Some(AdfNode::blockquote(inner_blocks)))
471 }
472
473 fn try_list(&mut self) -> Result<Option<AdfNode>> {
474 let line = self.current_line();
475 let trimmed = line.trim_start();
476
477 let is_bullet =
478 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
479 let ordered_match = parse_ordered_list_marker(trimmed);
480
481 if !is_bullet && ordered_match.is_none() {
482 return Ok(None);
483 }
484
485 if is_bullet {
486 self.parse_bullet_list()
487 } else {
488 let start = ordered_match.map_or(1, |(n, _)| n);
489 self.parse_ordered_list(start)
490 }
491 }
492
493 fn peek_past_blank_run(&self) -> Option<usize> {
496 let mut i = self.pos;
497 while i < self.lines.len() && self.lines[i].trim().is_empty() {
498 i += 1;
499 }
500 (i < self.lines.len()).then_some(i)
501 }
502
503 fn skip_blank_run(&mut self) {
505 while !self.at_end() && self.current_line().trim().is_empty() {
506 self.advance();
507 }
508 }
509
510 fn loose_bullet_item_follows(&self, first_marker_indent: usize, is_task_list: bool) -> bool {
514 let Some(idx) = self.peek_past_blank_run() else {
515 return false;
516 };
517 let after = self.lines[idx];
518 if leading_spaces(after) != first_marker_indent {
519 return false;
520 }
521 let trimmed = after.trim_start();
522 if !(trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ")) {
523 return false;
524 }
525 try_parse_task_marker(trimmed[2..].trim_start()).is_some() == is_task_list
528 }
529
530 fn loose_ordered_item_follows(
534 &self,
535 first_marker_indent: usize,
536 start: u32,
537 seen: usize,
538 ) -> bool {
539 let Some(idx) = self.peek_past_blank_run() else {
540 return false;
541 };
542 let after = self.lines[idx];
543 if leading_spaces(after) != first_marker_indent {
544 return false;
545 }
546 let Some((n, _)) = parse_ordered_list_marker(after.trim_start()) else {
547 return false;
548 };
549 let seen = u32::try_from(seen).unwrap_or(u32::MAX);
550 n == start.saturating_add(seen) || n == 1
551 }
552
553 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
554 let mut items = Vec::new();
555 let first_marker_indent = leading_spaces(self.current_line());
558 let is_task_list = {
561 let trimmed = self.current_line().trim_start();
562 try_parse_task_marker(trimmed[2..].trim_start()).is_some()
563 };
564
565 while !self.at_end() {
566 let line = self.current_line();
567
568 if line.trim().is_empty() {
575 if !self.loose_bullet_item_follows(first_marker_indent, is_task_list) {
576 break;
577 }
578 self.skip_blank_run();
579 continue;
580 }
581
582 let trimmed = line.trim_start();
583
584 if !(trimmed.starts_with("- ")
585 || trimmed.starts_with("* ")
586 || trimmed.starts_with("+ "))
587 {
588 break;
589 }
590
591 let after_marker = trimmed[2..].trim_start();
592
593 if try_parse_task_marker(after_marker).is_some() != is_task_list {
599 break;
600 }
601
602 if let Some((state, text)) = try_parse_task_marker(after_marker) {
604 self.advance();
605 let mut full_text = text.to_string();
609 self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
610 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
611 let inline_nodes = parse_inline(item_text);
612 let content = if let Some(ref plid) = para_local_id {
616 let mut para = AdfNode::paragraph(inline_nodes);
617 if plid != "_" {
618 para.attrs = Some(serde_json::json!({"localId": plid}));
619 }
620 vec![para]
621 } else {
622 inline_nodes
623 };
624 let mut task = AdfNode::task_item(state, content);
625 if let Some(id) = local_id {
627 if let Some(ref mut attrs) = task.attrs {
628 attrs["localId"] = serde_json::Value::String(id);
629 }
630 }
631 let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
635 if !sub_lines.is_empty() {
636 let sub_text = sub_lines.join("\n");
637 let mut nested =
638 MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
639 let is_empty = task.content.as_ref().is_none_or(Vec::is_empty);
647 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
648 if let Some(task_items) = nested.remove(0).content {
649 task.content = Some(task_items);
650 }
651 if let Some(ref mut attrs) = task.attrs {
652 if let Some(obj) = attrs.as_object_mut() {
653 obj.remove("state");
654 }
655 }
656 items.push(task);
657 } else {
658 let mut sibling_task_lists = Vec::new();
664 let mut child_nodes = Vec::new();
665 for n in nested {
666 if n.node_type == "taskList" {
667 sibling_task_lists.push(n);
668 } else {
669 child_nodes.push(n);
670 }
671 }
672 if !child_nodes.is_empty() {
673 match task.content {
674 Some(ref mut content) => content.append(&mut child_nodes),
675 None => task.content = Some(child_nodes),
676 }
677 }
678 items.push(task);
679 items.append(&mut sibling_task_lists);
680 }
681 } else {
682 items.push(task);
683 }
684 } else {
685 let first_line = &trimmed[2..];
686 self.advance();
687 let mut full_text = first_line.to_string();
688 self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
689 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
690 let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
694 let item_content = parse_list_item_first_line(
695 item_text,
696 sub_lines,
697 local_id,
698 para_local_id,
699 self.depth + 1,
700 )?;
701 items.push(item_content);
702 }
703 }
704
705 if items.is_empty() {
706 Ok(None)
707 } else if is_task_list {
708 Ok(Some(AdfNode::task_list(items)))
709 } else {
710 Ok(Some(AdfNode::bullet_list(items)))
711 }
712 }
713
714 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
726 let mut items = Vec::new();
727 let first_marker_indent = leading_spaces(self.current_line());
728
729 while !self.at_end() {
730 let line = self.current_line();
731
732 if line.trim().is_empty() {
733 if !self.loose_ordered_item_follows(first_marker_indent, start, items.len()) {
734 break;
735 }
736 self.skip_blank_run();
737 continue;
738 }
739
740 let trimmed = line.trim_start();
741
742 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
743 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
744 self.advance();
745 let mut full_text = first_line.to_string();
746 self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
747 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
748 let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
750 let item_content = parse_list_item_first_line(
751 item_text,
752 sub_lines,
753 local_id,
754 para_local_id,
755 self.depth + 1,
756 )?;
757 items.push(item_content);
758 } else {
759 break;
760 }
761 }
762
763 if items.is_empty() {
764 Ok(None)
765 } else {
766 let order = if start == 1 { None } else { Some(start) };
767 Ok(Some(AdfNode::ordered_list(items, order)))
768 }
769 }
770
771 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
772 if self.at_end() {
773 return;
774 }
775 let line = self.current_line().trim();
776 if !line.starts_with('{') {
777 return;
778 }
779 let Some((_, attrs)) = parse_attrs(line, 0) else {
780 return;
781 };
782
783 let mut marks = Vec::new();
784 if let Some(align) = attrs.get("align") {
785 marks.push(AdfMark::alignment(align));
786 }
787 if let Some(indent) = attrs.get("indent") {
788 if let Ok(level) = indent.parse::<u32>() {
789 marks.push(AdfMark::indentation(level));
790 }
791 }
792 if let Some(mode) = attrs.get("breakout") {
793 let width = attrs
794 .get("breakoutWidth")
795 .and_then(|w| w.parse::<u32>().ok());
796 marks.push(AdfMark::breakout(mode, width));
797 }
798
799 let local_id = attrs.get("localId").map(str::to_string);
801
802 let order = if node.node_type == "orderedList" {
804 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
805 } else {
806 None
807 };
808
809 let list_separate =
814 attrs.get(LIST_SEPARATE_ATTR) == Some("separate") && is_list_node_type(&node.node_type);
815
816 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some() || list_separate;
817 if has_attrs {
818 if !marks.is_empty() {
819 let existing = node.marks.get_or_insert_with(Vec::new);
820 existing.extend(marks);
821 }
822 if let Some(id) = local_id {
823 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
824 node_attrs["localId"] = serde_json::Value::String(id);
825 }
826 if let Some(n) = order {
827 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
828 node_attrs["order"] = serde_json::json!(n);
829 }
830 self.advance(); }
832 }
833
834 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
835 let line = self.current_line();
836 let Some((d, colon_count)) = try_parse_container_open(line) else {
837 return Ok(None);
838 };
839 let start_line = self.pos + 1; self.advance(); let mut inner_lines = Vec::new();
844 let mut depth: usize = 0;
845 let mut closed = false;
846 while !self.at_end() {
847 let current = self.current_line();
848 if try_parse_container_open(current).is_some() {
849 depth += 1;
850 } else if depth == 0 && is_container_close(current, colon_count) {
851 self.advance(); closed = true;
853 break;
854 } else if depth > 0 && is_container_close(current, 3) {
855 depth -= 1;
856 }
857 inner_lines.push(current.to_string());
858 self.advance();
859 }
860
861 let inner_text = inner_lines.join("\n");
862
863 let node = match d.name.as_str() {
864 "panel" => {
865 let panel_type = d
866 .attrs
867 .as_ref()
868 .and_then(|a| a.get("type"))
869 .unwrap_or("info");
870 let inner_blocks =
871 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
872 let mut node = AdfNode::panel(panel_type, inner_blocks);
873 if let Some(ref attrs) = d.attrs {
875 if let Some(ref mut node_attrs) = node.attrs {
876 if let Some(icon) = attrs.get("icon") {
877 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
878 }
879 if let Some(color) = attrs.get("color") {
880 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
881 }
882 }
883 }
884 node
885 }
886 "expand" => {
887 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
888 let inner_blocks =
889 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
890 let mut node = AdfNode::expand(title, inner_blocks);
891 pass_through_expand_params(&d.attrs, &mut node);
892 node
893 }
894 "nested-expand" => {
895 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
896 let inner_blocks =
897 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
898 let mut node = AdfNode::nested_expand(title, inner_blocks);
899 pass_through_expand_params(&d.attrs, &mut node);
900 node
901 }
902 "layout" => {
903 let columns = self.parse_layout_columns(&inner_text)?;
905 AdfNode::layout_section(columns)
906 }
907 "decisions" => {
908 let items = parse_decision_items(&inner_text);
909 AdfNode::decision_list(items)
910 }
911 "table" => {
912 let rows = self.parse_directive_table_rows(&inner_text)?;
913 let mut table_attrs = serde_json::json!({});
914 if let Some(ref attrs) = d.attrs {
915 if let Some(layout) = attrs.get("layout") {
916 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
917 }
918 if attrs.has_flag("numbered") {
919 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
920 } else if attrs.get("numbered") == Some("false") {
921 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
922 }
923 if let Some(tw) = attrs.get("width") {
924 if let Some(w) = parse_numeric_attr(tw) {
925 table_attrs["width"] = w;
926 }
927 }
928 if let Some(local_id) = attrs.get("localId") {
929 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
930 }
931 }
932 if table_attrs == serde_json::json!({}) {
933 AdfNode::table(rows)
934 } else {
935 AdfNode::table_with_attrs(rows, table_attrs)
936 }
937 }
938 "extension" => {
939 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
940 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
941 let inner_blocks =
942 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
943 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
944 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
945 {
946 if let Some(layout) = dir_attrs.get("layout") {
947 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
948 }
949 if let Some(local_id) = dir_attrs.get("localId") {
950 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
951 }
952 if let Some(params_str) = dir_attrs.get("params") {
953 if let Ok(params_val) =
954 serde_json::from_str::<serde_json::Value>(params_str)
955 {
956 node_attrs["parameters"] = params_val;
957 }
958 }
959 }
960 node
961 }
962 _ => {
963 if !closed {
964 bail!(
974 "Unclosed `:::{}` directive at line {start_line} — add a matching `:::` close fence",
975 d.name
976 );
977 }
978 return Ok(None);
979 }
980 };
981
982 Ok(Some(node))
983 }
984
985 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
986 let mut columns = Vec::new();
987 let mut current_column_lines: Vec<String> = Vec::new();
988 let mut current_width: serde_json::Value = serde_json::json!(50);
989 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
990 let mut in_column = false;
991 let mut depth: usize = 0;
992
993 let lines: Vec<&str> = inner_text.lines().collect();
994 let mut i = 0;
995
996 while i < lines.len() {
997 let line = lines[i];
998 if let Some((col_d, _)) = try_parse_container_open(line) {
999 if col_d.name == "column" && depth == 0 {
1000 if in_column && !current_column_lines.is_empty() {
1002 let col_text = current_column_lines.join("\n");
1003 let blocks =
1004 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1005 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
1006 pass_through_local_id(¤t_dir_attrs, &mut col);
1007 columns.push(col);
1008 current_column_lines.clear();
1009 }
1010 current_width = col_d
1011 .attrs
1012 .as_ref()
1013 .and_then(|a| a.get("width"))
1014 .and_then(parse_numeric_attr)
1015 .unwrap_or_else(|| serde_json::json!(50));
1016 current_dir_attrs = col_d.attrs;
1017 in_column = true;
1018 i += 1;
1019 continue;
1020 }
1021 if in_column {
1022 depth += 1;
1023 }
1024 }
1025 if in_column && is_container_close(line, 3) {
1026 if depth > 0 {
1027 depth -= 1;
1028 current_column_lines.push(line.to_string());
1029 i += 1;
1030 continue;
1031 }
1032 let col_text = current_column_lines.join("\n");
1034 let blocks =
1035 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1036 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
1037 pass_through_local_id(¤t_dir_attrs, &mut col);
1038 columns.push(col);
1039 current_column_lines.clear();
1040 current_dir_attrs = None;
1041 in_column = false;
1042 i += 1;
1043 continue;
1044 }
1045 if in_column {
1046 current_column_lines.push(line.to_string());
1047 }
1048 i += 1;
1049 }
1050
1051 if in_column && !current_column_lines.is_empty() {
1053 let col_text = current_column_lines.join("\n");
1054 let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1055 let mut col = AdfNode::layout_column(current_width, blocks);
1056 pass_through_local_id(¤t_dir_attrs, &mut col);
1057 columns.push(col);
1058 }
1059
1060 Ok(columns)
1061 }
1062
1063 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
1065 debug!(
1066 "parse_directive_table_rows: {} lines of inner text",
1067 inner_text.lines().count()
1068 );
1069 let mut rows = Vec::new();
1070 let lines: Vec<&str> = inner_text.lines().collect();
1071 let mut i = 0;
1072
1073 while i < lines.len() {
1074 let line = lines[i];
1075 if let Some((d, _)) = try_parse_container_open(line) {
1076 if d.name == "tr" {
1077 let tr_attrs = d.attrs.clone();
1078 i += 1;
1079 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
1080 if let Some(ref attrs) = tr_attrs {
1082 if let Some(local_id) = attrs.get("localId") {
1083 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
1084 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1085 }
1086 }
1087 rows.push(row);
1088 i = next_i;
1089 continue;
1090 }
1091 if d.name == "caption" {
1092 let dir_attrs = d.attrs.clone();
1093 i += 1;
1094 let mut caption_lines = Vec::new();
1095 while i < lines.len() {
1096 if is_container_close(lines[i], 3) {
1097 i += 1;
1098 break;
1099 }
1100 caption_lines.push(lines[i]);
1101 i += 1;
1102 }
1103 let caption_text = caption_lines.join("\n");
1104 let inline_nodes = parse_inline(&caption_text);
1105 let mut caption = AdfNode::caption(inline_nodes);
1106 pass_through_local_id(&dir_attrs, &mut caption);
1107 rows.push(caption);
1108 continue;
1109 }
1110 }
1111 i += 1;
1112 }
1113
1114 Ok(rows)
1115 }
1116
1117 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
1119 let mut cells = Vec::new();
1120 let mut i = start;
1121 let mut depth: usize = 0;
1122
1123 while i < lines.len() {
1124 let line = lines[i];
1125 if is_container_close(line, 3) {
1126 if depth == 0 {
1127 i += 1;
1129 break;
1130 }
1131 depth -= 1;
1132 i += 1;
1133 continue;
1134 }
1135 if let Some((d, _)) = try_parse_container_open(line) {
1136 if depth == 0 && (d.name == "th" || d.name == "td") {
1137 let is_header = d.name == "th";
1138 let cell_attrs = d.attrs.clone();
1139 i += 1;
1140 let (cell, next_i) =
1141 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
1142 cells.push(cell);
1143 i = next_i;
1144 continue;
1145 }
1146 depth += 1;
1147 }
1148 i += 1;
1149 }
1150
1151 if cells.is_empty() {
1152 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
1153 warn!(
1154 "Directive table row at line {start} has no cells — \
1155 Confluence requires at least one. Nearby lines: {context:?}"
1156 );
1157 }
1158 debug!("Parsed directive table row: {} cells", cells.len());
1159
1160 Ok((AdfNode::table_row(cells), i))
1161 }
1162
1163 fn parse_directive_table_cell(
1165 &self,
1166 lines: &[&str],
1167 start: usize,
1168 is_header: bool,
1169 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
1170 ) -> Result<(AdfNode, usize)> {
1171 let mut cell_lines = Vec::new();
1172 let mut i = start;
1173 let mut depth: usize = 0;
1174
1175 while i < lines.len() {
1176 let line = lines[i];
1177 if try_parse_container_open(line).is_some() {
1178 depth += 1;
1179 } else if is_container_close(line, 3) {
1180 if depth == 0 {
1181 i += 1;
1182 break;
1183 }
1184 depth -= 1;
1185 }
1186 cell_lines.push(line.to_string());
1187 i += 1;
1188 }
1189
1190 let cell_text = cell_lines.join("\n");
1191 let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
1192
1193 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
1194 let cell_marks = cell_attrs
1195 .as_ref()
1196 .map(build_border_marks)
1197 .unwrap_or_default();
1198
1199 let cell = if cell_marks.is_empty() {
1200 if is_header {
1201 if let Some(attrs) = adf_attrs {
1202 AdfNode::table_header_with_attrs(blocks, attrs)
1203 } else {
1204 AdfNode::table_header(blocks)
1205 }
1206 } else if let Some(attrs) = adf_attrs {
1207 AdfNode::table_cell_with_attrs(blocks, attrs)
1208 } else {
1209 AdfNode::table_cell(blocks)
1210 }
1211 } else if is_header {
1212 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
1213 } else {
1214 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
1215 };
1216
1217 Ok((cell, i))
1218 }
1219
1220 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
1221 let line = self.current_line();
1222 let d = try_parse_leaf_directive(line)?;
1223
1224 let node = match d.name.as_str() {
1225 "card" => {
1226 let content = d.content.as_deref().unwrap_or("");
1227 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
1232 Some(u) => u,
1233 None => content,
1234 };
1235 let mut node = AdfNode::block_card(url);
1236 if let Some(ref attrs) = d.attrs {
1238 if let Some(ref mut node_attrs) = node.attrs {
1239 if let Some(layout) = attrs.get("layout") {
1240 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1241 }
1242 if let Some(width) = attrs.get("width") {
1243 if let Ok(w) = width.parse::<u64>() {
1244 node_attrs["width"] = serde_json::json!(w);
1245 }
1246 }
1247 }
1248 }
1249 node
1250 }
1251 "embed" => {
1252 let url = d.content.as_deref().unwrap_or("");
1253 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
1254 let original_height = d
1255 .attrs
1256 .as_ref()
1257 .and_then(|a| a.get("originalHeight"))
1258 .and_then(|v| v.parse::<f64>().ok());
1259 let width = d
1260 .attrs
1261 .as_ref()
1262 .and_then(|a| a.get("width"))
1263 .and_then(|w| w.parse::<f64>().ok());
1264 AdfNode::embed_card(url, layout, original_height, width)
1265 }
1266 "extension" => {
1267 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
1268 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
1269 let params = d
1270 .attrs
1271 .as_ref()
1272 .and_then(|a| a.get("params"))
1273 .and_then(|p| serde_json::from_str(p).ok());
1274 let mut node = AdfNode::extension(ext_type, ext_key, params);
1275 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
1276 {
1277 if let Some(layout) = dir_attrs.get("layout") {
1278 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1279 }
1280 if let Some(local_id) = dir_attrs.get("localId") {
1281 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1282 }
1283 }
1284 node
1285 }
1286 "paragraph" => {
1287 let mut node = if let Some(ref text) = d.content {
1288 AdfNode::paragraph(parse_inline(text))
1289 } else {
1290 AdfNode::paragraph(vec![])
1291 };
1292 pass_through_local_id(&d.attrs, &mut node);
1293 node
1294 }
1295 _ => return None,
1296 };
1297
1298 self.advance();
1299 Some(node)
1300 }
1301
1302 fn try_image(&mut self) -> Option<AdfNode> {
1303 let line = self.current_line().trim();
1304 let mut node = try_parse_media_single_from_line(line)?;
1305 self.advance();
1306
1307 if !self.at_end() {
1309 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1310 if d.name == "caption" {
1311 let dir_attrs = d.attrs;
1312 self.advance(); let mut caption_lines = Vec::new();
1314 while !self.at_end() {
1315 if is_container_close(self.current_line(), 3) {
1316 self.advance(); break;
1318 }
1319 caption_lines.push(self.current_line());
1320 self.advance();
1321 }
1322 let caption_text = caption_lines.join("\n");
1323 let inline_nodes = parse_inline(&caption_text);
1324 let mut caption = AdfNode::caption(inline_nodes);
1325 pass_through_local_id(&dir_attrs, &mut caption);
1326 if let Some(ref mut content) = node.content {
1327 content.push(caption);
1328 }
1329 }
1330 }
1331 }
1332
1333 Some(node)
1334 }
1335
1336 fn try_table(&mut self) -> Result<Option<AdfNode>> {
1337 let line = self.current_line();
1338 if !line.contains('|') || !line.trim_start().starts_with('|') {
1339 return Ok(None);
1340 }
1341
1342 if self.pos + 1 >= self.lines.len() {
1344 return Ok(None);
1345 }
1346 let next_line = self.lines[self.pos + 1];
1347 if !is_table_separator(next_line) {
1348 return Ok(None);
1349 }
1350
1351 let header_cells = parse_table_row(line);
1353 self.advance(); let sep_line = self.current_line();
1357 let alignments = parse_table_alignments(sep_line);
1358 self.advance(); let mut rows = Vec::new();
1361
1362 let header_adf_cells: Vec<AdfNode> = header_cells
1364 .iter()
1365 .enumerate()
1366 .map(|(col_idx, cell)| {
1367 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1368 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1369 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1370 if let Some(attrs) = cell_attrs {
1371 AdfNode::table_header_with_attrs(vec![para], attrs)
1372 } else {
1373 AdfNode::table_header(vec![para])
1374 }
1375 })
1376 .collect();
1377 if header_adf_cells.is_empty() {
1378 warn!(
1379 "Pipe table header row at line {} has no cells",
1380 self.pos - 1
1381 );
1382 }
1383 rows.push(AdfNode::table_row(header_adf_cells));
1384
1385 while !self.at_end() {
1387 let line = self.current_line();
1388 if !line.contains('|') || line.trim().is_empty() {
1389 break;
1390 }
1391
1392 let cells = parse_table_row(line);
1393 let adf_cells: Vec<AdfNode> = cells
1394 .iter()
1395 .enumerate()
1396 .map(|(col_idx, cell)| {
1397 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1398 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1399 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1400 if let Some(attrs) = cell_attrs {
1401 AdfNode::table_cell_with_attrs(vec![para], attrs)
1402 } else {
1403 AdfNode::table_cell(vec![para])
1404 }
1405 })
1406 .collect();
1407 if adf_cells.is_empty() {
1408 warn!("Pipe table body row at line {} has no cells", self.pos);
1409 }
1410 rows.push(AdfNode::table_row(adf_cells));
1411 self.advance();
1412 }
1413
1414 debug!("Parsed pipe table with {} rows", rows.len());
1415 let mut table = AdfNode::table(rows);
1416
1417 if !self.at_end() {
1419 let next = self.current_line().trim();
1420 if next.starts_with('{') {
1421 if let Some((_, attrs)) = parse_attrs(next, 0) {
1422 let mut table_attrs = serde_json::json!({});
1423 if let Some(layout) = attrs.get("layout") {
1424 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1425 }
1426 if attrs.has_flag("numbered") {
1427 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1428 } else if attrs.get("numbered") == Some("false") {
1429 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1430 }
1431 if let Some(tw) = attrs.get("width") {
1432 if let Some(w) = parse_numeric_attr(tw) {
1433 table_attrs["width"] = w;
1434 }
1435 }
1436 if let Some(local_id) = attrs.get("localId") {
1437 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1438 }
1439 if table_attrs != serde_json::json!({}) {
1440 table.attrs = Some(table_attrs);
1441 self.advance(); }
1443 }
1444 }
1445 }
1446
1447 Ok(Some(table))
1448 }
1449
1450 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1451 let mut lines: Vec<&str> = Vec::new();
1452
1453 while !self.at_end() {
1454 let line = self.current_line();
1455 if (line.trim().is_empty()
1464 && !lines
1465 .last()
1466 .is_some_and(|prev| has_trailing_hard_break(prev)))
1467 || is_code_fence_opener(line)
1468 || (is_horizontal_rule(line) && !lines.is_empty())
1469 {
1470 break;
1471 }
1472 let is_hardbreak_cont = !lines.is_empty()
1475 && line.starts_with(" ")
1476 && lines
1477 .last()
1478 .is_some_and(|prev| has_trailing_hard_break(prev));
1479 if is_hardbreak_cont {
1480 lines.push(&line[2..]);
1481 self.advance();
1482 continue;
1483 }
1484 if !lines.is_empty()
1485 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1486 {
1487 break;
1488 }
1489 if !lines.is_empty() && is_block_attrs_line(line) {
1491 break;
1492 }
1493 lines.push(line);
1494 self.advance();
1495 }
1496
1497 let text = lines.join("\n");
1498 let inline_nodes = parse_inline(&text);
1499 Ok(AdfNode::paragraph(inline_nodes))
1500 }
1501}
1502
1503fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1506 let mut adf = serde_json::json!({});
1507 if let Some(bg) = attrs.get("bg") {
1508 adf["background"] = serde_json::Value::String(bg.to_string());
1509 }
1510 if let Some(colspan) = attrs.get("colspan") {
1511 if let Ok(n) = colspan.parse::<u32>() {
1512 adf["colspan"] = serde_json::json!(n);
1513 }
1514 }
1515 if let Some(rowspan) = attrs.get("rowspan") {
1516 if let Ok(n) = rowspan.parse::<u32>() {
1517 adf["rowspan"] = serde_json::json!(n);
1518 }
1519 }
1520 if let Some(colwidth) = attrs.get("colwidth") {
1521 let widths: Vec<serde_json::Value> = colwidth
1522 .split(',')
1523 .filter_map(|s| parse_numeric_attr(s.trim()))
1524 .collect();
1525 if !widths.is_empty() {
1526 adf["colwidth"] = serde_json::Value::Array(widths);
1527 }
1528 }
1529 if let Some(local_id) = attrs.get("localId") {
1530 adf["localId"] = serde_json::Value::String(local_id.to_string());
1531 }
1532 adf
1533}
1534
1535fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1537 let mut marks = Vec::new();
1538 let border_color = attrs.get("border-color");
1539 let border_size = attrs.get("border-size");
1540 if border_color.is_some() || border_size.is_some() {
1541 let color = border_color.unwrap_or("#000000");
1542 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1543 marks.push(AdfMark::border(color, size));
1544 }
1545 marks
1546}
1547
1548fn iso_date_to_epoch_ms(date_str: &str) -> String {
1551 if date_str.chars().all(|c| c.is_ascii_digit()) {
1553 return date_str.to_string();
1554 }
1555 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1556 let epoch_ms = date
1557 .and_hms_opt(0, 0, 0)
1558 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1559 epoch_ms.to_string()
1560 } else {
1561 date_str.to_string()
1563 }
1564}
1565
1566fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1569 if timestamp.contains('-') {
1571 return timestamp.to_string();
1572 }
1573 if let Ok(ms) = timestamp.parse::<i64>() {
1574 let secs = ms / 1000;
1575 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1576 return dt.format("%Y-%m-%d").to_string();
1577 }
1578 }
1579 timestamp.to_string()
1581}
1582
1583fn is_block_attrs_line(line: &str) -> bool {
1585 let trimmed = line.trim();
1586 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1587 return false;
1588 }
1589 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1590 attrs.get("align").is_some()
1592 || attrs.get("indent").is_some()
1593 || attrs.get("breakout").is_some()
1594 || attrs.get("breakoutWidth").is_some()
1595 || attrs.get("localId").is_some()
1596 } else {
1597 false
1598 }
1599}
1600
1601fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1604 let mut items = Vec::new();
1605 for line in text.lines() {
1606 let trimmed = line.trim();
1607 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1608 let inline_nodes = parse_inline(rest);
1609 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1610 }
1611 }
1612 items
1613}
1614
1615fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1623 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1624 Some(("TODO", rest))
1625 } else if let Some(rest) =
1626 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1627 {
1628 Some(("DONE", rest))
1629 } else {
1630 None
1631 }
1632}
1633
1634fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1638 let rest = text.strip_prefix(checkbox)?;
1639 if rest.is_empty() {
1640 Some(rest)
1641 } else {
1642 rest.strip_prefix(' ')
1643 }
1644}
1645
1646fn starts_with_task_marker(s: &str) -> bool {
1654 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1655 rest
1656 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1657 rest
1658 } else {
1659 return false;
1660 };
1661 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1662}
1663
1664fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1666 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1667 if digit_end == 0 {
1668 return None;
1669 }
1670 let rest = &line[digit_end..];
1671 let after_marker = rest.strip_prefix(". ")?;
1672 let num: u32 = line[..digit_end].parse().ok()?;
1673 Some((num, after_marker))
1674}
1675
1676fn has_trailing_hard_break(line: &str) -> bool {
1679 line.ends_with('\\') || line.ends_with(" ")
1680}
1681
1682fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1690 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1691}
1692
1693fn is_list_start(line: &str) -> bool {
1695 let trimmed = line.trim_start();
1696 trimmed.starts_with("- ")
1697 || trimmed.starts_with("* ")
1698 || trimmed.starts_with("+ ")
1699 || parse_ordered_list_marker(trimmed).is_some()
1700}
1701
1702fn leading_spaces(line: &str) -> usize {
1707 line.bytes().take_while(|&b| b == b' ').count()
1708}
1709
1710fn escape_emphasis_markers(text: &str) -> String {
1723 escape_emphasis_with(text, false)
1724}
1725
1726fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1734 escape_emphasis_with(text, true)
1735}
1736
1737fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1743 let chars: Vec<char> = text.chars().collect();
1744 let mut out = String::with_capacity(text.len());
1745 let mut idx = 0;
1746 while idx < chars.len() {
1747 let ch = chars[idx];
1748 if ch == '*' {
1749 out.push('\\');
1750 out.push(ch);
1751 idx += 1;
1752 } else if ch == '_' {
1753 let run_start = idx;
1757 let mut run_end = idx;
1758 while run_end < chars.len() && chars[run_end] == '_' {
1759 run_end += 1;
1760 }
1761 let escape_run = if escape_underscore_always {
1762 true
1763 } else {
1764 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1765 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1766 !(before_alnum && after_alnum)
1767 };
1768 for _ in run_start..run_end {
1769 if escape_run {
1770 out.push('\\');
1771 }
1772 out.push('_');
1773 }
1774 idx = run_end;
1775 } else {
1776 out.push(ch);
1777 idx += 1;
1778 }
1779 }
1780 out
1781}
1782
1783fn escape_backticks(text: &str) -> String {
1789 let mut out = String::with_capacity(text.len());
1790 for ch in text.chars() {
1791 if ch == '`' {
1792 out.push('\\');
1793 }
1794 out.push(ch);
1795 }
1796 out
1797}
1798
1799fn inline_code_delimiter(text: &str) -> (usize, bool) {
1807 let mut max_run = 0usize;
1808 let mut current = 0usize;
1809 for ch in text.chars() {
1810 if ch == '`' {
1811 current += 1;
1812 if current > max_run {
1813 max_run = current;
1814 }
1815 } else {
1816 current = 0;
1817 }
1818 }
1819 let n = max_run + 1;
1820 let starts_bt = text.starts_with('`');
1821 let ends_bt = text.ends_with('`');
1822 let starts_sp = text.starts_with(' ');
1823 let ends_sp = text.ends_with(' ');
1824 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1825 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1826 (n, needs_pad)
1827}
1828
1829fn render_inline_code(text: &str, output: &mut String) {
1832 let (n, pad) = inline_code_delimiter(text);
1833 for _ in 0..n {
1834 output.push('`');
1835 }
1836 if pad {
1837 output.push(' ');
1838 }
1839 output.push_str(text);
1840 if pad {
1841 output.push(' ');
1842 }
1843 for _ in 0..n {
1844 output.push('`');
1845 }
1846}
1847
1848fn escape_pipes_in_cell(text: &str) -> String {
1855 let mut out = String::with_capacity(text.len());
1856 for ch in text.chars() {
1857 if ch == '|' {
1858 out.push('\\');
1859 }
1860 out.push(ch);
1861 }
1862 out
1863}
1864
1865fn escape_link_brackets(text: &str) -> String {
1870 let mut out = String::with_capacity(text.len());
1871 for ch in text.chars() {
1872 if ch == '[' || ch == ']' {
1873 out.push('\\');
1874 }
1875 out.push(ch);
1876 }
1877 out
1878}
1879
1880fn escape_bare_urls(text: &str) -> String {
1886 let mut result = String::with_capacity(text.len());
1887 for (i, ch) in text.char_indices() {
1888 if ch == 'h' {
1889 let rest = &text[i..];
1890 if rest.starts_with("http://") || rest.starts_with("https://") {
1891 result.push('\\');
1892 }
1893 }
1894 result.push(ch);
1895 }
1896 result
1897}
1898
1899fn url_safe_in_bracket_content(s: &str) -> bool {
1908 if s.contains('\n') {
1909 return false;
1910 }
1911 let mut depth: i32 = 1;
1912 for ch in s.chars() {
1913 match ch {
1914 '[' => depth += 1,
1915 ']' => {
1916 depth -= 1;
1917 if depth == 0 {
1918 return false;
1919 }
1920 }
1921 _ => {}
1922 }
1923 }
1924 true
1925}
1926
1927fn escape_emoji_shortcodes(text: &str) -> String {
1938 let mut result = String::with_capacity(text.len());
1939
1940 for (i, ch) in text.char_indices() {
1941 if ch == ':' {
1942 let after = i + 1;
1945 if after < text.len() {
1946 let name_end = text[after..]
1947 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1948 .unwrap_or(text[after..].len());
1949 if name_end > 0
1950 && after + name_end < text.len()
1951 && text.as_bytes()[after + name_end] == b':'
1952 {
1953 result.push('\\');
1955 }
1956 }
1957 }
1958 result.push(ch);
1959 }
1960
1961 result
1962}
1963
1964fn escape_list_marker(line: &str) -> String {
1968 if let Some(dot_pos) = line.find(". ") {
1969 if parse_ordered_list_marker(line).is_some() {
1970 let mut s = String::with_capacity(line.len() + 1);
1971 s.push_str(&line[..dot_pos]);
1972 s.push('\\');
1973 s.push_str(&line[dot_pos..]);
1974 return s;
1975 }
1976 }
1977 for prefix in &["- ", "* ", "+ "] {
1978 if line.starts_with(prefix) {
1979 let mut s = String::with_capacity(line.len() + 1);
1980 s.push('\\');
1981 s.push_str(line);
1982 return s;
1983 }
1984 }
1985 line.to_string()
1986}
1987
1988fn is_code_fence_opener(line: &str) -> bool {
1995 if !line.starts_with("```") {
1996 return false;
1997 }
1998 !line[3..].contains('`')
1999}
2000
2001fn is_horizontal_rule(line: &str) -> bool {
2003 let trimmed = line.trim();
2004 trimmed.len() >= 3
2005 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
2006 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
2007 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
2008}
2009
2010fn is_table_separator(line: &str) -> bool {
2012 let trimmed = line.trim();
2013 trimmed.contains('|')
2014 && trimmed
2015 .chars()
2016 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
2017}
2018
2019fn parse_table_row(line: &str) -> Vec<String> {
2026 let trimmed = line.trim();
2027 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
2028 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
2029
2030 let mut cells: Vec<String> = Vec::new();
2031 let mut current = String::new();
2032 let mut chars = trimmed.chars().peekable();
2033 while let Some(ch) = chars.next() {
2034 if ch == '\\' && chars.peek() == Some(&'|') {
2035 current.push('|');
2036 chars.next();
2037 } else if ch == '|' {
2038 cells.push(std::mem::take(&mut current));
2039 } else {
2040 current.push(ch);
2041 }
2042 }
2043 cells.push(current);
2044
2045 cells
2046 .iter()
2047 .map(|s| {
2048 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
2051 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
2052 stripped.to_string()
2053 })
2054 .collect()
2055}
2056
2057fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
2060 let trimmed = separator_line.trim();
2061 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
2062 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
2063
2064 trimmed
2065 .split('|')
2066 .map(|cell| {
2067 let cell = cell.trim();
2068 let starts_colon = cell.starts_with(':');
2069 let ends_colon = cell.ends_with(':');
2070 match (starts_colon, ends_colon) {
2071 (true, true) => Some("center"),
2072 (false, true) => Some("end"),
2073 _ => None, }
2075 })
2076 .collect()
2077}
2078
2079fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
2081 if let Some(align) = alignment {
2082 para.marks = Some(vec![AdfMark::alignment(align)]);
2083 }
2084}
2085
2086fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
2089 let trimmed = cell_text.trim_start();
2090 if !trimmed.starts_with('{') {
2091 return (cell_text.to_string(), None);
2092 }
2093 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
2094 let remaining = trimmed[end_pos..].trim_start().to_string();
2095 let adf_attrs = build_cell_attrs(&attrs);
2096 (remaining, Some(adf_attrs))
2097 } else {
2098 (cell_text.to_string(), None)
2099 }
2100}
2101
2102fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
2105 let line = line.trim();
2106 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
2115 let after_img = line[img_end..].trim_start();
2116
2117 if after_img.starts_with('{') {
2118 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
2119 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
2121 let mut media_attrs = serde_json::json!({"type": "file"});
2122 if let Some(id) = attrs.get("id") {
2123 media_attrs["id"] = serde_json::Value::String(id.to_string());
2124 }
2125 if let Some(collection) = attrs.get("collection") {
2126 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
2127 }
2128 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
2129 media_attrs["occurrenceKey"] =
2130 serde_json::Value::String(occurrence_key.to_string());
2131 }
2132 if let Some(height) = attrs.get("height") {
2133 if let Some(h) = parse_numeric_attr(height) {
2134 media_attrs["height"] = h;
2135 }
2136 }
2137 if let Some(width) = attrs.get("width") {
2138 if let Some(w) = parse_numeric_attr(width) {
2139 media_attrs["width"] = w;
2140 }
2141 }
2142 if let Some(alt_text) = alt_opt {
2143 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
2144 }
2145 if let Some(local_id) = attrs.get("localId") {
2146 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2147 }
2148 let mut ms_attrs = serde_json::json!({"layout": "center"});
2149 if let Some(layout) = attrs.get("layout") {
2150 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
2151 }
2152 if let Some(ms_width) = attrs.get("mediaWidth") {
2153 if let Some(w) = parse_numeric_attr(ms_width) {
2154 ms_attrs["width"] = w;
2155 }
2156 }
2157 if let Some(wt) = attrs.get("widthType") {
2158 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
2159 }
2160 if let Some(mode) = attrs.get("mode") {
2161 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
2162 }
2163 let border_marks = build_border_marks(&attrs);
2164 let media_marks = if border_marks.is_empty() {
2165 None
2166 } else {
2167 Some(border_marks)
2168 };
2169 return Some(AdfNode {
2170 node_type: "mediaSingle".to_string(),
2171 attrs: Some(ms_attrs),
2172 content: Some(vec![AdfNode {
2173 node_type: "media".to_string(),
2174 attrs: Some(media_attrs),
2175 content: None,
2176 text: None,
2177 marks: media_marks,
2178 local_id: None,
2179 parameters: None,
2180 }]),
2181 text: None,
2182 marks: None,
2183 local_id: None,
2184 parameters: None,
2185 });
2186 }
2187
2188 let mut node = AdfNode::media_single(url, alt_opt);
2190 if let Some(ref mut node_attrs) = node.attrs {
2191 if let Some(layout) = attrs.get("layout") {
2192 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
2193 }
2194 if let Some(width) = attrs.get("width") {
2195 if let Some(w) = parse_numeric_attr(width) {
2196 node_attrs["width"] = w;
2197 }
2198 }
2199 if let Some(wt) = attrs.get("widthType") {
2200 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
2201 }
2202 if let Some(mode) = attrs.get("mode") {
2203 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
2204 }
2205 }
2206 if let Some(ref mut content) = node.content {
2207 if let Some(media) = content.first_mut() {
2208 if let Some(local_id) = attrs.get("localId") {
2209 if let Some(ref mut media_attrs) = media.attrs {
2210 media_attrs["localId"] =
2211 serde_json::Value::String(local_id.to_string());
2212 }
2213 }
2214 let border_marks = build_border_marks(&attrs);
2215 if !border_marks.is_empty() {
2216 media.marks = Some(border_marks);
2217 }
2218 }
2219 }
2220 return Some(node);
2221 }
2222 }
2223
2224 Some(AdfNode::media_single(url, alt_opt))
2225}
2226
2227fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
2229 let line = line.trim();
2230 if !line.starts_with("?;
2235 let alt = &line[2..alt_end];
2236 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
2238 let url = &line[paren_start + 1..url_end];
2239
2240 Some((alt, url))
2241}
2242
2243fn parse_inline(text: &str) -> Vec<AdfNode> {
2251 parse_inline_impl(text, true, 0)
2252}
2253
2254fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
2269 if depth > MAX_NESTING_DEPTH {
2270 return if text.is_empty() {
2271 Vec::new()
2272 } else {
2273 vec![AdfNode::text(text)]
2274 };
2275 }
2276
2277 let mut nodes = Vec::new();
2278 let mut chars = text.char_indices().peekable();
2279 let mut plain_start = 0;
2280
2281 while let Some(&(i, ch)) = chars.peek() {
2282 match ch {
2283 '*' | '_' => {
2284 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
2285 flush_plain(text, plain_start, i, &mut nodes);
2286 let mark = if is_bold {
2287 AdfMark::strong()
2288 } else {
2289 AdfMark::em()
2290 };
2291 let inner = parse_inline_impl(content, false, depth + 1);
2292 for mut node in inner {
2293 prepend_mark(&mut node, mark.clone());
2294 nodes.push(node);
2295 }
2296 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2298 chars.next();
2299 }
2300 plain_start = end;
2301 continue;
2302 }
2303 if ch == '_' {
2308 while chars.peek().is_some_and(|&(_, c)| c == '_') {
2309 chars.next();
2310 }
2311 } else {
2312 chars.next();
2313 }
2314 }
2315 '~' => {
2316 if let Some((end, content)) = try_parse_strikethrough(text, i) {
2317 flush_plain(text, plain_start, i, &mut nodes);
2318 let inner = parse_inline_impl(content, false, depth + 1);
2319 for mut node in inner {
2320 prepend_mark(&mut node, AdfMark::strike());
2321 nodes.push(node);
2322 }
2323 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2324 chars.next();
2325 }
2326 plain_start = end;
2327 continue;
2328 }
2329 chars.next();
2330 }
2331 '`' => {
2332 if let Some((end, content)) = try_parse_inline_code(text, i) {
2333 flush_plain(text, plain_start, i, &mut nodes);
2334 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2335 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2336 chars.next();
2337 }
2338 plain_start = end;
2339 continue;
2340 }
2341 while chars.peek().is_some_and(|&(_, c)| c == '`') {
2344 chars.next();
2345 }
2346 }
2347 '[' => {
2348 if let Some((end, link_text, href)) = try_parse_link(text, i) {
2349 flush_plain(text, plain_start, i, &mut nodes);
2350 if link_text.starts_with("http://") || link_text.starts_with("https://") {
2351 nodes.push(AdfNode::text_with_marks(
2356 link_text,
2357 vec![AdfMark::link(href)],
2358 ));
2359 } else {
2360 let inner = parse_inline_impl(link_text, false, depth + 1);
2361 for mut node in inner {
2362 prepend_mark(&mut node, AdfMark::link(href));
2363 nodes.push(node);
2364 }
2365 }
2366 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2367 chars.next();
2368 }
2369 plain_start = end;
2370 continue;
2371 }
2372 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2374 flush_plain(text, plain_start, i, &mut nodes);
2375 nodes.extend(span_nodes);
2376 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2377 chars.next();
2378 }
2379 plain_start = end;
2380 continue;
2381 }
2382 chars.next();
2383 }
2384 ':' => {
2385 if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2387 flush_plain(text, plain_start, i, &mut nodes);
2388 let end = node.1;
2389 nodes.push(node.0);
2390 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2391 chars.next();
2392 }
2393 plain_start = end;
2394 continue;
2395 }
2396 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2398 flush_plain(text, plain_start, i, &mut nodes);
2399 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2400 nodes.push(emoji_node);
2401 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2402 chars.next();
2403 }
2404 plain_start = final_end;
2405 continue;
2406 }
2407 chars.next();
2408 }
2409 ' ' if text[i..].starts_with(" \n") => {
2410 flush_plain(text, plain_start, i, &mut nodes);
2413 nodes.push(AdfNode::hard_break());
2414 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2416 chars.next();
2417 }
2418 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2420 chars.next();
2421 }
2422 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2423 }
2424 '!' if text[i..].starts_with("![") => {
2425 chars.next();
2429 }
2430 'h' if auto_inline_card
2431 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2432 {
2433 if let Some((end, url)) = try_parse_bare_url(text, i) {
2434 flush_plain(text, plain_start, i, &mut nodes);
2435 nodes.push(AdfNode::inline_card(url));
2436 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2437 chars.next();
2438 }
2439 plain_start = end;
2440 continue;
2441 }
2442 chars.next();
2443 }
2444 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2445 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2446 {
2447 flush_plain(text, plain_start, i, &mut nodes);
2451 nodes.push(AdfNode::text("\n"));
2452 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2455 }
2456 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2457 flush_plain(text, plain_start, i, &mut nodes);
2462 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2467 chars.next(); }
2469 '\\' if text[i..].starts_with("\\\n") => {
2470 flush_plain(text, plain_start, i, &mut nodes);
2472 nodes.push(AdfNode::hard_break());
2473 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2476 chars.next();
2477 }
2478 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2479 }
2480 '\\' if i + 1 == text.len() => {
2481 flush_plain(text, plain_start, i, &mut nodes);
2483 nodes.push(AdfNode::hard_break());
2484 chars.next(); plain_start = text.len();
2486 }
2487 _ => {
2488 chars.next();
2489 }
2490 }
2491 }
2492
2493 if plain_start < text.len() {
2495 let remaining = &text[plain_start..];
2496 if !remaining.is_empty() {
2497 nodes.push(AdfNode::text(remaining));
2498 }
2499 }
2500
2501 merge_adjacent_text(&mut nodes);
2504
2505 nodes
2506}
2507
2508fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2510 let mut i = 0;
2511 while i + 1 < nodes.len() {
2512 if nodes[i].node_type == "text"
2513 && nodes[i + 1].node_type == "text"
2514 && nodes[i].marks.is_none()
2515 && nodes[i + 1].marks.is_none()
2516 {
2517 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2518 if let Some(ref mut t) = nodes[i].text {
2519 t.push_str(&next_text);
2520 }
2521 nodes.remove(i + 1);
2522 } else {
2523 i += 1;
2524 }
2525 }
2526}
2527
2528fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2530 if start < end {
2531 let plain = &text[start..end];
2532 if !plain.is_empty() {
2533 nodes.push(AdfNode::text(plain));
2534 }
2535 }
2536}
2537
2538#[cfg(test)]
2540fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2541 if let Some(ref mut marks) = node.marks {
2542 marks.push(mark);
2543 } else {
2544 node.marks = Some(vec![mark]);
2545 }
2546}
2547
2548fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2559 let mut removed = 0;
2560 for node in nodes.iter_mut() {
2561 if let Some(marks) = node.marks.as_mut() {
2562 let before = marks.len();
2563 marks.retain(|m| m.mark_type != "code");
2564 removed += before - marks.len();
2565 if marks.is_empty() {
2566 node.marks = None;
2567 }
2568 }
2569 if let Some(children) = node.content.as_mut() {
2570 removed += strip_heading_code_marks(children);
2571 }
2572 }
2573 removed
2574}
2575
2576const EMPHASIS_MARKS_SPLIT_FROM_CODE: [&str; 3] = ["strong", "em", "strike"];
2582
2583fn split_emphasis_code_marks(nodes: &mut [AdfNode]) -> usize {
2596 let mut dropped = 0;
2597 for node in nodes.iter_mut() {
2598 if let Some(marks) = node.marks.as_mut() {
2599 if node.text.is_some() && marks.iter().any(|m| m.mark_type == "code") {
2600 let before = marks.len();
2601 marks.retain(|m| !EMPHASIS_MARKS_SPLIT_FROM_CODE.contains(&m.mark_type.as_str()));
2602 dropped += before - marks.len();
2603 }
2604 }
2605 if let Some(children) = node.content.as_mut() {
2606 dropped += split_emphasis_code_marks(children);
2607 }
2608 }
2609 dropped
2610}
2611
2612fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2614 if let Some(ref mut marks) = node.marks {
2615 marks.insert(0, mark);
2616 } else {
2617 node.marks = Some(vec![mark]);
2618 }
2619}
2620
2621fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2626 let before = text[..delim_pos]
2627 .chars()
2628 .next_back()
2629 .is_some_and(char::is_alphanumeric);
2630 let after = text[delim_pos + len..]
2631 .chars()
2632 .next()
2633 .is_some_and(char::is_alphanumeric);
2634 before && after
2635}
2636
2637fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2641 let needle_bytes = needle.as_bytes();
2642 let hay_bytes = haystack.as_bytes();
2643 let mut i = 0;
2644 while i < hay_bytes.len() {
2645 if hay_bytes[i] == b'\\' {
2646 i += 2; continue;
2648 }
2649 if hay_bytes[i..].starts_with(needle_bytes) {
2650 return Some(i);
2651 }
2652 i += 1;
2653 }
2654 None
2655}
2656
2657fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2660 let hay_bytes = haystack.as_bytes();
2661 let mut i = 0;
2662 while i < hay_bytes.len() {
2663 if hay_bytes[i] == b'\\' {
2664 i += 2;
2665 continue;
2666 }
2667 if hay_bytes[i] == ch {
2668 return Some(i);
2669 }
2670 i += 1;
2671 }
2672 None
2673}
2674
2675fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2685 let rest = &text[i..];
2686
2687 if rest.starts_with("***") || rest.starts_with("___") {
2691 let is_underscore = rest.starts_with("___");
2692 if is_underscore && is_intraword_underscore(text, i, 3) {
2693 return None;
2694 }
2695 let triple = &rest[..3];
2696 let after = &rest[3..];
2697 if let Some(close) = find_unescaped(after, triple) {
2698 if close > 0 {
2699 let close_pos = i + 3 + close;
2700 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2701 return None;
2702 }
2703 let content = &rest[2..=3 + close];
2707 let end = i + 3 + close + 3;
2708 return Some((end, content, true));
2709 }
2710 }
2711 }
2712
2713 if rest.starts_with("**") || rest.starts_with("__") {
2715 let is_underscore = rest.starts_with("__");
2716 if is_underscore && is_intraword_underscore(text, i, 2) {
2717 return None;
2718 }
2719 let delimiter = &rest[..2];
2720 let after = &rest[2..];
2721 let close = find_unescaped(after, delimiter)?;
2722 if close == 0 {
2723 return None;
2724 }
2725 let close_pos = i + 2 + close;
2726 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2727 return None;
2728 }
2729 let content = &after[..close];
2730 let end = i + 2 + close + 2;
2731 return Some((end, content, true));
2732 }
2733
2734 if rest.starts_with('*') || rest.starts_with('_') {
2736 let delim_char = rest.as_bytes()[0];
2737 let is_underscore = delim_char == b'_';
2738 if is_underscore && is_intraword_underscore(text, i, 1) {
2739 return None;
2740 }
2741 let after = &rest[1..];
2742 let close = find_unescaped_char(after, delim_char)?;
2743 if close == 0 {
2744 return None;
2745 }
2746 let close_pos = i + 1 + close;
2747 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2748 return None;
2749 }
2750 let content = &after[..close];
2751 let end = i + 1 + close + 1;
2752 return Some((end, content, false));
2753 }
2754
2755 None
2756}
2757
2758fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2760 let rest = &text[i..];
2761 if !rest.starts_with("~~") {
2762 return None;
2763 }
2764 let after = &rest[2..];
2765 let close = after.find("~~")?;
2766 if close == 0 {
2767 return None;
2768 }
2769 let content = &after[..close];
2770 Some((i + 2 + close + 2, content))
2771}
2772
2773fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2780 let rest = &text[i..];
2781 let bytes = rest.as_bytes();
2782 if bytes.is_empty() || bytes[0] != b'`' {
2783 return None;
2784 }
2785 let mut opening = 0usize;
2786 while opening < bytes.len() && bytes[opening] == b'`' {
2787 opening += 1;
2788 }
2789
2790 let mut j = opening;
2791 while j < bytes.len() {
2792 if bytes[j] == b'`' {
2793 let run_start = j;
2794 while j < bytes.len() && bytes[j] == b'`' {
2795 j += 1;
2796 }
2797 if j - run_start == opening {
2798 let content = &rest[opening..run_start];
2799 let content = strip_code_span_padding(content);
2800 return Some((i + j, content));
2801 }
2802 } else {
2803 j += 1;
2804 }
2805 }
2806 None
2807}
2808
2809fn strip_code_span_padding(content: &str) -> &str {
2813 let bytes = content.as_bytes();
2814 if bytes.len() >= 2
2815 && bytes[0] == b' '
2816 && bytes[bytes.len() - 1] == b' '
2817 && content.bytes().any(|b| b != b' ')
2818 {
2819 &content[1..content.len() - 1]
2820 } else {
2821 content
2822 }
2823}
2824
2825fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2829 let rest = &text[i..];
2830 if !rest.starts_with('[') {
2831 return None;
2832 }
2833
2834 let mut bracket_depth: usize = 0;
2838 let mut bracket_close = None;
2839 let bs_bytes = rest.as_bytes();
2840 for (j, ch) in rest.char_indices() {
2841 match ch {
2842 '\\' if j + 1 < bs_bytes.len()
2843 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2844 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2845 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2846 bracket_depth -= 1;
2847 if bracket_depth == 0 {
2848 bracket_close = Some(j);
2849 break;
2850 }
2851 }
2852 _ => {}
2853 }
2854 }
2855 let bracket_close = bracket_close?;
2856 let after_bracket = &rest[bracket_close + 1..];
2858 if !after_bracket.starts_with('{') {
2859 return None;
2860 }
2861
2862 let span_text = &rest[1..bracket_close];
2863 let attrs_start = i + bracket_close + 1;
2864 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2865
2866 let mut marks = Vec::new();
2867 if attrs.has_flag("underline") {
2868 marks.push(AdfMark::underline());
2869 }
2870 let ann_ids = attrs.get_all("annotation-id");
2871 let ann_types = attrs.get_all("annotation-type");
2872 for (idx, ann_id) in ann_ids.iter().enumerate() {
2873 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2874 marks.push(AdfMark::annotation(ann_id, ann_type));
2875 }
2876
2877 if marks.is_empty() {
2878 return None; }
2880
2881 let inner = parse_inline_impl(span_text, false, depth + 1);
2882 let result: Vec<AdfNode> = inner
2883 .into_iter()
2884 .map(|mut node| {
2885 let mut combined = marks.clone();
2888 if let Some(ref existing) = node.marks {
2889 combined.extend(existing.iter().cloned());
2890 }
2891 node.marks = Some(combined);
2892 node
2893 })
2894 .collect();
2895
2896 Some((attrs_end, result))
2897}
2898
2899fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2903 let d = try_parse_inline_directive(text, pos)?;
2904 let content = d.content.as_deref().unwrap_or("");
2905
2906 let node = match d.name.as_str() {
2907 "card" => {
2908 let url = d
2913 .attrs
2914 .as_ref()
2915 .and_then(|a| a.get("url"))
2916 .unwrap_or(content);
2917 let mut node = AdfNode::inline_card(url);
2918 pass_through_local_id(&d.attrs, &mut node);
2919 node
2920 }
2921 "status" => {
2922 let color = d
2923 .attrs
2924 .as_ref()
2925 .and_then(|a| a.get("color"))
2926 .unwrap_or("neutral");
2927 let mut node = AdfNode::status(content, color);
2928 if let Some(ref attrs) = d.attrs {
2930 if let Some(ref mut node_attrs) = node.attrs {
2931 if let Some(style) = attrs.get("style") {
2932 node_attrs["style"] = serde_json::Value::String(style.to_string());
2933 }
2934 if let Some(local_id) = attrs.get("localId") {
2935 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2936 }
2937 }
2938 }
2939 node
2940 }
2941 "date" => {
2942 let timestamp = d
2943 .attrs
2944 .as_ref()
2945 .and_then(|a| a.get("timestamp"))
2946 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2947 let mut node = AdfNode::date(×tamp);
2948 pass_through_local_id(&d.attrs, &mut node);
2949 node
2950 }
2951 "mention" => {
2952 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2953 let mut node = AdfNode::mention(id, content);
2954 if let Some(ref attrs) = d.attrs {
2956 if let (Some(ref mut node_attrs), true) = (
2957 &mut node.attrs,
2958 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2959 ) {
2960 if let Some(ut) = attrs.get("userType") {
2961 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2962 }
2963 if let Some(al) = attrs.get("accessLevel") {
2964 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2965 }
2966 }
2967 }
2968 pass_through_local_id(&d.attrs, &mut node);
2969 node
2970 }
2971 "span" => {
2972 let mut marks = Vec::new();
2973 if let Some(ref attrs) = d.attrs {
2974 if let Some(color) = attrs.get("color") {
2975 marks.push(AdfMark::text_color(color));
2976 }
2977 if let Some(bg) = attrs.get("bg") {
2978 marks.push(AdfMark::background_color(bg));
2979 }
2980 if attrs.has_flag("sub") {
2981 marks.push(AdfMark::subsup("sub"));
2982 }
2983 if attrs.has_flag("sup") {
2984 marks.push(AdfMark::subsup("sup"));
2985 }
2986 }
2987 if marks.is_empty() {
2988 AdfNode::text(content)
2989 } else {
2990 let inner = parse_inline_impl(content, false, depth + 1);
2993 let mut nodes: Vec<AdfNode> = inner
2994 .into_iter()
2995 .map(|mut node| {
2996 let mut combined = marks.clone();
2997 if let Some(ref existing) = node.marks {
2998 combined.extend(existing.iter().cloned());
2999 }
3000 node.marks = Some(combined);
3001 node
3002 })
3003 .collect();
3004 nodes.remove(0)
3006 }
3007 }
3008 "placeholder" => AdfNode::placeholder(content),
3009 "media-inline" => {
3010 let mut json_attrs = serde_json::Map::new();
3011 if let Some(ref attrs) = d.attrs {
3012 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
3013 if let Some(val) = attrs.get(key) {
3014 if *key == "width" || *key == "height" {
3015 if let Ok(n) = val.parse::<u64>() {
3016 json_attrs.insert(
3017 (*key).to_string(),
3018 serde_json::Value::Number(n.into()),
3019 );
3020 continue;
3021 }
3022 }
3023 json_attrs.insert(
3024 (*key).to_string(),
3025 serde_json::Value::String(val.to_string()),
3026 );
3027 }
3028 }
3029 if let Some(local_id) = attrs.get("localId") {
3030 json_attrs.insert(
3031 "localId".to_string(),
3032 serde_json::Value::String(local_id.to_string()),
3033 );
3034 }
3035 }
3036 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
3037 }
3038 "extension" => {
3039 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
3040 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
3041 AdfNode::inline_extension(ext_type, ext_key, Some(content))
3042 }
3043 "adf-unsupported" => {
3044 let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
3052 serde_json::from_str::<AdfNode>(json).ok()?
3053 }
3054 _ => return None, };
3056
3057 Some((node, d.end_pos))
3058}
3059
3060fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
3063 let rest = &text[i..];
3064 if !rest.starts_with("http://") && !rest.starts_with("https://") {
3065 return None;
3066 }
3067 let end = rest
3069 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
3070 .unwrap_or(rest.len());
3071 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
3073 if url.len() <= "https://".len() {
3074 return None; }
3076 Some((i + url.len(), url))
3077}
3078
3079fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
3082 let rest = &text[i..];
3083 if !rest.starts_with(':') {
3084 return None;
3085 }
3086 let after = &rest[1..];
3087 let name_end =
3088 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
3089 if name_end == 0 {
3090 return None;
3091 }
3092 if after.as_bytes().get(name_end) != Some(&b':') {
3093 return None;
3094 }
3095 let name = &after[..name_end];
3096 Some((i + 1 + name_end + 1, name))
3097}
3098
3099fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
3102 let mut chain_end = shortcode_end;
3107 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
3108 chain_end = next_end;
3109 }
3110 if chain_end > shortcode_end {
3111 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
3112 return (attr_end, build_emoji_node(&attrs, short_name));
3113 }
3114 }
3115
3116 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
3117 (attr_end, build_emoji_node(&attrs, short_name))
3118 } else {
3119 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
3120 }
3121}
3122
3123fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
3126 let resolved_name = attrs
3127 .get("shortName")
3128 .map_or_else(|| format!(":{short_name}:"), str::to_string);
3129 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
3130 if let Some(id) = attrs.get("id") {
3131 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
3132 }
3133 if let Some(t) = attrs.get("text") {
3134 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
3135 }
3136 if let Some(lid) = attrs.get("localId") {
3137 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
3138 }
3139 AdfNode {
3140 node_type: "emoji".to_string(),
3141 attrs: Some(emoji_attrs),
3142 content: None,
3143 text: None,
3144 marks: None,
3145 local_id: None,
3146 parameters: None,
3147 }
3148}
3149
3150fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
3155 let mut depth: usize = 0;
3156 for (j, ch) in s[open..].char_indices() {
3157 match ch {
3158 '(' => depth += 1,
3159 ')' => {
3160 depth -= 1;
3161 if depth == 0 {
3162 return Some(open + j);
3163 }
3164 }
3165 _ => {}
3166 }
3167 }
3168 None
3169}
3170
3171fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
3177 let rest = &text[i..];
3178 if !rest.starts_with('[') {
3179 return None;
3180 }
3181
3182 let mut depth: usize = 0;
3184 let mut text_end = None;
3185 let bytes = rest.as_bytes();
3186 for (j, ch) in rest.char_indices() {
3187 match ch {
3188 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
3189 }
3191 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
3192 ']' if j == 0 || bytes[j - 1] != b'\\' => {
3193 depth -= 1;
3194 if depth == 0 {
3195 text_end = Some(j);
3196 break;
3197 }
3198 }
3199 _ => {}
3200 }
3201 }
3202
3203 let text_end = text_end?;
3204 let link_text = &rest[1..text_end];
3205 let after_bracket = &rest[text_end + 1..];
3207 if !after_bracket.starts_with('(') {
3208 return None;
3209 }
3210 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
3212 let href = &rest[url_start + 1..url_end];
3213
3214 Some((i + url_end + 1, link_text, href))
3215}
3216
3217#[derive(Debug, Clone, Default)]
3221pub struct RenderOptions {
3222 pub strip_local_ids: bool,
3224}
3225
3226pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
3228 adf_to_markdown_with_options(doc, &RenderOptions::default())
3229}
3230
3231pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
3233 let mut output = String::new();
3234 render_block_children(&doc.content, &mut output, opts);
3235 Ok(output)
3236}
3237
3238#[must_use]
3247pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
3248 let mut out = String::new();
3249 for node in &doc.content {
3250 collect_plain_text(node, &mut out);
3251 if !out.is_empty() && !out.ends_with(' ') {
3252 out.push(' ');
3253 }
3254 }
3255 out.truncate(out.trim_end().len());
3256 out
3257}
3258
3259fn collect_plain_text(node: &AdfNode, out: &mut String) {
3260 if let Some(text) = &node.text {
3261 out.push_str(text);
3262 }
3263 if let Some(children) = &node.content {
3264 for child in children {
3265 collect_plain_text(child, out);
3266 }
3267 }
3268}
3269
3270fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
3274 if let Some(ref attrs) = dir_attrs {
3275 if let Some(local_id) = attrs.get("localId") {
3276 if let Some(ref mut node_attrs) = node.attrs {
3277 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
3278 } else {
3279 node.attrs = Some(serde_json::json!({"localId": local_id}));
3280 }
3281 }
3282 }
3283}
3284
3285fn pass_through_expand_params(
3288 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
3289 node: &mut AdfNode,
3290) {
3291 if let Some(ref attrs) = dir_attrs {
3292 if let Some(local_id) = attrs.get("localId") {
3293 node.local_id = Some(local_id.to_string());
3294 }
3295 if let Some(params_str) = attrs.get("params") {
3296 if let Ok(params) = serde_json::from_str(params_str) {
3297 node.parameters = Some(params);
3298 }
3299 }
3300 }
3301}
3302
3303fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
3313 let trimmed = text.trim_end();
3314 if !trimmed.ends_with('}') {
3315 return (text, None, None);
3316 }
3317 if let Some(brace_pos) = trimmed.rfind('{') {
3322 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
3323 return (text, None, None);
3324 }
3325 let attr_str = &trimmed[brace_pos..];
3326 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
3327 let local_id = attrs.get("localId").map(str::to_string);
3328 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
3329 if local_id.is_some() || para_local_id.is_some() {
3330 let before = trimmed[..brace_pos]
3331 .strip_suffix(' ')
3332 .unwrap_or(&trimmed[..brace_pos]);
3333 return (before, local_id, para_local_id);
3334 }
3335 }
3336 }
3337 (text, None, None)
3338}
3339
3340fn parse_list_item_first_line(
3350 item_text: &str,
3351 sub_lines: Vec<String>,
3352 local_id: Option<String>,
3353 para_local_id: Option<String>,
3354 depth: usize,
3355) -> Result<AdfNode> {
3356 if item_text.starts_with("```") {
3357 let mut all_lines = vec![item_text.to_string()];
3359 all_lines.extend(sub_lines);
3360 let combined = all_lines.join("\n");
3361 let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3362 Ok(list_item_with_local_id(nested, local_id, para_local_id))
3363 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3364 if sub_lines.is_empty() {
3366 Ok(list_item_with_local_id(
3367 vec![media],
3368 local_id,
3369 para_local_id,
3370 ))
3371 } else {
3372 let sub_text = sub_lines.join("\n");
3373 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3374 let mut content = vec![media];
3375 content.append(&mut nested);
3376 Ok(list_item_with_local_id(content, local_id, para_local_id))
3377 }
3378 } else {
3379 let first_node = AdfNode::paragraph(parse_inline(item_text));
3380 if sub_lines.is_empty() {
3381 Ok(list_item_with_local_id(
3382 vec![first_node],
3383 local_id,
3384 para_local_id,
3385 ))
3386 } else {
3387 let sub_text = sub_lines.join("\n");
3388 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3389 let mut content = vec![first_node];
3390 content.append(&mut nested);
3391 Ok(list_item_with_local_id(content, local_id, para_local_id))
3392 }
3393 }
3394}
3395
3396fn list_item_with_local_id(
3397 mut content: Vec<AdfNode>,
3398 local_id: Option<String>,
3399 para_local_id: Option<String>,
3400) -> AdfNode {
3401 if let Some(id) = ¶_local_id {
3402 if let Some(first) = content.first_mut() {
3403 if first.node_type == "paragraph" {
3404 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3405 node_attrs["localId"] = serde_json::Value::String(id.clone());
3406 }
3407 }
3408 }
3409 let mut item = AdfNode::list_item(content);
3410 if let Some(id) = local_id {
3411 item.attrs = Some(serde_json::json!({"localId": id}));
3412 }
3413 item
3414}
3415
3416fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3417 if opts.strip_local_ids {
3418 return;
3419 }
3420 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3421 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3422 parts.push(format_kv("localId", local_id));
3423 }
3424 }
3425}
3426
3427fn needs_list_separator(node: &AdfNode, next: Option<&AdfNode>) -> bool {
3449 if !is_list_node_type(&node.node_type) || list_item_count(node) == 0 {
3450 return false;
3451 }
3452 let Some(next) = next else {
3453 return false;
3454 };
3455 if list_item_count(next) == 0 {
3456 return false;
3457 }
3458 match (node.node_type.as_str(), next.node_type.as_str()) {
3459 ("bulletList" | "taskList", "bulletList" | "taskList") => true,
3460 ("orderedList", "orderedList") => {
3461 let start = ordered_list_start(node);
3462 let len = u32::try_from(list_item_count(node)).unwrap_or(u32::MAX);
3463 let next_start = ordered_list_start(next);
3464 next_start == start.saturating_add(len) || next_start == 1
3465 }
3466 _ => false,
3467 }
3468}
3469
3470fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3472 for (i, child) in children.iter().enumerate() {
3473 if i > 0 {
3474 output.push('\n');
3475 }
3476 let separate = needs_list_separator(child, children.get(i + 1));
3477 render_block_node_separated(child, output, opts, separate);
3478 }
3479}
3480
3481fn fmt_f64_attr(v: f64) -> String {
3484 if v.fract() == 0.0 {
3485 format!("{}", v as i64)
3486 } else {
3487 v.to_string()
3488 }
3489}
3490
3491fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3498 if s.contains('.') || s.contains('e') || s.contains('E') {
3499 s.parse::<f64>().ok().map(serde_json::Value::from)
3500 } else {
3501 s.parse::<i64>().ok().map(serde_json::Value::from)
3502 }
3503}
3504
3505fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3513 if let Some(n) = v.as_i64() {
3514 return Some(n.to_string());
3515 }
3516 if let Some(n) = v.as_u64() {
3517 return Some(n.to_string());
3518 }
3519 if let Some(n) = v.as_f64() {
3520 if n.fract() == 0.0 && n.is_finite() {
3521 return Some(format!("{n:.1}"));
3522 }
3523 return Some(n.to_string());
3524 }
3525 None
3526}
3527
3528fn paragraph_needs_directive_form(buf: &str) -> bool {
3543 if buf.trim().is_empty() {
3544 return !buf.is_empty();
3545 }
3546 buf.starts_with([' ', '\t']) && !buf.contains('\n')
3547}
3548
3549fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3551 render_block_node_separated(node, output, opts, false);
3552}
3553
3554fn render_block_node_separated(
3563 node: &AdfNode,
3564 output: &mut String,
3565 opts: &RenderOptions,
3566 separate_from_next: bool,
3567) {
3568 match node.node_type.as_str() {
3569 "paragraph" => {
3570 let is_empty = node.content.as_ref().is_none_or(Vec::is_empty);
3571 let dir_attrs = {
3573 let mut parts = Vec::new();
3574 if let Some(ref attrs) = node.attrs {
3575 maybe_push_local_id(attrs, &mut parts, opts);
3576 }
3577 if parts.is_empty() {
3578 String::new()
3579 } else {
3580 format!("{{{}}}", parts.join(" "))
3581 }
3582 };
3583 if is_empty {
3584 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3585 } else {
3586 let mut buf = String::new();
3588 render_inline_content(node, &mut buf, opts);
3589 if paragraph_needs_directive_form(&buf) {
3590 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3591 } else {
3592 let mut is_first_line = true;
3597 for line in buf.split('\n') {
3598 if is_first_line {
3599 if is_list_start(line) {
3600 output.push_str(&escape_list_marker(line));
3601 } else {
3602 output.push_str(line);
3603 }
3604 is_first_line = false;
3605 } else {
3606 output.push('\n');
3607 if !line.is_empty() {
3608 output.push_str(" ");
3609 }
3610 output.push_str(line);
3611 }
3612 }
3613 output.push('\n');
3614 }
3615 }
3616 }
3617 "heading" => {
3618 let level = node
3619 .attrs
3620 .as_ref()
3621 .and_then(|a| a.get("level"))
3622 .and_then(serde_json::Value::as_u64)
3623 .unwrap_or(1);
3624 for _ in 0..level {
3625 output.push('#');
3626 }
3627 output.push(' ');
3628 let mut buf = String::new();
3629 render_inline_content(node, &mut buf, opts);
3630 let mut is_first_line = true;
3633 for line in buf.split('\n') {
3634 if is_first_line {
3635 output.push_str(line);
3636 is_first_line = false;
3637 } else {
3638 output.push('\n');
3639 if !line.is_empty() {
3640 output.push_str(" ");
3641 }
3642 output.push_str(line);
3643 }
3644 }
3645 output.push('\n');
3646 }
3647 "codeBlock" => {
3648 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3649 let language = language_value
3650 .and_then(serde_json::Value::as_str)
3651 .unwrap_or("");
3652 output.push_str("```");
3653 if language.is_empty() && language_value.is_some() {
3654 output.push_str("\"\"");
3657 } else {
3658 output.push_str(language);
3659 }
3660 output.push('\n');
3661 if let Some(ref content) = node.content {
3662 for child in content {
3663 if let Some(ref text) = child.text {
3664 output.push_str(text);
3665 }
3666 }
3667 }
3668 output.push_str("\n```\n");
3669 }
3670 "blockquote" => {
3671 if let Some(ref content) = node.content {
3672 for (i, child) in content.iter().enumerate() {
3673 if i > 0
3677 && child.node_type == "paragraph"
3678 && content[i - 1].node_type == "paragraph"
3679 {
3680 output.push_str(">\n");
3681 }
3682 let mut inner = String::new();
3683 let separate = needs_list_separator(child, content.get(i + 1));
3684 render_block_node_separated(child, &mut inner, opts, separate);
3685 for line in inner.lines() {
3686 output.push_str("> ");
3687 output.push_str(line);
3688 output.push('\n');
3689 }
3690 }
3691 }
3692 }
3693 "bulletList" => {
3694 if let Some(ref items) = node.content {
3695 for item in items {
3696 output.push_str("- ");
3697 let content_start = output.len();
3698 render_list_item_content(item, output, opts);
3699 if starts_with_task_marker(&output[content_start..]) {
3705 output.insert(content_start, '\\');
3706 }
3707 }
3708 }
3709 }
3710 "orderedList" => {
3711 let start = u64::from(ordered_list_start(node));
3712 if let Some(ref items) = node.content {
3713 for (i, item) in items.iter().enumerate() {
3714 let num = start + i as u64;
3715 output.push_str(&format!("{num}. "));
3716 render_list_item_content(item, output, opts);
3717 }
3718 }
3719 }
3720 "taskList" => {
3721 if let Some(ref items) = node.content {
3722 for item in items {
3723 if item.node_type == "taskList" {
3724 let mut nested = String::new();
3728 render_block_node(item, &mut nested, opts);
3729 for line in nested.lines() {
3730 output.push_str(" ");
3731 output.push_str(line);
3732 output.push('\n');
3733 }
3734 } else {
3735 let state = item
3736 .attrs
3737 .as_ref()
3738 .and_then(|a| a.get("state"))
3739 .and_then(serde_json::Value::as_str)
3740 .unwrap_or("TODO");
3741 if state == "DONE" {
3742 output.push_str("- [x] ");
3743 } else {
3744 output.push_str("- [ ] ");
3745 }
3746 render_list_item_content(item, output, opts);
3747 }
3748 }
3749 }
3750 }
3751 "rule" => {
3752 output.push_str("---\n");
3753 }
3754 "table" => {
3755 render_table(node, output, opts);
3756 }
3757 "mediaSingle" => {
3758 if let Some(ref content) = node.content {
3759 for child in content {
3760 if child.node_type == "media" {
3761 render_media(child, node.attrs.as_ref(), output, opts);
3762 }
3763 }
3764 for child in content {
3765 if child.node_type == "caption" {
3766 let mut cap_parts = Vec::new();
3767 if let Some(ref attrs) = child.attrs {
3768 maybe_push_local_id(attrs, &mut cap_parts, opts);
3769 }
3770 if cap_parts.is_empty() {
3771 output.push_str(":::caption\n");
3772 } else {
3773 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3774 }
3775 if let Some(ref caption_content) = child.content {
3776 for inline in caption_content {
3777 render_inline_node(inline, output, opts);
3778 }
3779 output.push('\n');
3780 }
3781 output.push_str(":::\n");
3782 }
3783 }
3784 }
3785 }
3786 "blockCard" => {
3787 if let Some(ref attrs) = node.attrs {
3788 let url = attrs
3789 .get("url")
3790 .and_then(serde_json::Value::as_str)
3791 .unwrap_or("");
3792 let mut attr_parts = Vec::new();
3793 if url_safe_in_bracket_content(url) {
3794 output.push_str(&format!("::card[{url}]"));
3795 } else {
3796 output.push_str("::card[]");
3798 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3799 attr_parts.push(format!("url=\"{escaped}\""));
3800 }
3801 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3802 attr_parts.push(format!("layout={layout}"));
3803 }
3804 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3805 attr_parts.push(format!("width={width}"));
3806 }
3807 if !attr_parts.is_empty() {
3808 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3809 }
3810 output.push('\n');
3811 }
3812 }
3813 "embedCard" => {
3814 if let Some(ref attrs) = node.attrs {
3815 let url = attrs
3816 .get("url")
3817 .and_then(serde_json::Value::as_str)
3818 .unwrap_or("");
3819 output.push_str(&format!("::embed[{url}]"));
3820 let mut attr_parts = Vec::new();
3821 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3822 attr_parts.push(format!("layout={layout}"));
3823 }
3824 if let Some(h) = attrs
3825 .get("originalHeight")
3826 .and_then(serde_json::Value::as_f64)
3827 {
3828 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3829 }
3830 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3831 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3832 }
3833 if !attr_parts.is_empty() {
3834 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3835 }
3836 output.push('\n');
3837 }
3838 }
3839 "extension" => {
3840 if let Some(ref attrs) = node.attrs {
3841 let ext_type = attrs
3842 .get("extensionType")
3843 .and_then(serde_json::Value::as_str)
3844 .unwrap_or("");
3845 let ext_key = attrs
3846 .get("extensionKey")
3847 .and_then(serde_json::Value::as_str)
3848 .unwrap_or("");
3849 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3850 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3851 attr_parts.push(format!("layout={layout}"));
3852 }
3853 if let Some(params) = attrs.get("parameters") {
3854 if let Ok(json_str) = serde_json::to_string(params) {
3855 attr_parts.push(format!("params='{json_str}'"));
3856 }
3857 }
3858 maybe_push_local_id(attrs, &mut attr_parts, opts);
3859 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3860 }
3861 }
3862 "panel" => {
3863 let panel_type = node
3864 .attrs
3865 .as_ref()
3866 .and_then(|a| a.get("panelType"))
3867 .and_then(serde_json::Value::as_str)
3868 .unwrap_or("info");
3869 let mut attr_parts = vec![format!("type={panel_type}")];
3870 if let Some(ref attrs) = node.attrs {
3871 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3872 attr_parts.push(format!("icon=\"{icon}\""));
3873 }
3874 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3875 attr_parts.push(format!("color=\"{color}\""));
3876 }
3877 }
3878 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3879 if let Some(ref content) = node.content {
3880 render_block_children(content, output, opts);
3881 }
3882 output.push_str(":::\n");
3883 }
3884 "expand" | "nestedExpand" => {
3885 let directive_name = if node.node_type == "nestedExpand" {
3886 "nested-expand"
3887 } else {
3888 "expand"
3889 };
3890 let mut attr_parts = Vec::new();
3891 if let Some(t) = node
3892 .attrs
3893 .as_ref()
3894 .and_then(|a| a.get("title"))
3895 .and_then(serde_json::Value::as_str)
3896 {
3897 attr_parts.push(format!("title=\"{t}\""));
3898 }
3899 if let Some(ref lid) = node.local_id {
3901 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3902 attr_parts.push(format!("localId={lid}"));
3903 }
3904 } else if let Some(ref attrs) = node.attrs {
3905 maybe_push_local_id(attrs, &mut attr_parts, opts);
3906 }
3907 if let Some(ref params) = node.parameters {
3909 if let Ok(json_str) = serde_json::to_string(params) {
3910 attr_parts.push(format!("params='{json_str}'"));
3911 }
3912 }
3913 if attr_parts.is_empty() {
3914 output.push_str(&format!(":::{directive_name}\n"));
3915 } else {
3916 output.push_str(&format!(
3917 ":::{directive_name}{{{}}}\n",
3918 attr_parts.join(" ")
3919 ));
3920 }
3921 if let Some(ref content) = node.content {
3922 render_block_children(content, output, opts);
3923 }
3924 output.push_str(":::\n");
3925 }
3926 "layoutSection" => {
3927 output.push_str("::::layout\n");
3928 if let Some(ref content) = node.content {
3929 for child in content {
3930 if child.node_type == "layoutColumn" {
3931 let width_str = child
3932 .attrs
3933 .as_ref()
3934 .and_then(|a| a.get("width"))
3935 .and_then(fmt_numeric_attr)
3936 .unwrap_or_else(|| "50".to_string());
3937 let mut parts = vec![format!("width={width_str}")];
3938 if let Some(ref attrs) = child.attrs {
3939 maybe_push_local_id(attrs, &mut parts, opts);
3940 }
3941 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3942 if let Some(ref col_content) = child.content {
3943 render_block_children(col_content, output, opts);
3944 }
3945 output.push_str(":::\n");
3946 }
3947 }
3948 }
3949 output.push_str("::::\n");
3950 }
3951 "decisionList" => {
3952 output.push_str(":::decisions\n");
3953 if let Some(ref content) = node.content {
3954 for item in content {
3955 output.push_str("- <> ");
3956 render_list_item_content(item, output, opts);
3957 }
3958 }
3959 output.push_str(":::\n");
3960 }
3961 "bodiedExtension" => {
3962 if let Some(ref attrs) = node.attrs {
3963 let ext_type = attrs
3964 .get("extensionType")
3965 .and_then(serde_json::Value::as_str)
3966 .unwrap_or("");
3967 let ext_key = attrs
3968 .get("extensionKey")
3969 .and_then(serde_json::Value::as_str)
3970 .unwrap_or("");
3971 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3972 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3973 attr_parts.push(format!("layout={layout}"));
3974 }
3975 if let Some(params) = attrs.get("parameters") {
3976 if let Ok(json_str) = serde_json::to_string(params) {
3977 attr_parts.push(format!("params='{json_str}'"));
3978 }
3979 }
3980 maybe_push_local_id(attrs, &mut attr_parts, opts);
3981 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3982 if let Some(ref content) = node.content {
3983 render_block_children(content, output, opts);
3984 }
3985 output.push_str(":::\n");
3986 }
3987 }
3988 _ => {
3989 if let Ok(json) = serde_json::to_string_pretty(node) {
3991 output.push_str("```adf-unsupported\n");
3992 output.push_str(&json);
3993 output.push_str("\n```\n");
3994 }
3995 }
3996 }
3997
3998 let mut parts = Vec::new();
4000 if let Some(ref marks) = node.marks {
4001 for mark in marks {
4002 match mark.mark_type.as_str() {
4003 "alignment" => {
4004 if let Some(align) = mark
4005 .attrs
4006 .as_ref()
4007 .and_then(|a| a.get("align"))
4008 .and_then(serde_json::Value::as_str)
4009 {
4010 parts.push(format!("align={align}"));
4011 }
4012 }
4013 "indentation" => {
4014 if let Some(level) = mark
4015 .attrs
4016 .as_ref()
4017 .and_then(|a| a.get("level"))
4018 .and_then(serde_json::Value::as_u64)
4019 {
4020 parts.push(format!("indent={level}"));
4021 }
4022 }
4023 "breakout" => {
4024 if let Some(mode) = mark
4025 .attrs
4026 .as_ref()
4027 .and_then(|a| a.get("mode"))
4028 .and_then(serde_json::Value::as_str)
4029 {
4030 parts.push(format!("breakout={mode}"));
4031 }
4032 if let Some(width) = mark
4033 .attrs
4034 .as_ref()
4035 .and_then(|a| a.get("width"))
4036 .and_then(serde_json::Value::as_u64)
4037 {
4038 parts.push(format!("breakoutWidth={width}"));
4039 }
4040 }
4041 _ => {}
4042 }
4043 }
4044 }
4045 let para_used_directive = node.node_type == "paragraph" && {
4049 let is_empty = node.content.as_ref().is_none_or(Vec::is_empty);
4050 if is_empty {
4051 true
4052 } else {
4053 let mut buf = String::new();
4054 render_inline_content(node, &mut buf, opts);
4055 paragraph_needs_directive_form(&buf)
4056 }
4057 };
4058 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
4059 if let Some(ref attrs) = node.attrs {
4060 maybe_push_local_id(attrs, &mut parts, opts);
4061 }
4062 }
4063 if node.node_type == "orderedList" {
4068 if let Some(ref attrs) = node.attrs {
4069 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
4070 parts.push("order=1".to_string());
4071 }
4072 }
4073 }
4074 if separate_from_next && is_list_node_type(&node.node_type) {
4080 parts.push(format_kv(LIST_SEPARATE_ATTR, "separate"));
4081 }
4082 if !parts.is_empty() {
4083 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4084 }
4085}
4086
4087fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
4095 let Some(ref content) = item.content else {
4096 let bare = AdfNode::text("");
4098 emit_list_item_local_ids(item, &bare, output, opts);
4099 output.push('\n');
4100 return;
4101 };
4102 if content.is_empty() {
4103 let bare = AdfNode::text("");
4104 emit_list_item_local_ids(item, &bare, output, opts);
4105 output.push('\n');
4106 return;
4107 }
4108 let first = &content[0];
4109 let rest_start;
4110 if first.node_type == "paragraph" {
4111 let mut buf = String::new();
4112 render_inline_content(first, &mut buf, opts);
4113 let buf = buf.trim_end_matches('\n');
4119 let mut is_first_line = true;
4122 for line in buf.split('\n') {
4123 if is_first_line {
4124 output.push_str(line);
4125 is_first_line = false;
4126 } else {
4127 output.push('\n');
4128 if !line.is_empty() {
4129 output.push_str(" ");
4130 }
4131 output.push_str(line);
4132 }
4133 }
4134 emit_list_item_local_ids(item, first, output, opts);
4136 output.push('\n');
4137 rest_start = 1;
4138 } else if is_inline_node_type(&first.node_type) {
4139 rest_start = content
4141 .iter()
4142 .position(|c| !is_inline_node_type(&c.node_type))
4143 .unwrap_or(content.len());
4144 let mut buf = String::new();
4145 for child in &content[..rest_start] {
4146 render_inline_node(child, &mut buf, opts);
4147 }
4148 let buf = buf.trim_end_matches('\n');
4151 let mut is_first_line = true;
4152 for line in buf.split('\n') {
4153 if is_first_line {
4154 output.push_str(line);
4155 is_first_line = false;
4156 } else {
4157 output.push('\n');
4158 if !line.is_empty() {
4159 output.push_str(" ");
4160 }
4161 output.push_str(line);
4162 }
4163 }
4164 let bare = AdfNode::text("");
4166 emit_list_item_local_ids(item, &bare, output, opts);
4167 output.push('\n');
4168 } else if first.node_type == "taskItem" {
4171 let bare = AdfNode::text("");
4176 emit_list_item_local_ids(item, &bare, output, opts);
4177 output.push('\n');
4178 for (i, child) in content.iter().enumerate() {
4179 if child.node_type == "taskItem" {
4180 let state = child
4181 .attrs
4182 .as_ref()
4183 .and_then(|a| a.get("state"))
4184 .and_then(serde_json::Value::as_str)
4185 .unwrap_or("TODO");
4186 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
4187 output.push_str(" ");
4188 output.push_str(marker);
4189 render_list_item_content(child, output, opts);
4190 } else {
4191 let mut nested = String::new();
4192 let separate = needs_list_separator(child, content.get(i + 1));
4193 render_block_node_separated(child, &mut nested, opts, separate);
4194 for line in nested.lines() {
4195 output.push_str(" ");
4196 output.push_str(line);
4197 output.push('\n');
4198 }
4199 }
4200 }
4201 return;
4202 } else {
4203 let mut buf = String::new();
4209 let separate = needs_list_separator(first, content.get(1));
4212 render_block_node_separated(first, &mut buf, opts, separate);
4213 let bare = AdfNode::text("");
4214 let mut is_first = true;
4215 for line in buf.lines() {
4216 if is_first {
4217 output.push_str(line);
4218 emit_list_item_local_ids(item, &bare, output, opts);
4219 output.push('\n');
4220 is_first = false;
4221 } else {
4222 output.push_str(" ");
4223 output.push_str(line);
4224 output.push('\n');
4225 }
4226 }
4227 rest_start = 1;
4228 }
4229 let rest = &content[rest_start..];
4230 for (i, child) in rest.iter().enumerate() {
4231 let prev = if i == 0 { first } else { &rest[i - 1] };
4236 if child.node_type == "paragraph" && prev.node_type == "paragraph" {
4237 output.push_str(" \n");
4238 }
4239 let separate = needs_list_separator(child, rest.get(i + 1));
4240 let mut nested = String::new();
4241 render_block_node_separated(child, &mut nested, opts, separate);
4242 for line in nested.lines() {
4243 output.push_str(" ");
4244 output.push_str(line);
4245 output.push('\n');
4246 }
4247 }
4248}
4249
4250fn is_inline_node_type(node_type: &str) -> bool {
4252 matches!(
4253 node_type,
4254 "text"
4255 | "hardBreak"
4256 | "inlineCard"
4257 | "emoji"
4258 | "mention"
4259 | "status"
4260 | "date"
4261 | "placeholder"
4262 | "mediaInline"
4263 )
4264}
4265
4266fn emit_list_item_local_ids(
4269 item: &AdfNode,
4270 paragraph: &AdfNode,
4271 output: &mut String,
4272 opts: &RenderOptions,
4273) {
4274 if opts.strip_local_ids {
4275 return;
4276 }
4277 let mut parts = Vec::new();
4278 if let Some(ref attrs) = item.attrs {
4279 maybe_push_local_id(attrs, &mut parts, opts);
4280 }
4281 if paragraph.node_type == "paragraph" {
4282 let has_real_id = paragraph
4283 .attrs
4284 .as_ref()
4285 .and_then(|a| a.get("localId"))
4286 .and_then(serde_json::Value::as_str)
4287 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
4288 if let Some(local_id) = has_real_id {
4289 parts.push(format!("paraLocalId={local_id}"));
4290 } else if item.node_type == "taskItem" {
4291 parts.push("paraLocalId=_".to_string());
4295 }
4296 }
4297 if !parts.is_empty() {
4298 output.push_str(&format!(" {{{}}}", parts.join(" ")));
4299 }
4300}
4301
4302fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4304 let Some(ref rows) = node.content else {
4305 return;
4306 };
4307
4308 if table_qualifies_for_pipe_syntax(rows) {
4309 render_pipe_table(node, rows, output, opts);
4310 } else {
4311 render_directive_table(node, rows, output, opts);
4312 }
4313}
4314
4315fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
4322 if rows.iter().any(|n| n.node_type == "caption") {
4324 return false;
4325 }
4326 let mut first_row_has_header = false;
4327 for (row_idx, row) in rows.iter().enumerate() {
4328 let Some(ref cells) = row.content else {
4329 continue;
4330 };
4331 for cell in cells {
4332 if row_idx > 0 && cell.node_type == "tableHeader" {
4334 return false;
4335 }
4336 if row_idx == 0 && cell.node_type == "tableHeader" {
4337 first_row_has_header = true;
4338 }
4339 let Some(ref content) = cell.content else {
4341 continue;
4342 };
4343 if content.len() != 1 || content[0].node_type != "paragraph" {
4344 return false;
4345 }
4346 if cell_contains_hard_break(&content[0]) {
4349 return false;
4350 }
4351 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
4354 return false;
4355 }
4356 if content[0]
4359 .attrs
4360 .as_ref()
4361 .and_then(|a| a.get("localId"))
4362 .is_some()
4363 {
4364 return false;
4365 }
4366 }
4367 }
4368 first_row_has_header
4371}
4372
4373fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
4375 paragraph
4376 .content
4377 .as_ref()
4378 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
4379}
4380
4381fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
4383 for (row_idx, row) in rows.iter().enumerate() {
4384 let Some(ref cells) = row.content else {
4385 continue;
4386 };
4387
4388 output.push('|');
4389 for cell in cells {
4390 output.push(' ');
4391 let mut cell_buf = String::new();
4392 render_cell_attrs_prefix(cell, &mut cell_buf);
4393 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
4394 output.push_str(&escape_pipes_in_cell(&cell_buf));
4395 output.push_str(" |");
4396 }
4397 output.push('\n');
4398
4399 if row_idx == 0 {
4401 output.push('|');
4402 for cell in cells {
4403 let align = get_cell_paragraph_alignment(cell);
4404 match align {
4405 Some("center") => output.push_str(" :---: |"),
4406 Some("end") => output.push_str(" ---: |"),
4407 _ => output.push_str(" --- |"),
4408 }
4409 }
4410 output.push('\n');
4411 }
4412 }
4413
4414 render_table_level_attrs(node, output, opts);
4416}
4417
4418fn render_directive_table(
4420 node: &AdfNode,
4421 rows: &[AdfNode],
4422 output: &mut String,
4423 opts: &RenderOptions,
4424) {
4425 let mut attr_parts = Vec::new();
4427 if let Some(ref attrs) = node.attrs {
4428 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4429 attr_parts.push(format!("layout={layout}"));
4430 }
4431 if let Some(numbered) = attrs
4432 .get("isNumberColumnEnabled")
4433 .and_then(serde_json::Value::as_bool)
4434 {
4435 if numbered {
4436 attr_parts.push("numbered".to_string());
4437 } else {
4438 attr_parts.push("numbered=false".to_string());
4439 }
4440 }
4441 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4442 let tw_str = if tw.fract() == 0.0 {
4443 (tw as u64).to_string()
4444 } else {
4445 tw.to_string()
4446 };
4447 attr_parts.push(format!("width={tw_str}"));
4448 }
4449 maybe_push_local_id(attrs, &mut attr_parts, opts);
4450 }
4451 if attr_parts.is_empty() {
4452 output.push_str("::::table\n");
4453 } else {
4454 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4455 }
4456
4457 for row in rows {
4458 if row.node_type == "caption" {
4459 let mut cap_parts = Vec::new();
4460 if let Some(ref attrs) = row.attrs {
4461 maybe_push_local_id(attrs, &mut cap_parts, opts);
4462 }
4463 if cap_parts.is_empty() {
4464 output.push_str(":::caption\n");
4465 } else {
4466 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4467 }
4468 if let Some(ref content) = row.content {
4469 for child in content {
4470 render_inline_node(child, output, opts);
4471 }
4472 output.push('\n');
4473 }
4474 output.push_str(":::\n");
4475 continue;
4476 }
4477 let Some(ref cells) = row.content else {
4478 continue;
4479 };
4480 let mut tr_attrs = Vec::new();
4482 if let Some(ref attrs) = row.attrs {
4483 maybe_push_local_id(attrs, &mut tr_attrs, opts);
4484 }
4485 if tr_attrs.is_empty() {
4486 output.push_str(":::tr\n");
4487 } else {
4488 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4489 }
4490 for cell in cells {
4491 let directive_name = if cell.node_type == "tableHeader" {
4492 "th"
4493 } else {
4494 "td"
4495 };
4496 let mut cell_attr_str = build_cell_attrs_string(cell);
4497 if let Some(ref attrs) = cell.attrs {
4499 let mut lid_parts = Vec::new();
4500 maybe_push_local_id(attrs, &mut lid_parts, opts);
4501 if !lid_parts.is_empty() {
4502 if !cell_attr_str.is_empty() {
4503 cell_attr_str.push(' ');
4504 }
4505 cell_attr_str.push_str(&lid_parts.join(" "));
4506 }
4507 }
4508 if let Some(ref marks) = cell.marks {
4510 for mark in marks {
4511 if mark.mark_type == "border" {
4512 if let Some(ref attrs) = mark.attrs {
4513 if let Some(color) =
4514 attrs.get("color").and_then(serde_json::Value::as_str)
4515 {
4516 if !cell_attr_str.is_empty() {
4517 cell_attr_str.push(' ');
4518 }
4519 cell_attr_str.push_str(&format!("border-color={color}"));
4520 }
4521 if let Some(size) =
4522 attrs.get("size").and_then(serde_json::Value::as_u64)
4523 {
4524 if !cell_attr_str.is_empty() {
4525 cell_attr_str.push(' ');
4526 }
4527 cell_attr_str.push_str(&format!("border-size={size}"));
4528 }
4529 }
4530 }
4531 }
4532 }
4533 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4534 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4535 output.push_str(&format!(":::{directive_name}\n"));
4536 } else {
4537 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4538 }
4539 if let Some(ref content) = cell.content {
4540 render_block_children(content, output, opts);
4541 }
4542 output.push_str(":::\n");
4543 }
4544 output.push_str(":::\n");
4545 }
4546
4547 output.push_str("::::\n");
4548}
4549
4550fn needs_attr_quoting(value: &str) -> bool {
4554 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4555}
4556
4557fn build_cell_attrs_string(cell: &AdfNode) -> String {
4559 let Some(ref attrs) = cell.attrs else {
4560 return String::new();
4561 };
4562 let mut parts = Vec::new();
4563 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4564 parts.push(format!("colspan={colspan}"));
4565 }
4566 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4567 parts.push(format!("rowspan={rowspan}"));
4568 }
4569 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4570 if needs_attr_quoting(bg) {
4571 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4572 parts.push(format!("bg=\"{escaped}\""));
4573 } else {
4574 parts.push(format!("bg={bg}"));
4575 }
4576 }
4577 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4578 let widths: Vec<String> = colwidth
4579 .iter()
4580 .filter_map(|v| {
4581 if let Some(n) = v.as_u64() {
4584 Some(n.to_string())
4585 } else if let Some(n) = v.as_f64() {
4586 if n.fract() == 0.0 {
4587 format!("{n:.1}")
4588 } else {
4589 n.to_string()
4590 }
4591 .into()
4592 } else {
4593 None
4594 }
4595 })
4596 .collect();
4597 if !widths.is_empty() {
4598 parts.push(format!("colwidth={}", widths.join(",")));
4599 }
4600 }
4601 parts.join(" ")
4602}
4603
4604fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4606 let Some(ref _attrs) = cell.attrs else {
4607 return;
4608 };
4609 let attr_str = build_cell_attrs_string(cell);
4610 if attr_str.is_empty() {
4611 output.push_str("{} ");
4612 } else {
4613 output.push_str(&format!("{{{attr_str}}} "));
4614 }
4615}
4616
4617fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4619 let content = cell.content.as_ref()?;
4620 let para = content.first()?;
4621 let marks = para.marks.as_ref()?;
4622 marks.iter().find_map(|m| {
4623 if m.mark_type == "alignment" {
4624 m.attrs
4625 .as_ref()
4626 .and_then(|a| a.get("align"))
4627 .and_then(serde_json::Value::as_str)
4628 } else {
4629 None
4630 }
4631 })
4632}
4633
4634fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4636 if let Some(ref attrs) = node.attrs {
4637 let mut parts = Vec::new();
4638 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4639 parts.push(format!("layout={layout}"));
4640 }
4641 if let Some(numbered) = attrs
4642 .get("isNumberColumnEnabled")
4643 .and_then(serde_json::Value::as_bool)
4644 {
4645 if numbered {
4646 parts.push("numbered".to_string());
4647 } else {
4648 parts.push("numbered=false".to_string());
4649 }
4650 }
4651 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4652 parts.push(format!("width={tw_str}"));
4653 }
4654 maybe_push_local_id(attrs, &mut parts, opts);
4655 if !parts.is_empty() {
4656 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4657 }
4658 }
4659}
4660
4661fn render_inline_content_from_first_paragraph(
4663 cell: &AdfNode,
4664 output: &mut String,
4665 opts: &RenderOptions,
4666) {
4667 if let Some(ref content) = cell.content {
4668 if let Some(first) = content.first() {
4669 if first.node_type == "paragraph" {
4670 render_inline_content(first, output, opts);
4671 }
4672 }
4673 }
4674}
4675
4676fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4678 if let Some(ref marks) = marks {
4679 for mark in marks {
4680 if mark.mark_type == "border" {
4681 if let Some(ref attrs) = mark.attrs {
4682 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4683 parts.push(format!("border-color={color}"));
4684 }
4685 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4686 parts.push(format!("border-size={size}"));
4687 }
4688 }
4689 }
4690 }
4691 }
4692}
4693
4694fn render_media(
4696 node: &AdfNode,
4697 parent_attrs: Option<&serde_json::Value>,
4698 output: &mut String,
4699 opts: &RenderOptions,
4700) {
4701 if let Some(ref attrs) = node.attrs {
4702 let media_type = attrs
4703 .get("type")
4704 .and_then(serde_json::Value::as_str)
4705 .unwrap_or("external");
4706 let alt = attrs
4707 .get("alt")
4708 .and_then(serde_json::Value::as_str)
4709 .unwrap_or("");
4710
4711 if media_type == "file" {
4712 output.push_str(&format!("![{alt}]()"));
4714 let mut parts = vec!["type=file".to_string()];
4715 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4716 parts.push(format_kv("id", id));
4717 }
4718 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4719 parts.push(format_kv("collection", collection));
4720 }
4721 if let Some(occurrence_key) = attrs
4722 .get("occurrenceKey")
4723 .and_then(serde_json::Value::as_str)
4724 {
4725 parts.push(format_kv("occurrenceKey", occurrence_key));
4726 }
4727 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4728 parts.push(format!("height={height_str}"));
4729 }
4730 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4731 parts.push(format!("width={width_str}"));
4732 }
4733 maybe_push_local_id(attrs, &mut parts, opts);
4734 if let Some(p_attrs) = parent_attrs {
4736 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4737 if layout != "center" {
4738 parts.push(format!("layout={layout}"));
4739 }
4740 }
4741 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4742 parts.push(format!("mediaWidth={ms_width_str}"));
4743 }
4744 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4745 parts.push(format!("widthType={wt}"));
4746 }
4747 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4748 parts.push(format!("mode={mode}"));
4749 }
4750 }
4751 push_border_mark_attrs(&node.marks, &mut parts);
4752 output.push_str(&format!("{{{}}}", parts.join(" ")));
4753 } else {
4754 let url = attrs
4756 .get("url")
4757 .and_then(serde_json::Value::as_str)
4758 .unwrap_or("");
4759 output.push_str(&format!(""));
4760
4761 {
4763 let mut parts = Vec::new();
4764 if let Some(p_attrs) = parent_attrs {
4765 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4766 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4767 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4768 if let Some(l) = layout {
4769 if l != "center" {
4770 parts.push(format!("layout={l}"));
4771 }
4772 }
4773 if let Some(w) = width_str {
4774 parts.push(format!("width={w}"));
4775 }
4776 if let Some(wt) = width_type {
4777 parts.push(format!("widthType={wt}"));
4778 }
4779 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4780 parts.push(format!("mode={mode}"));
4781 }
4782 }
4783 maybe_push_local_id(attrs, &mut parts, opts);
4784 push_border_mark_attrs(&node.marks, &mut parts);
4785 if !parts.is_empty() {
4786 output.push_str(&format!("{{{}}}", parts.join(" ")));
4787 }
4788 }
4789 }
4790
4791 output.push('\n');
4792 }
4793}
4794
4795fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4797 if let Some(ref content) = node.content {
4798 for child in content {
4799 render_inline_node(child, output, opts);
4800 }
4801 }
4802}
4803
4804fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4806 match node.node_type.as_str() {
4807 "text" => {
4808 let text = node.text.as_deref().unwrap_or("");
4809 let marks = node.marks.as_deref().unwrap_or(&[]);
4810 let has_code = marks.iter().any(|m| m.mark_type == "code");
4811 let owned;
4816 let text = if !has_code {
4817 owned = text.replace('\\', "\\\\");
4818 owned.as_str()
4819 } else {
4820 text
4821 };
4822 let owned_nl;
4827 let text = if text.contains('\n') {
4828 owned_nl = text.replace('\n', "\\n");
4829 owned_nl.as_str()
4830 } else {
4831 text
4832 };
4833 let owned_ts;
4839 let text = if !has_code && text.ends_with(" ") {
4840 let mut s = text.to_string();
4841 s.insert(s.len() - 1, '\\');
4843 owned_ts = s;
4844 owned_ts.as_str()
4845 } else {
4846 text
4847 };
4848 render_marked_text(text, marks, output);
4849 }
4850 "hardBreak" => {
4851 output.push_str("\\\n");
4852 }
4853 other => {
4854 let mut body = String::new();
4858 render_non_text_inline_body(other, node, &mut body, opts);
4859
4860 let annotations: Vec<&AdfMark> = node
4861 .marks
4862 .as_deref()
4863 .unwrap_or(&[])
4864 .iter()
4865 .filter(|m| m.mark_type == "annotation")
4866 .collect();
4867
4868 if annotations.is_empty() {
4869 output.push_str(&body);
4870 } else {
4871 let mut attr_parts = Vec::new();
4872 for ann in &annotations {
4873 if let Some(ref attrs) = ann.attrs {
4874 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4875 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4876 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4877 }
4878 if let Some(at) = attrs
4879 .get("annotationType")
4880 .and_then(serde_json::Value::as_str)
4881 {
4882 attr_parts.push(format!("annotation-type={at}"));
4883 }
4884 }
4885 }
4886 output.push('[');
4887 output.push_str(&body);
4888 output.push_str("]{");
4889 output.push_str(&attr_parts.join(" "));
4890 output.push('}');
4891 }
4892 }
4893 }
4894}
4895
4896fn render_non_text_inline_body(
4898 node_type: &str,
4899 node: &AdfNode,
4900 output: &mut String,
4901 opts: &RenderOptions,
4902) {
4903 match node_type {
4904 "inlineCard" => {
4905 if let Some(ref attrs) = node.attrs {
4906 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4907 let mut attr_parts = Vec::new();
4908 if url_safe_in_bracket_content(url) {
4909 output.push_str(":card[");
4910 output.push_str(url);
4911 output.push(']');
4912 } else {
4913 output.push_str(":card[]");
4917 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4918 attr_parts.push(format!("url=\"{escaped}\""));
4919 }
4920 maybe_push_local_id(attrs, &mut attr_parts, opts);
4921 if !attr_parts.is_empty() {
4922 output.push('{');
4923 output.push_str(&attr_parts.join(" "));
4924 output.push('}');
4925 }
4926 }
4927 }
4928 }
4929 "emoji" => {
4930 if let Some(ref attrs) = node.attrs {
4931 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4932 {
4933 output.push(':');
4934 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4935 let name = name.strip_suffix(':').unwrap_or(name);
4936 output.push_str(name);
4937 output.push(':');
4938
4939 let mut parts = Vec::new();
4940 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4941 parts.push(format!("shortName=\"{escaped_sn}\""));
4942 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4943 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4944 parts.push(format!("id=\"{escaped}\""));
4945 }
4946 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4947 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4948 parts.push(format!("text=\"{escaped}\""));
4949 }
4950 maybe_push_local_id(attrs, &mut parts, opts);
4951 output.push('{');
4952 output.push_str(&parts.join(" "));
4953 output.push('}');
4954 }
4955 }
4956 }
4957 "status" => {
4958 if let Some(ref attrs) = node.attrs {
4959 let text = attrs
4960 .get("text")
4961 .and_then(serde_json::Value::as_str)
4962 .unwrap_or("");
4963 let color = attrs
4964 .get("color")
4965 .and_then(serde_json::Value::as_str)
4966 .unwrap_or("neutral");
4967 let mut attr_parts = vec![format!("color={color}")];
4968 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4969 attr_parts.push(format!("style={style}"));
4970 }
4971 maybe_push_local_id(attrs, &mut attr_parts, opts);
4972 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4973 }
4974 }
4975 "date" => {
4976 if let Some(ref attrs) = node.attrs {
4977 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4978 {
4979 let display = epoch_ms_to_iso_date(timestamp);
4980 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4981 maybe_push_local_id(attrs, &mut attr_parts, opts);
4982 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4983 }
4984 }
4985 }
4986 "mention" => {
4987 if let Some(ref attrs) = node.attrs {
4988 let id = attrs
4989 .get("id")
4990 .and_then(serde_json::Value::as_str)
4991 .unwrap_or("");
4992 let text = attrs
4993 .get("text")
4994 .and_then(serde_json::Value::as_str)
4995 .unwrap_or("");
4996 let mut attr_parts = vec![format!("id={id}")];
4997 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4998 attr_parts.push(format!("userType={ut}"));
4999 }
5000 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
5001 attr_parts.push(format!("accessLevel={al}"));
5002 }
5003 maybe_push_local_id(attrs, &mut attr_parts, opts);
5004 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
5005 }
5006 }
5007 "placeholder" => {
5008 if let Some(ref attrs) = node.attrs {
5009 let text = attrs
5010 .get("text")
5011 .and_then(serde_json::Value::as_str)
5012 .unwrap_or("");
5013 output.push_str(&format!(":placeholder[{text}]"));
5014 }
5015 }
5016 "inlineExtension" => {
5017 if let Some(ref attrs) = node.attrs {
5018 let ext_type = attrs
5019 .get("extensionType")
5020 .and_then(serde_json::Value::as_str)
5021 .unwrap_or("");
5022 let ext_key = attrs
5023 .get("extensionKey")
5024 .and_then(serde_json::Value::as_str)
5025 .unwrap_or("");
5026 let fallback = node.text.as_deref().unwrap_or("");
5027 output.push_str(&format!(
5028 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
5029 ));
5030 }
5031 }
5032 "mediaInline" => {
5033 if let Some(ref attrs) = node.attrs {
5034 let mut attr_parts = Vec::new();
5035 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
5036 attr_parts.push(format_kv("type", media_type));
5037 }
5038 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
5039 attr_parts.push(format_kv("id", id));
5040 }
5041 if let Some(collection) =
5042 attrs.get("collection").and_then(serde_json::Value::as_str)
5043 {
5044 attr_parts.push(format_kv("collection", collection));
5045 }
5046 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
5047 attr_parts.push(format_kv("url", url));
5048 }
5049 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
5050 attr_parts.push(format_kv("alt", alt));
5051 }
5052 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
5053 attr_parts.push(format!("width={width}"));
5054 }
5055 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
5056 attr_parts.push(format!("height={height}"));
5057 }
5058 maybe_push_local_id(attrs, &mut attr_parts, opts);
5059 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
5060 }
5061 }
5062 _ => {
5063 if let Ok(json) = serde_json::to_string(node) {
5070 output.push_str(":adf-unsupported[]{");
5071 output.push_str(&format_kv("json", &json));
5072 output.push('}');
5073 }
5074 }
5075 }
5076}
5077
5078fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
5091 if marks.iter().any(|m| m.mark_type == "code") {
5092 render_code_marked_text(text, marks, output);
5093 return;
5094 }
5095
5096 let has_link = marks.iter().any(|m| m.mark_type == "link");
5097 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
5098 let has_em = marks.iter().any(|m| m.mark_type == "em");
5099
5100 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
5104 let escaped = escape_emphasis_markers(text);
5105 let escaped = escape_emoji_shortcodes(&escaped);
5106 let escaped = escape_backticks(&escaped);
5107 let escaped = escape_bare_urls(&escaped);
5108 output.push_str("***");
5109 output.push_str(&escaped);
5110 output.push_str("***");
5111 return;
5112 }
5113
5114 let em_delim = if has_strong && has_em { "_" } else { "*" };
5118
5119 let escaped = if em_delim == "_" {
5122 escape_emphasis_markers_with_underscore(text)
5123 } else {
5124 escape_emphasis_markers(text)
5125 };
5126 let escaped = escape_emoji_shortcodes(&escaped);
5127 let escaped = escape_backticks(&escaped);
5128 let escaped = escape_bare_urls(&escaped);
5136 let escaped = if has_link {
5137 escape_link_brackets(&escaped)
5138 } else {
5139 escaped
5140 };
5141
5142 let mut wrappers: Vec<(String, String)> = Vec::new();
5148 let mut i = 0;
5149 while i < marks.len() {
5150 match marks[i].mark_type.as_str() {
5151 "em" => {
5152 wrappers.push((em_delim.to_string(), em_delim.to_string()));
5153 i += 1;
5154 }
5155 "strong" => {
5156 wrappers.push(("**".to_string(), "**".to_string()));
5157 i += 1;
5158 }
5159 "strike" => {
5160 wrappers.push(("~~".to_string(), "~~".to_string()));
5161 i += 1;
5162 }
5163 "link" => {
5164 let href = link_href(&marks[i]);
5165 wrappers.push(("[".to_string(), format!("]({href})")));
5166 i += 1;
5167 }
5168 "textColor" | "backgroundColor" | "subsup" => {
5169 let start = i;
5170 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
5171 i += 1;
5172 }
5173 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
5174 }
5175 "underline" | "annotation" => {
5176 let start = i;
5177 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
5178 i += 1;
5179 }
5180 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
5181 }
5182 _ => {
5183 i += 1;
5184 }
5185 }
5186 }
5187
5188 let mut result = escaped;
5190 for (open, close) in wrappers.iter().rev() {
5191 result.insert_str(0, open);
5192 result.push_str(close);
5193 }
5194 output.push_str(&result);
5195}
5196
5197fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
5205 let link_mark = marks.iter().find(|m| m.mark_type == "link");
5206
5207 let mut code_str = String::new();
5208 if let Some(link_mark) = link_mark {
5209 let href = link_href(link_mark);
5210 code_str.push('[');
5211 render_inline_code(text, &mut code_str);
5212 code_str.push_str("](");
5213 code_str.push_str(href);
5214 code_str.push(')');
5215 } else {
5216 render_inline_code(text, &mut code_str);
5217 }
5218
5219 let mut wrappers: Vec<(String, String)> = Vec::new();
5222 let mut i = 0;
5223 while i < marks.len() {
5224 match marks[i].mark_type.as_str() {
5225 "textColor" | "backgroundColor" | "subsup" => {
5226 let start = i;
5227 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
5228 i += 1;
5229 }
5230 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
5231 }
5232 "underline" | "annotation" => {
5233 let start = i;
5234 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
5235 i += 1;
5236 }
5237 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
5238 }
5239 _ => {
5240 i += 1;
5241 }
5242 }
5243 }
5244
5245 let mut result = code_str;
5247 for (open, close) in wrappers.iter().rev() {
5248 result.insert_str(0, open);
5249 result.push_str(close);
5250 }
5251 output.push_str(&result);
5252}
5253
5254fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
5256 match mark.mark_type.as_str() {
5257 "textColor" => {
5258 if let Some(c) = mark
5259 .attrs
5260 .as_ref()
5261 .and_then(|a| a.get("color"))
5262 .and_then(serde_json::Value::as_str)
5263 {
5264 attrs.push(format!("color={c}"));
5265 }
5266 }
5267 "backgroundColor" => {
5268 if let Some(c) = mark
5269 .attrs
5270 .as_ref()
5271 .and_then(|a| a.get("color"))
5272 .and_then(serde_json::Value::as_str)
5273 {
5274 attrs.push(format!("bg={c}"));
5275 }
5276 }
5277 "subsup" => {
5278 if let Some(kind) = mark
5279 .attrs
5280 .as_ref()
5281 .and_then(|a| a.get("type"))
5282 .and_then(serde_json::Value::as_str)
5283 {
5284 attrs.push(kind.to_string());
5285 }
5286 }
5287 _ => {}
5288 }
5289}
5290
5291fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
5293 match mark.mark_type.as_str() {
5294 "underline" => attrs.push("underline".to_string()),
5295 "annotation" => {
5296 if let Some(ref a) = mark.attrs {
5297 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
5298 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
5299 attrs.push(format!("annotation-id=\"{escaped}\""));
5300 }
5301 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
5302 attrs.push(format!("annotation-type={at}"));
5303 }
5304 }
5305 }
5306 _ => {}
5307 }
5308}
5309
5310fn is_span_attr_mark(mark_type: &str) -> bool {
5311 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
5312}
5313
5314fn is_bracketed_span_mark(mark_type: &str) -> bool {
5315 matches!(mark_type, "underline" | "annotation")
5316}
5317
5318fn span_attr_order(mark_type: &str) -> u8 {
5322 match mark_type {
5323 "textColor" => 0,
5324 "backgroundColor" => 1,
5325 "subsup" => 2,
5326 _ => u8::MAX,
5327 }
5328}
5329
5330fn span_run_is_canonical(run: &[AdfMark]) -> bool {
5335 let mut prev = 0;
5336 for m in run {
5337 let order = span_attr_order(&m.mark_type);
5338 if order == u8::MAX || order < prev {
5339 return false;
5340 }
5341 prev = order;
5342 }
5343 true
5344}
5345
5346fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
5351 let mut seen_annotation = false;
5352 for m in run {
5353 match m.mark_type.as_str() {
5354 "underline" => {
5355 if seen_annotation {
5356 return false;
5357 }
5358 }
5359 "annotation" => seen_annotation = true,
5360 _ => return false,
5361 }
5362 }
5363 true
5364}
5365
5366fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5370 if span_run_is_canonical(run) {
5371 let mut attrs = Vec::new();
5372 for m in run {
5373 collect_span_attr(m, &mut attrs);
5374 }
5375 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5376 return;
5377 }
5378 for m in run {
5379 let mut attrs = Vec::new();
5380 collect_span_attr(m, &mut attrs);
5381 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5382 }
5383}
5384
5385fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5389 if bracketed_run_is_canonical(run) {
5390 let mut attrs = Vec::new();
5391 for m in run {
5392 collect_bracketed_attr(m, &mut attrs);
5393 }
5394 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5395 return;
5396 }
5397 for m in run {
5398 let mut attrs = Vec::new();
5399 collect_bracketed_attr(m, &mut attrs);
5400 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5401 }
5402}
5403
5404fn link_href(mark: &AdfMark) -> &str {
5406 mark.attrs
5407 .as_ref()
5408 .and_then(|a| a.get("href"))
5409 .and_then(serde_json::Value::as_str)
5410 .unwrap_or("")
5411}
5412
5413#[cfg(test)]
5414#[allow(
5415 clippy::unwrap_used,
5416 clippy::expect_used,
5417 clippy::needless_update,
5418 clippy::needless_collect,
5419 duplicate_macro_attributes
5420)]
5421mod tests {
5422 use super::*;
5423
5424 #[test]
5427 fn adf_to_plain_text_single_paragraph() {
5428 let doc = markdown_to_adf("Hello world").unwrap();
5429 assert_eq!(adf_to_plain_text(&doc), "Hello world");
5430 }
5431
5432 #[test]
5433 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5434 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5435 let plain = adf_to_plain_text(&doc);
5436 assert!(plain.contains("Alpha"));
5438 assert!(plain.contains("Beta"));
5439 assert_eq!(plain, "Alpha Beta");
5440 }
5441
5442 #[test]
5443 fn adf_to_plain_text_drops_marks_but_keeps_text() {
5444 let doc = markdown_to_adf("Hello **bold** world").unwrap();
5445 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5446 }
5447
5448 #[test]
5449 fn adf_to_plain_text_empty_doc() {
5450 let doc = AdfDocument::new();
5451 assert_eq!(adf_to_plain_text(&doc), "");
5452 }
5453
5454 #[test]
5455 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5456 let doc = AdfDocument {
5459 version: 1,
5460 doc_type: "doc".to_string(),
5461 content: vec![
5462 AdfNode {
5463 node_type: "paragraph".to_string(),
5464 attrs: None,
5465 content: Some(vec![]),
5466 text: None,
5467 marks: None,
5468 local_id: None,
5469 parameters: None,
5470 },
5471 AdfNode {
5472 node_type: "paragraph".to_string(),
5473 attrs: None,
5474 content: Some(vec![AdfNode::text("Hello")]),
5475 text: None,
5476 marks: None,
5477 local_id: None,
5478 parameters: None,
5479 },
5480 ],
5481 };
5482 assert_eq!(adf_to_plain_text(&doc), "Hello");
5483 }
5484
5485 #[test]
5488 fn paragraph() {
5489 let doc = markdown_to_adf("Hello world").unwrap();
5490 assert_eq!(doc.content.len(), 1);
5491 assert_eq!(doc.content[0].node_type, "paragraph");
5492 }
5493
5494 #[test]
5495 fn heading_levels() {
5496 for level in 1..=6 {
5497 let hashes = "#".repeat(level);
5498 let md = format!("{hashes} Title");
5499 let doc = markdown_to_adf(&md).unwrap();
5500 assert_eq!(doc.content[0].node_type, "heading");
5501 let attrs = doc.content[0].attrs.as_ref().unwrap();
5502 assert_eq!(attrs["level"], level as u64);
5503 }
5504 }
5505
5506 #[test]
5509 fn heading_inline_code_mark_stripped() {
5510 let doc = markdown_to_adf("### `GET /api`").unwrap();
5513 let heading = &doc.content[0];
5514 assert_eq!(heading.node_type, "heading");
5515 let content = heading.content.as_ref().unwrap();
5516 assert_eq!(content.len(), 1);
5517 assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5518 assert!(
5519 content[0].marks.is_none(),
5520 "expected no marks, got: {:?}",
5521 content[0].marks
5522 );
5523 }
5524
5525 #[test]
5526 fn heading_code_strip_preserves_sibling_strong() {
5527 let doc = markdown_to_adf("### **`x`**").unwrap();
5529 let content = doc.content[0].content.as_ref().unwrap();
5530 let marks = content[0].marks.as_ref().unwrap();
5531 assert!(marks.iter().any(|m| m.mark_type == "strong"));
5532 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5533 }
5534
5535 #[test]
5536 fn heading_code_strip_preserves_link() {
5537 let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5539 let content = doc.content[0].content.as_ref().unwrap();
5540 let marks = content[0].marks.as_ref().unwrap();
5541 assert!(marks.iter().any(|m| m.mark_type == "link"));
5542 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5543 }
5544
5545 #[test]
5546 fn heading_with_code_now_passes_validation() {
5547 let doc = markdown_to_adf("### `GET /api`").unwrap();
5550 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5551 assert!(violations.is_empty(), "got: {violations:?}");
5552 }
5553
5554 #[test]
5555 fn paragraph_inline_code_mark_preserved() {
5556 let doc = markdown_to_adf("`x`").unwrap();
5559 let content = doc.content[0].content.as_ref().unwrap();
5560 let marks = content[0].marks.as_ref().unwrap();
5561 assert!(marks.iter().any(|m| m.mark_type == "code"));
5562 }
5563
5564 fn mark_types(node: &AdfNode) -> Vec<&str> {
5568 node.marks
5569 .as_ref()
5570 .map(|marks| marks.iter().map(|m| m.mark_type.as_str()).collect())
5571 .unwrap_or_default()
5572 }
5573
5574 #[test]
5575 fn split_strong_code_single_run() {
5576 let doc = markdown_to_adf("**`x`**").unwrap();
5579 let content = doc.content[0].content.as_ref().unwrap();
5580 assert_eq!(content.len(), 1);
5581 assert_eq!(content[0].text.as_deref(), Some("x"));
5582 assert_eq!(mark_types(&content[0]), vec!["code"]);
5583 }
5584
5585 #[test]
5586 fn split_em_code_single_run() {
5587 let doc = markdown_to_adf("*`x`*").unwrap();
5588 let content = doc.content[0].content.as_ref().unwrap();
5589 assert_eq!(mark_types(&content[0]), vec!["code"]);
5590 }
5591
5592 #[test]
5593 fn split_strike_code_single_run() {
5594 let doc = markdown_to_adf("~~`x`~~").unwrap();
5595 let content = doc.content[0].content.as_ref().unwrap();
5596 assert_eq!(mark_types(&content[0]), vec!["code"]);
5597 }
5598
5599 #[test]
5600 fn split_strong_em_code_drops_both() {
5601 let doc = markdown_to_adf("***`x`***").unwrap();
5602 let content = doc.content[0].content.as_ref().unwrap();
5603 assert_eq!(mark_types(&content[0]), vec!["code"]);
5604 }
5605
5606 #[test]
5607 fn split_nested_code_keeps_sibling_strong() {
5608 let doc = markdown_to_adf("**foo `bar` baz**").unwrap();
5611 let content = doc.content[0].content.as_ref().unwrap();
5612 assert_eq!(content.len(), 3);
5613 assert_eq!(content[0].text.as_deref(), Some("foo "));
5614 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5615 assert_eq!(content[1].text.as_deref(), Some("bar"));
5616 assert_eq!(mark_types(&content[1]), vec!["code"]);
5617 assert_eq!(content[2].text.as_deref(), Some(" baz"));
5618 assert_eq!(mark_types(&content[2]), vec!["strong"]);
5619 }
5620
5621 #[test]
5622 fn split_multiple_hits_in_one_line() {
5623 let doc = markdown_to_adf("**`x`** and **`y`**").unwrap();
5624 let content = doc.content[0].content.as_ref().unwrap();
5625 assert_eq!(content.len(), 3);
5626 assert_eq!(mark_types(&content[0]), vec!["code"]);
5627 assert_eq!(content[1].text.as_deref(), Some(" and "));
5628 assert!(content[1].marks.is_none());
5629 assert_eq!(mark_types(&content[2]), vec!["code"]);
5630 }
5631
5632 #[test]
5633 fn split_leaves_separate_runs_untouched() {
5634 let doc = markdown_to_adf("**foo** `bar`").unwrap();
5636 let content = doc.content[0].content.as_ref().unwrap();
5637 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5638 assert_eq!(content[1].text.as_deref(), Some(" "));
5639 assert_eq!(mark_types(&content[2]), vec!["code"]);
5640 }
5641
5642 #[test]
5643 fn split_preserves_link_on_code_run() {
5644 let doc = markdown_to_adf("**[`x`](https://e.com)**").unwrap();
5646 let content = doc.content[0].content.as_ref().unwrap();
5647 assert_eq!(mark_types(&content[0]), vec!["link", "code"]);
5648 }
5649
5650 #[test]
5651 fn split_keeps_span_syntax_marks_for_validator() {
5652 let doc = markdown_to_adf("[`x`]{underline}").unwrap();
5656 let content = doc.content[0].content.as_ref().unwrap();
5657 assert_eq!(mark_types(&content[0]), vec!["underline", "code"]);
5658 }
5659
5660 #[test]
5661 fn split_runs_after_heading_strip_bold_kept() {
5662 let doc = markdown_to_adf("# **`x`**").unwrap();
5666 let content = doc.content[0].content.as_ref().unwrap();
5667 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5668 }
5669
5670 #[test]
5671 fn split_applies_inside_containers() {
5672 let doc = markdown_to_adf("- **`x`**").unwrap();
5674 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
5675 let paragraph = &list_item.content.as_ref().unwrap()[0];
5676 let content = paragraph.content.as_ref().unwrap();
5677 assert_eq!(mark_types(&content[0]), vec!["code"]);
5678 }
5679
5680 #[test]
5681 fn split_strong_code_passes_validation() {
5682 let doc = markdown_to_adf(
5685 "**Testing `widget` itself is out of scope for this ticket** — coordinate \
5686 with the team that owns the widget credentials.",
5687 )
5688 .unwrap();
5689 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5690 assert!(violations.is_empty(), "got: {violations:?}");
5691 }
5692
5693 #[test]
5694 fn code_block() {
5695 let md = "```rust\nfn main() {}\n```";
5696 let doc = markdown_to_adf(md).unwrap();
5697 assert_eq!(doc.content[0].node_type, "codeBlock");
5698 let attrs = doc.content[0].attrs.as_ref().unwrap();
5699 assert_eq!(attrs["language"], "rust");
5700 }
5701
5702 #[test]
5703 fn code_block_no_language() {
5704 let md = "```\nsome code\n```";
5705 let doc = markdown_to_adf(md).unwrap();
5706 assert_eq!(doc.content[0].node_type, "codeBlock");
5707 assert!(doc.content[0].attrs.is_none());
5708 }
5709
5710 #[test]
5711 fn code_block_empty_language() {
5712 let md = "```\"\"\nsome code\n```";
5713 let doc = markdown_to_adf(md).unwrap();
5714 assert_eq!(doc.content[0].node_type, "codeBlock");
5715 let attrs = doc.content[0].attrs.as_ref().unwrap();
5716 assert_eq!(attrs["language"], "");
5717 }
5718
5719 #[test]
5720 fn horizontal_rule() {
5721 let doc = markdown_to_adf("---").unwrap();
5722 assert_eq!(doc.content[0].node_type, "rule");
5723 }
5724
5725 #[test]
5726 fn horizontal_rule_stars() {
5727 let doc = markdown_to_adf("***").unwrap();
5728 assert_eq!(doc.content[0].node_type, "rule");
5729 }
5730
5731 #[test]
5732 fn blockquote() {
5733 let md = "> This is a quote\n> Second line";
5734 let doc = markdown_to_adf(md).unwrap();
5735 assert_eq!(doc.content[0].node_type, "blockquote");
5736 }
5737
5738 #[test]
5739 fn bullet_list() {
5740 let md = "- Item 1\n- Item 2\n- Item 3";
5741 let doc = markdown_to_adf(md).unwrap();
5742 assert_eq!(doc.content[0].node_type, "bulletList");
5743 let items = doc.content[0].content.as_ref().unwrap();
5744 assert_eq!(items.len(), 3);
5745 }
5746
5747 #[test]
5748 fn ordered_list() {
5749 let md = "1. First\n2. Second\n3. Third";
5750 let doc = markdown_to_adf(md).unwrap();
5751 assert_eq!(doc.content[0].node_type, "orderedList");
5752 let items = doc.content[0].content.as_ref().unwrap();
5753 assert_eq!(items.len(), 3);
5754 }
5755
5756 fn item_text(item: &AdfNode) -> &str {
5760 item.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
5761 .text
5762 .as_deref()
5763 .unwrap()
5764 }
5765
5766 #[test]
5771 fn nested_ordered_list_sibling_stays_sibling() {
5772 let md = "1. Родитель:\n 1. Пункт А\n 2. Пункт Б\n2. Следующий пункт";
5773 let doc = markdown_to_adf(md).unwrap();
5774 let outer = doc.content[0].content.as_ref().unwrap();
5775 assert_eq!(outer.len(), 2);
5776 let first = outer[0].content.as_ref().unwrap();
5779 assert_eq!(first.len(), 2);
5780 assert_eq!(first[0].node_type, "paragraph");
5781 assert_eq!(first[1].node_type, "orderedList");
5782 assert!(first[1].attrs.is_none());
5784 let nested_items = first[1].content.as_ref().unwrap();
5785 assert_eq!(nested_items.len(), 2);
5786 assert_eq!(item_text(&nested_items[0]), "Пункт А");
5787 assert_eq!(item_text(&nested_items[1]), "Пункт Б");
5788 let second = outer[1].content.as_ref().unwrap();
5791 assert_eq!(second.len(), 1);
5792 assert_eq!(item_text(&outer[1]), "Следующий пункт");
5793 }
5794
5795 #[test]
5796 fn nested_unordered_list_sibling_stays_sibling() {
5797 let md = "- Родитель:\n - Пункт А\n - Пункт Б\n- Следующий пункт";
5798 let doc = markdown_to_adf(md).unwrap();
5799 let outer = doc.content[0].content.as_ref().unwrap();
5800 assert_eq!(outer.len(), 2);
5801 let first = outer[0].content.as_ref().unwrap();
5802 assert_eq!(first.len(), 2);
5803 assert_eq!(first[1].node_type, "bulletList");
5804 assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5805 assert_eq!(item_text(&outer[1]), "Следующий пункт");
5806 }
5807
5808 #[test]
5809 fn mixed_nested_list_ordered_outer_unordered_inner() {
5810 let md = "1. Родитель:\n - Пункт А\n - Пункт Б\n2. Следующий пункт";
5811 let doc = markdown_to_adf(md).unwrap();
5812 let outer = doc.content[0].content.as_ref().unwrap();
5813 assert_eq!(outer.len(), 2);
5814 let first = outer[0].content.as_ref().unwrap();
5815 assert_eq!(first[1].node_type, "bulletList");
5816 assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5817 assert_eq!(item_text(&outer[1]), "Следующий пункт");
5818 }
5819
5820 #[test]
5821 fn mixed_nested_list_unordered_outer_ordered_inner() {
5822 let md = "- Родитель:\n 1. Пункт А\n 2. Пункт Б\n- Следующий пункт";
5823 let doc = markdown_to_adf(md).unwrap();
5824 let outer = doc.content[0].content.as_ref().unwrap();
5825 assert_eq!(outer.len(), 2);
5826 let first = outer[0].content.as_ref().unwrap();
5827 assert_eq!(first[1].node_type, "orderedList");
5828 assert!(first[1].attrs.is_none());
5829 assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5830 assert_eq!(item_text(&outer[1]), "Следующий пункт");
5831 }
5832
5833 #[test]
5836 fn top_level_numbering_continues_after_nested_block() {
5837 let md = "1. Первый\n 1. Вложенный\n 2. Тоже вложенный\n2. Второй\n3. Третий";
5838 let doc = markdown_to_adf(md).unwrap();
5839 let list = &doc.content[0];
5840 assert!(list.attrs.is_none());
5841 let outer = list.content.as_ref().unwrap();
5842 assert_eq!(outer.len(), 3);
5843 assert_eq!(item_text(&outer[1]), "Второй");
5844 assert_eq!(item_text(&outer[2]), "Третий");
5845 for item in &outer[1..] {
5847 assert_eq!(item.content.as_ref().unwrap().len(), 1);
5848 }
5849 }
5850
5851 #[test]
5854 fn nested_list_three_levels_sibling_stays_sibling() {
5855 let md = "1. L1\n 1. L2a\n 1. L3a\n 2. L3b\n 2. L2b\n2. L1b";
5856 let doc = markdown_to_adf(md).unwrap();
5857 let outer = doc.content[0].content.as_ref().unwrap();
5858 assert_eq!(outer.len(), 2);
5859 let l1 = outer[0].content.as_ref().unwrap();
5861 assert_eq!(l1.len(), 2);
5862 let level2 = l1[1].content.as_ref().unwrap();
5863 assert_eq!(level2.len(), 2);
5864 assert_eq!(item_text(&level2[0]), "L2a");
5865 assert_eq!(item_text(&level2[1]), "L2b");
5866 let l2a = level2[0].content.as_ref().unwrap();
5868 assert_eq!(l2a.len(), 2);
5869 let level3 = l2a[1].content.as_ref().unwrap();
5870 assert_eq!(level3.len(), 2);
5871 assert_eq!(item_text(&level3[0]), "L3a");
5872 assert_eq!(item_text(&level3[1]), "L3b");
5873 assert_eq!(level2[1].content.as_ref().unwrap().len(), 1);
5875 assert_eq!(outer[1].content.as_ref().unwrap().len(), 1);
5876 }
5877
5878 #[test]
5879 fn nested_task_list_sibling_stays_sibling() {
5880 let md = "- [ ] Родитель\n - [ ] Подзадача А\n - [ ] Подзадача Б";
5881 let doc = markdown_to_adf(md).unwrap();
5882 let items = doc.content[0].content.as_ref().unwrap();
5883 assert_eq!(items.len(), 2);
5886 assert_eq!(items[1].node_type, "taskList");
5887 let sub_items = items[1].content.as_ref().unwrap();
5888 assert_eq!(sub_items.len(), 2);
5889 assert_eq!(sub_items[0].node_type, "taskItem");
5890 assert_eq!(sub_items[1].node_type, "taskItem");
5891 }
5892
5893 #[test]
5896 fn nested_item_ending_in_hardbreak_keeps_next_sibling() {
5897 let md = "1. Родитель:\n 1. Пункт А \n 2. Пункт Б\n2. Следующий";
5898 let doc = markdown_to_adf(md).unwrap();
5899 let outer = doc.content[0].content.as_ref().unwrap();
5900 assert_eq!(outer.len(), 2);
5901 let nested = outer[0].content.as_ref().unwrap()[1]
5902 .content
5903 .as_ref()
5904 .unwrap();
5905 assert_eq!(nested.len(), 2, "nested items: {nested:?}");
5906 assert_eq!(item_text(&nested[1]), "Пункт Б");
5907 }
5908
5909 #[test]
5912 fn nested_item_continuation_paragraph_stays_in_item() {
5913 let md = "1. Parent:\n 1. Child one\n Continuation of child one";
5914 let doc = markdown_to_adf(md).unwrap();
5915 let outer = doc.content[0].content.as_ref().unwrap();
5916 assert_eq!(outer.len(), 1);
5917 let parent = outer[0].content.as_ref().unwrap();
5918 assert_eq!(parent.len(), 2, "parent content: {parent:?}");
5919 let child = parent[1].content.as_ref().unwrap()[0]
5920 .content
5921 .as_ref()
5922 .unwrap();
5923 assert_eq!(child.len(), 2);
5924 assert_eq!(child[1].node_type, "paragraph");
5925 assert_eq!(
5926 child[1].content.as_ref().unwrap()[0].text.as_deref(),
5927 Some("Continuation of child one")
5928 );
5929 }
5930
5931 #[test]
5934 fn nested_item_fenced_code_block_stays_in_item() {
5935 let md = "- Parent\n - Child\n ```bash\n echo hi\n ```";
5936 let doc = markdown_to_adf(md).unwrap();
5937 let parent = doc.content[0].content.as_ref().unwrap()[0]
5938 .content
5939 .as_ref()
5940 .unwrap();
5941 let child = parent[1].content.as_ref().unwrap()[0]
5942 .content
5943 .as_ref()
5944 .unwrap();
5945 assert_eq!(child.len(), 2, "child content: {child:?}");
5946 assert_eq!(child[1].node_type, "codeBlock");
5947 assert_eq!(
5948 child[1].content.as_ref().unwrap()[0].text.as_deref(),
5949 Some("echo hi")
5950 );
5951 }
5952
5953 #[test]
5957 fn nbsp_before_marker_keeps_children_nested() {
5958 let md = "\u{a0}- Parent\n - Child\n- Next";
5959 let doc = markdown_to_adf(md).unwrap();
5960 let outer = doc.content[0].content.as_ref().unwrap();
5961 assert_eq!(outer.len(), 2, "outer items: {outer:?}");
5962 let parent = outer[0].content.as_ref().unwrap();
5963 assert_eq!(parent.len(), 2);
5964 assert_eq!(parent[1].node_type, "bulletList");
5965 assert_eq!(item_text(&outer[1]), "Next");
5966 }
5967
5968 #[test]
5973 fn ordered_item_continuation_at_natural_column_no_surplus() {
5974 let md = "1. a\n more\n";
5975 let doc = markdown_to_adf(md).unwrap();
5976 let item = doc.content[0].content.as_ref().unwrap()[0]
5977 .content
5978 .as_ref()
5979 .unwrap();
5980 assert_eq!(item.len(), 2, "item content: {item:?}");
5981 assert_eq!(
5982 item[1].content.as_ref().unwrap()[0].text.as_deref(),
5983 Some("more")
5984 );
5985 }
5986
5987 #[test]
5990 fn ordered_item_continuation_after_blank_at_natural_column_no_surplus() {
5991 let md = "1. a\n\n more\n";
5992 let doc = markdown_to_adf(md).unwrap();
5993 let item = doc.content[0].content.as_ref().unwrap()[0]
5994 .content
5995 .as_ref()
5996 .unwrap();
5997 assert_eq!(item.len(), 2, "item content: {item:?}");
5998 assert_eq!(
5999 item[1].content.as_ref().unwrap()[0].text.as_deref(),
6000 Some("more")
6001 );
6002 }
6003
6004 #[test]
6007 fn bullet_item_continuation_deep_indent_no_surplus() {
6008 let md = "- a\n deep\n";
6009 let doc = markdown_to_adf(md).unwrap();
6010 let item = doc.content[0].content.as_ref().unwrap()[0]
6011 .content
6012 .as_ref()
6013 .unwrap();
6014 assert_eq!(item.len(), 2, "item content: {item:?}");
6015 assert_eq!(
6016 item[1].content.as_ref().unwrap()[0].text.as_deref(),
6017 Some("deep")
6018 );
6019 }
6020
6021 #[test]
6026 fn item_fence_one_column_deeper_than_natural_is_recognized_and_dedented() {
6027 let md = "- a\n ```bash\n echo hi\n ```";
6028 let doc = markdown_to_adf(md).unwrap();
6029 let item = doc.content[0].content.as_ref().unwrap()[0]
6030 .content
6031 .as_ref()
6032 .unwrap();
6033 assert_eq!(item.len(), 2, "item content: {item:?}");
6034 assert_eq!(item[1].node_type, "codeBlock");
6035 assert_eq!(
6036 item[1].content.as_ref().unwrap()[0].text.as_deref(),
6037 Some("echo hi")
6038 );
6039 }
6040
6041 #[test]
6045 fn surplus_indent_removed_at_every_nesting_depth() {
6046 let md = "- L1\n text1\n - L2\n text2\n - L3\n text3\n";
6047 let doc = markdown_to_adf(md).unwrap();
6048 let l1 = doc.content[0].content.as_ref().unwrap()[0]
6049 .content
6050 .as_ref()
6051 .unwrap();
6052 assert_eq!(l1.len(), 3, "L1 content: {l1:?}");
6053 assert_eq!(
6054 l1[1].content.as_ref().unwrap()[0].text.as_deref(),
6055 Some("text1")
6056 );
6057 let l2 = l1[2].content.as_ref().unwrap()[0].content.as_ref().unwrap();
6058 assert_eq!(l2.len(), 3, "L2 content: {l2:?}");
6059 assert_eq!(
6060 l2[1].content.as_ref().unwrap()[0].text.as_deref(),
6061 Some("text2")
6062 );
6063 let l3 = l2[2].content.as_ref().unwrap()[0].content.as_ref().unwrap();
6064 assert_eq!(l3.len(), 2, "L3 content: {l3:?}");
6065 assert_eq!(
6066 l3[1].content.as_ref().unwrap()[0].text.as_deref(),
6067 Some("text3")
6068 );
6069 }
6070
6071 #[test]
6073 fn task_item_continuation_at_natural_column_no_surplus() {
6074 let md = "- [ ] a\n deep\n";
6075 let doc = markdown_to_adf(md).unwrap();
6076 let item = &doc.content[0].content.as_ref().unwrap()[0];
6077 let content = item.content.as_ref().unwrap();
6078 assert_eq!(content.len(), 2, "task item content: {content:?}");
6079 assert_eq!(content[1].node_type, "paragraph");
6080 assert_eq!(
6081 content[1].content.as_ref().unwrap()[0].text.as_deref(),
6082 Some("deep")
6083 );
6084 }
6085
6086 #[test]
6092 fn blank_line_before_nested_ordered_list_keeps_it_nested() {
6093 let md = "1. Родитель:\n\n 1. А\n 2. Б\n2. Следующий";
6094 let doc = markdown_to_adf(md).unwrap();
6095 let outer = doc.content[0].content.as_ref().unwrap();
6096 assert_eq!(outer.len(), 2, "outer items: {outer:?}");
6097 let first = outer[0].content.as_ref().unwrap();
6098 assert_eq!(first.len(), 2, "first item content: {first:?}");
6099 assert_eq!(first[0].node_type, "paragraph");
6100 assert_eq!(item_text(&outer[0]), "Родитель:");
6101 assert_eq!(first[1].node_type, "orderedList");
6102 let nested_items = first[1].content.as_ref().unwrap();
6103 assert_eq!(nested_items.len(), 2, "nested items: {nested_items:?}");
6104 assert_eq!(item_text(&nested_items[0]), "А");
6105 assert_eq!(item_text(&nested_items[1]), "Б");
6106 let second = outer[1].content.as_ref().unwrap();
6109 assert_eq!(second.len(), 1, "second item content: {second:?}");
6110 assert_eq!(item_text(&outer[1]), "Следующий");
6111 }
6112
6113 #[test]
6117 fn blank_line_before_continuation_paragraph_stays_in_item() {
6118 let md = "- a\n\n second paragraph\n- b";
6119 let doc = markdown_to_adf(md).unwrap();
6120 assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6121 assert_eq!(doc.content[0].node_type, "bulletList");
6122 let items = doc.content[0].content.as_ref().unwrap();
6123 assert_eq!(items.len(), 2, "items: {items:?}");
6124 let first = items[0].content.as_ref().unwrap();
6125 assert_eq!(first.len(), 2, "first item content: {first:?}");
6126 assert_eq!(first[0].node_type, "paragraph");
6127 assert_eq!(item_text(&items[0]), "a");
6128 assert_eq!(first[1].node_type, "paragraph");
6129 assert_eq!(
6130 first[1].content.as_ref().unwrap()[0].text.as_deref(),
6131 Some("second paragraph")
6132 );
6133 assert_eq!(item_text(&items[1]), "b");
6134 }
6135
6136 #[test]
6139 fn blank_line_before_unindented_line_still_ends_list() {
6140 let md = "- a\n\nPara";
6141 let doc = markdown_to_adf(md).unwrap();
6142 assert_eq!(doc.content.len(), 2, "top-level blocks: {:?}", doc.content);
6143 assert_eq!(doc.content[0].node_type, "bulletList");
6144 let items = doc.content[0].content.as_ref().unwrap();
6145 assert_eq!(items.len(), 1);
6146 assert_eq!(item_text(&items[0]), "a");
6147 assert_eq!(doc.content[1].node_type, "paragraph");
6148 assert_eq!(
6149 doc.content[1].content.as_ref().unwrap()[0].text.as_deref(),
6150 Some("Para")
6151 );
6152 }
6153
6154 #[test]
6157 fn multiple_blank_lines_before_continuation_paragraph_stay_in_item() {
6158 let md = "- a\n\n\n second paragraph";
6159 let doc = markdown_to_adf(md).unwrap();
6160 assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6161 let items = doc.content[0].content.as_ref().unwrap();
6162 assert_eq!(items.len(), 1);
6163 let first = items[0].content.as_ref().unwrap();
6164 assert_eq!(first.len(), 2, "first item content: {first:?}");
6165 assert_eq!(
6166 first[1].content.as_ref().unwrap()[0].text.as_deref(),
6167 Some("second paragraph")
6168 );
6169 }
6170
6171 #[test]
6175 fn blank_line_before_deepest_level_keeps_siblings() {
6176 let md = "1. L1\n 1. L2a\n\n 1. L3a\n 2. L3b\n 2. L2b\n2. L1b";
6177 let doc = markdown_to_adf(md).unwrap();
6178 let outer = doc.content[0].content.as_ref().unwrap();
6179 assert_eq!(outer.len(), 2);
6180 let l1 = outer[0].content.as_ref().unwrap();
6181 assert_eq!(l1.len(), 2);
6182 let level2 = l1[1].content.as_ref().unwrap();
6183 assert_eq!(level2.len(), 2);
6184 assert_eq!(item_text(&level2[0]), "L2a");
6185 assert_eq!(item_text(&level2[1]), "L2b");
6186 let l2a = level2[0].content.as_ref().unwrap();
6187 assert_eq!(l2a.len(), 2, "L2a content: {l2a:?}");
6188 let level3 = l2a[1].content.as_ref().unwrap();
6189 assert_eq!(level3.len(), 2, "level3 items: {level3:?}");
6190 assert_eq!(item_text(&level3[0]), "L3a");
6191 assert_eq!(item_text(&level3[1]), "L3b");
6192 assert_eq!(level2[1].content.as_ref().unwrap().len(), 1);
6193 assert_eq!(outer[1].content.as_ref().unwrap().len(), 1);
6194 assert_eq!(item_text(&outer[1]), "L1b");
6195 }
6196
6197 #[test]
6200 fn blank_line_before_nested_task_list_matches_two_space_form() {
6201 let with_bare_blank = markdown_to_adf("- [ ] t\n\n - [ ] sub").unwrap();
6202 let with_two_space_blank = markdown_to_adf("- [ ] t\n \n - [ ] sub").unwrap();
6203 assert_eq!(
6204 serde_json::to_value(&with_bare_blank).unwrap(),
6205 serde_json::to_value(&with_two_space_blank).unwrap()
6206 );
6207 let items = with_bare_blank.content[0].content.as_ref().unwrap();
6208 assert_eq!(items.len(), 2, "items: {items:?}");
6209 assert_eq!(items[0].node_type, "taskItem");
6210 assert_eq!(items[1].node_type, "taskList");
6211 let sub_items = items[1].content.as_ref().unwrap();
6212 assert_eq!(sub_items.len(), 1);
6213 assert_eq!(sub_items[0].node_type, "taskItem");
6214 }
6215
6216 #[test]
6220 fn blank_line_then_sibling_marker_becomes_one_list() {
6221 let md = "- a\n\n- b";
6222 let doc = markdown_to_adf(md).unwrap();
6223 assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6224 assert_eq!(doc.content[0].node_type, "bulletList");
6225 let items = doc.content[0].content.as_ref().unwrap();
6226 assert_eq!(items.len(), 2, "items: {items:?}");
6227 assert_eq!(item_text(&items[0]), "a");
6228 assert_eq!(item_text(&items[1]), "b");
6229 }
6230
6231 #[test]
6235 fn round_trip_blank_line_separated_paragraphs_in_list_item() {
6236 assert_round_trip(
6237 r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6238 {"type":"listItem","content":[
6239 {"type":"paragraph","content":[{"type":"text","text":"a"}]},
6240 {"type":"paragraph","content":[{"type":"text","text":"b"}]}
6241 ]}
6242 ]}]}"#,
6243 "- a\n \n b\n",
6244 );
6245 }
6246
6247 #[test]
6251 fn round_trip_nested_list_no_blank_separator() {
6252 assert_round_trip(
6253 r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6254 {"type":"listItem","content":[
6255 {"type":"paragraph","content":[{"type":"text","text":"p"}]},
6256 {"type":"bulletList","content":[
6257 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}
6258 ]}
6259 ]}
6260 ]}]}"#,
6261 "- p\n - x\n",
6262 );
6263 }
6264
6265 #[test]
6271 fn loose_ordered_list_merges_into_one() {
6272 let doc = markdown_to_adf("1. а\n\n2. б\n\n3. в").unwrap();
6273 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6274 assert_eq!(doc.content[0].node_type, "orderedList");
6275 assert!(doc.content[0].attrs.is_none(), "{:?}", doc.content[0].attrs);
6276 let items = doc.content[0].content.as_ref().unwrap();
6277 assert_eq!(items.len(), 3, "items: {items:?}");
6278 assert_eq!(item_text(&items[0]), "а");
6279 assert_eq!(item_text(&items[1]), "б");
6280 assert_eq!(item_text(&items[2]), "в");
6281 }
6282
6283 #[test]
6285 fn loose_bullet_list_merges_into_one() {
6286 let doc = markdown_to_adf("- а\n\n- б\n\n- в").unwrap();
6287 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6288 assert_eq!(doc.content[0].node_type, "bulletList");
6289 assert!(doc.content[0].attrs.is_none());
6290 let items = doc.content[0].content.as_ref().unwrap();
6291 assert_eq!(items.len(), 3, "items: {items:?}");
6292 }
6293
6294 #[test]
6297 fn loose_task_list_merges_into_one() {
6298 let doc = markdown_to_adf("- [ ] а\n\n- [x] б").unwrap();
6299 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6300 assert_eq!(doc.content[0].node_type, "taskList");
6301 let items = doc.content[0].content.as_ref().unwrap();
6302 assert_eq!(items.len(), 2, "items: {items:?}");
6303 assert_eq!(items[0].node_type, "taskItem");
6304 assert_eq!(items[1].node_type, "taskItem");
6305 }
6306
6307 #[test]
6310 fn loose_bullet_then_task_list_not_merged() {
6311 let doc = markdown_to_adf("- а\n\n- [ ] б").unwrap();
6312 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6313 assert_eq!(doc.content[0].node_type, "bulletList");
6314 assert_eq!(doc.content[1].node_type, "taskList");
6315 }
6316
6317 #[test]
6321 fn tight_bullet_then_task_list_not_merged() {
6322 let doc = markdown_to_adf("- b\n- [ ] t\n").unwrap();
6323 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6324 assert_eq!(doc.content[0].node_type, "bulletList");
6325 assert_eq!(doc.content[1].node_type, "taskList");
6326 assert_eq!(item_text(&doc.content[0].content.as_ref().unwrap()[0]), "b");
6327 let task_items = doc.content[1].content.as_ref().unwrap();
6328 assert_eq!(task_items[0].node_type, "taskItem");
6329 assert_eq!(
6330 task_items[0].content.as_ref().unwrap()[0].text.as_deref(),
6331 Some("t")
6332 );
6333 }
6334
6335 #[test]
6337 fn tight_task_then_bullet_list_not_merged() {
6338 let doc = markdown_to_adf("- [ ] t\n- b\n").unwrap();
6339 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6340 assert_eq!(doc.content[0].node_type, "taskList");
6341 assert_eq!(doc.content[1].node_type, "bulletList");
6342 let task_items = doc.content[0].content.as_ref().unwrap();
6343 assert_eq!(task_items[0].node_type, "taskItem");
6344 assert_eq!(
6345 task_items[0].content.as_ref().unwrap()[0].text.as_deref(),
6346 Some("t")
6347 );
6348 assert_eq!(item_text(&doc.content[1].content.as_ref().unwrap()[0]), "b");
6349 }
6350
6351 #[test]
6353 fn tight_bullet_run_unchanged() {
6354 let doc = markdown_to_adf("- a\n- b\n").unwrap();
6355 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6356 assert_eq!(doc.content[0].node_type, "bulletList");
6357 assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6358 }
6359
6360 #[test]
6362 fn tight_task_run_unchanged() {
6363 let doc = markdown_to_adf("- [ ] a\n- [ ] b\n").unwrap();
6364 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6365 assert_eq!(doc.content[0].node_type, "taskList");
6366 assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6367 }
6368
6369 #[test]
6372 fn tight_bullet_then_task_list_not_merged_in_list_item() {
6373 let doc = markdown_to_adf("- x\n - b\n - [ ] t\n").unwrap();
6374 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6375 let outer_items = doc.content[0].content.as_ref().unwrap();
6376 assert_eq!(outer_items.len(), 1, "outer items: {outer_items:?}");
6377 let children = outer_items[0].content.as_ref().unwrap();
6378 assert_eq!(children.len(), 3, "children: {children:?}");
6379 assert_eq!(children[1].node_type, "bulletList");
6380 assert_eq!(children[2].node_type, "taskList");
6381 }
6382
6383 #[test]
6386 fn ordered_lists_with_numbering_restart_not_merged() {
6387 let doc = markdown_to_adf("1. а\n2. б\n\n5. в").unwrap();
6388 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6389 assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6390 assert_eq!(
6391 doc.content[1].attrs.as_ref().and_then(|a| a.get("order")),
6392 Some(&serde_json::json!(5))
6393 );
6394 }
6395
6396 #[test]
6399 fn ordered_lists_both_starting_at_one_merge() {
6400 let doc = markdown_to_adf("1. а\n2. б\n\n1. в\n2. г").unwrap();
6401 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6402 let items = doc.content[0].content.as_ref().unwrap();
6403 assert_eq!(items.len(), 4, "items: {items:?}");
6404 assert_eq!(item_text(&items[3]), "г");
6405 }
6406
6407 #[test]
6409 fn ordered_and_bullet_lists_not_merged() {
6410 let doc = markdown_to_adf("1. а\n\n- б").unwrap();
6411 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6412 assert_eq!(doc.content[0].node_type, "orderedList");
6413 assert_eq!(doc.content[1].node_type, "bulletList");
6414 }
6415
6416 #[test]
6419 fn lists_separated_by_paragraph_not_merged() {
6420 let doc = markdown_to_adf("- а\n\nтекст\n\n- б").unwrap();
6421 assert_eq!(doc.content.len(), 3, "blocks: {:?}", doc.content);
6422 assert_eq!(doc.content[0].node_type, "bulletList");
6423 assert_eq!(doc.content[1].node_type, "paragraph");
6424 assert_eq!(doc.content[2].node_type, "bulletList");
6425 }
6426
6427 #[test]
6431 fn nested_loose_list_merges_inside_list_item() {
6432 let md = "1. Родитель:\n 1. А\n\n 2. Б\n2. Следующий";
6433 let doc = markdown_to_adf(md).unwrap();
6434 assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6435 let items = doc.content[0].content.as_ref().unwrap();
6436 assert_eq!(items.len(), 2, "items: {items:?}");
6437 assert_eq!(item_text(&items[0]), "Родитель:");
6438 assert_eq!(item_text(&items[1]), "Следующий");
6439 let first_children = items[0].content.as_ref().unwrap();
6440 assert_eq!(first_children.len(), 2, "children: {first_children:?}");
6441 assert_eq!(first_children[1].node_type, "orderedList");
6442 let nested = first_children[1].content.as_ref().unwrap();
6443 assert_eq!(nested.len(), 2, "nested items: {nested:?}");
6444 assert_eq!(item_text(&nested[0]), "А");
6445 assert_eq!(item_text(&nested[1]), "Б");
6446 }
6447
6448 #[test]
6450 fn list_separate_marker_keeps_lists_separate() {
6451 let doc = markdown_to_adf("- а\n{list=separate}\n\n- б").unwrap();
6452 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6453 assert_eq!(doc.content[0].node_type, "bulletList");
6454 assert_eq!(doc.content[1].node_type, "bulletList");
6455 assert!(doc.content[0].attrs.is_none(), "{:?}", doc.content[0].attrs);
6456 assert!(doc.content[1].attrs.is_none(), "{:?}", doc.content[1].attrs);
6457 assert_eq!(item_text(&doc.content[0].content.as_ref().unwrap()[0]), "а");
6458 assert_eq!(item_text(&doc.content[1].content.as_ref().unwrap()[0]), "б");
6459 }
6460
6461 #[test]
6464 fn list_separate_line_is_consumed_not_a_paragraph() {
6465 for md in [
6466 "- а\n{list=separate}\n\n- б",
6467 "1. а\n{list=separate}\n\n1. б",
6468 "- [ ] а\n{list=separate}\n\n- [ ] б",
6469 ] {
6470 let doc = markdown_to_adf(md).unwrap();
6471 assert!(
6472 doc.content.iter().all(|n| n.node_type != "paragraph"),
6473 "stray paragraph for {md:?}: {:?}",
6474 doc.content
6475 );
6476 let json = serde_json::to_string(&doc).unwrap();
6477 assert!(!json.contains("list=separate"), "leaked for {md:?}: {json}");
6478 }
6479 }
6480
6481 #[test]
6485 fn block_attrs_line_ends_a_list() {
6486 let doc = markdown_to_adf("- a\n{align=center}\n- b").unwrap();
6487 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6488 assert_eq!(doc.content[0].node_type, "bulletList");
6489 assert_eq!(doc.content[1].node_type, "bulletList");
6490 assert!(doc.content[0].marks.is_some(), "{:?}", doc.content[0]);
6491 assert!(doc.content[1].marks.is_none(), "{:?}", doc.content[1]);
6492 }
6493
6494 #[test]
6496 fn local_id_attrs_line_ends_a_list_keeping_both_ids() {
6497 let doc = markdown_to_adf("- a\n{localId=L1}\n- b\n{localId=L2}").unwrap();
6498 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6499 assert_eq!(
6500 doc.content[0].attrs.as_ref().and_then(|a| a.get("localId")),
6501 Some(&serde_json::json!("L1"))
6502 );
6503 assert_eq!(
6504 doc.content[1].attrs.as_ref().and_then(|a| a.get("localId")),
6505 Some(&serde_json::json!("L2"))
6506 );
6507 }
6508
6509 #[test]
6513 fn explicit_order_one_attrs_line_ends_a_list() {
6514 let doc = markdown_to_adf("1. a\n{order=1}\n\n2. b").unwrap();
6515 assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6516 assert_eq!(
6517 doc.content[0].attrs.as_ref().and_then(|a| a.get("order")),
6518 Some(&serde_json::json!(1))
6519 );
6520 assert_eq!(
6521 doc.content[1].attrs.as_ref().and_then(|a| a.get("order")),
6522 Some(&serde_json::json!(2))
6523 );
6524 }
6525
6526 #[test]
6528 fn round_trip_adjacent_bullet_lists() {
6529 assert_round_trip(
6530 r#"{"type":"doc","version":1,"content":[
6531 {"type":"bulletList","content":[
6532 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6533 ]},
6534 {"type":"bulletList","content":[
6535 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6536 ]}
6537 ]}"#,
6538 "- a\n{list=separate}\n\n- b\n",
6539 );
6540 }
6541
6542 #[test]
6544 fn round_trip_adjacent_ordered_lists() {
6545 assert_round_trip(
6546 r#"{"type":"doc","version":1,"content":[
6547 {"type":"orderedList","content":[
6548 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6549 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6550 ]},
6551 {"type":"orderedList","content":[
6552 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6553 ]}
6554 ]}"#,
6555 "1. a\n2. b\n{list=separate}\n\n1. c\n",
6556 );
6557 }
6558
6559 #[test]
6563 fn round_trip_adjacent_ordered_lists_with_explicit_order() {
6564 assert_round_trip(
6565 r#"{"type":"doc","version":1,"content":[
6566 {"type":"orderedList","content":[
6567 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6568 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6569 ]},
6570 {"type":"orderedList","attrs":{"order":3},"content":[
6571 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6572 ]}
6573 ]}"#,
6574 "1. a\n2. b\n{list=separate}\n\n3. c\n",
6575 );
6576 }
6577
6578 #[test]
6581 fn round_trip_adjacent_ordered_lists_restart_needs_no_marker() {
6582 assert_round_trip(
6583 r#"{"type":"doc","version":1,"content":[
6584 {"type":"orderedList","content":[
6585 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6586 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6587 ]},
6588 {"type":"orderedList","attrs":{"order":5},"content":[
6589 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6590 ]}
6591 ]}"#,
6592 "1. a\n2. b\n\n5. c\n",
6593 );
6594 }
6595
6596 #[test]
6599 fn round_trip_adjacent_ordered_lists_explicit_order_one() {
6600 assert_round_trip(
6601 r#"{"type":"doc","version":1,"content":[
6602 {"type":"orderedList","attrs":{"order":1},"content":[
6603 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6604 ]},
6605 {"type":"orderedList","content":[
6606 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6607 ]}
6608 ]}"#,
6609 "1. a\n{order=1 list=separate}\n\n1. b\n",
6610 );
6611 }
6612
6613 #[test]
6617 fn round_trip_adjacent_bullet_lists_in_list_item() {
6618 assert_round_trip(
6619 r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6620 {"type":"listItem","content":[
6621 {"type":"paragraph","content":[{"type":"text","text":"p"}]},
6622 {"type":"bulletList","content":[
6623 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}
6624 ]},
6625 {"type":"bulletList","content":[
6626 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"y"}]}]}
6627 ]}
6628 ]}
6629 ]}]}"#,
6630 "- p\n - x\n {list=separate}\n - y\n",
6631 );
6632 }
6633
6634 #[test]
6638 fn round_trip_bullet_then_task_list_in_list_item() {
6639 assert_round_trip(
6640 r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6641 {"type":"listItem","content":[
6642 {"type":"paragraph","content":[{"type":"text","text":"x"}]},
6643 {"type":"bulletList","content":[
6644 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6645 ]},
6646 {"type":"taskList","attrs":{"localId":"l1"},"content":[
6647 {"type":"taskItem","attrs":{"localId":"t1","state":"TODO"},"content":[{"type":"text","text":"t"}]}
6648 ]}
6649 ]}
6650 ]}]}"#,
6651 "- x\n - b\n {list=separate}\n - [ ] t {localId=t1}\n {localId=l1}\n",
6652 );
6653 }
6654
6655 #[test]
6658 fn round_trip_blockquote_adjacent_bullet_lists() {
6659 assert_round_trip(
6660 r#"{"type":"doc","version":1,"content":[{"type":"blockquote","content":[
6661 {"type":"bulletList","content":[
6662 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6663 ]},
6664 {"type":"bulletList","content":[
6665 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6666 ]}
6667 ]}]}"#,
6668 "> - a\n> {list=separate}\n> - b\n",
6669 );
6670 }
6671
6672 #[test]
6674 fn round_trip_adjacent_bullet_lists_in_table_cell() {
6675 assert_round_trip(
6676 r#"{"type":"doc","version":1,"content":[{"type":"table","content":[
6677 {"type":"tableRow","content":[
6678 {"type":"tableCell","attrs":{},"content":[
6679 {"type":"bulletList","content":[
6680 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6681 ]},
6682 {"type":"bulletList","content":[
6683 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6684 ]}
6685 ]}
6686 ]}
6687 ]}]}"#,
6688 "::::table\n:::tr\n:::td{}\n- a\n{list=separate}\n\n- b\n:::\n:::\n::::\n",
6689 );
6690 }
6691
6692 #[test]
6695 fn round_trip_adjacent_task_lists() {
6696 assert_round_trip(
6697 r#"{"type":"doc","version":1,"content":[
6698 {"type":"taskList","attrs":{"localId":"l1"},"content":[
6699 {"type":"taskItem","attrs":{"localId":"t1","state":"TODO"},"content":[{"type":"text","text":"a"}]}
6700 ]},
6701 {"type":"taskList","attrs":{"localId":"l2"},"content":[
6702 {"type":"taskItem","attrs":{"localId":"t2","state":"TODO"},"content":[{"type":"text","text":"b"}]}
6703 ]}
6704 ]}"#,
6705 "- [ ] a {localId=t1}\n{localId=l1 list=separate}\n\n- [ ] b {localId=t2}\n{localId=l2}\n",
6706 );
6707 }
6708
6709 #[test]
6711 fn round_trip_three_adjacent_bullet_lists() {
6712 assert_round_trip(
6713 r#"{"type":"doc","version":1,"content":[
6714 {"type":"bulletList","content":[
6715 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6716 ]},
6717 {"type":"bulletList","content":[
6718 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6719 ]},
6720 {"type":"bulletList","content":[
6721 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6722 ]}
6723 ]}"#,
6724 "- a\n{list=separate}\n\n- b\n{list=separate}\n\n- c\n",
6725 );
6726 }
6727
6728 #[test]
6732 fn empty_second_bullet_list_renders_without_a_marker() {
6733 let doc: AdfDocument = serde_json::from_str(
6734 r#"{"type":"doc","version":1,"content":[
6735 {"type":"bulletList","content":[
6736 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6737 ]},
6738 {"type":"bulletList","content":[]}
6739 ]}"#,
6740 )
6741 .unwrap();
6742 let md = adf_to_markdown(&doc).unwrap();
6743 assert_eq!(md, "- a\n\n");
6744 let reparsed = markdown_to_adf(&md).unwrap();
6745 assert_eq!(reparsed.content.len(), 1, "{:?}", reparsed.content);
6746 assert_eq!(reparsed.content[0].node_type, "bulletList");
6747 }
6748
6749 #[test]
6753 fn adf_unsupported_subtree_round_trips_verbatim() {
6754 let doc: AdfDocument = serde_json::from_str(
6755 r#"{"type":"doc","version":1,"content":[
6756 {"type":"mysteryBlock","attrs":{"__list_separate":true},"content":[
6757 {"type":"bulletList","content":[
6758 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6759 ]},
6760 {"type":"bulletList","content":[
6761 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6762 ]}
6763 ]}
6764 ]}"#,
6765 )
6766 .unwrap();
6767 let md = adf_to_markdown(&doc).unwrap();
6768 assert!(!md.contains("list=separate"), "md: {md}");
6769 let reparsed = markdown_to_adf(&md).unwrap();
6770 assert_eq!(
6771 serde_json::to_value(&reparsed).unwrap(),
6772 serde_json::to_value(&doc).unwrap(),
6773 "markdown was:\n{md}"
6774 );
6775 }
6776
6777 #[test]
6780 fn round_trip_paragraph_with_leading_spaces_after_list() {
6781 assert_round_trip(
6782 r#"{"type":"doc","version":1,"content":[
6783 {"type":"bulletList","content":[
6784 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6785 ]},
6786 {"type":"paragraph","content":[{"type":"text","text":" hello"}]}
6787 ]}"#,
6788 "- a\n\n::paragraph[ hello]\n",
6789 );
6790 }
6791
6792 #[test]
6795 fn round_trip_paragraph_with_leading_hash_after_list() {
6796 assert_round_trip(
6797 r#"{"type":"doc","version":1,"content":[
6798 {"type":"bulletList","content":[
6799 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6800 ]},
6801 {"type":"paragraph","content":[{"type":"text","text":" # nope"}]}
6802 ]}"#,
6803 "- a\n\n::paragraph[ # nope]\n",
6804 );
6805 }
6806
6807 #[test]
6808 fn leading_spaces_counts_ascii_spaces_only() {
6809 assert_eq!(leading_spaces(""), 0);
6810 assert_eq!(leading_spaces("- a"), 0);
6811 assert_eq!(leading_spaces(" - a"), 4);
6812 assert_eq!(leading_spaces("\t- a"), 0);
6813 assert_eq!(leading_spaces("\u{a0}- a"), 0);
6814 assert_eq!(leading_spaces(" \u{a0} - a"), 2);
6815 }
6816
6817 fn assert_round_trip(json: &str, expected_md: &str) {
6820 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6821 let md = adf_to_markdown(&doc).unwrap();
6822 assert_eq!(md, expected_md);
6823 let doc2 = markdown_to_adf(&md).unwrap();
6824 assert_eq!(
6825 serde_json::to_value(&doc2).unwrap(),
6826 serde_json::to_value(&doc).unwrap(),
6827 "round trip changed the document; markdown was:\n{md}"
6828 );
6829 }
6830
6831 #[test]
6832 fn round_trip_nested_list_siblings() {
6833 assert_round_trip(
6834 r#"{"type":"doc","version":1,"content":[{"type":"orderedList","content":[
6835 {"type":"listItem","content":[
6836 {"type":"paragraph","content":[{"type":"text","text":"Родитель:"}]},
6837 {"type":"orderedList","content":[
6838 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Пункт А"}]}]},
6839 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Пункт Б"}]}]}
6840 ]}
6841 ]},
6842 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Следующий"}]}]}
6843 ]}]}"#,
6844 "1. Родитель:\n 1. Пункт А\n 2. Пункт Б\n2. Следующий\n",
6845 );
6846 }
6847
6848 #[test]
6853 fn hardbreak_continuation_at_content_indent_is_still_text() {
6854 let md = "- first line \n 2. continued\n- second";
6855 let doc = markdown_to_adf(md).unwrap();
6856 let items = doc.content[0].content.as_ref().unwrap();
6857 assert_eq!(items.len(), 2, "items: {items:?}");
6858 let inlines = items[0].content.as_ref().unwrap()[0]
6859 .content
6860 .as_ref()
6861 .unwrap();
6862 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
6863 assert_eq!(types, vec!["text", "hardBreak", "text"]);
6864 assert_eq!(inlines[2].text.as_deref(), Some("2. continued"));
6865 }
6866
6867 #[test]
6868 fn task_list() {
6869 let md = "- [ ] Todo item\n- [x] Done item";
6870 let doc = markdown_to_adf(md).unwrap();
6871 assert_eq!(doc.content[0].node_type, "taskList");
6872 let items = doc.content[0].content.as_ref().unwrap();
6873 assert_eq!(items.len(), 2);
6874 assert_eq!(items[0].node_type, "taskItem");
6875 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6876 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6877 }
6878
6879 #[test]
6880 fn task_list_uppercase_x() {
6881 let md = "- [X] Done item";
6882 let doc = markdown_to_adf(md).unwrap();
6883 assert_eq!(doc.content[0].node_type, "taskList");
6884 let item = &doc.content[0].content.as_ref().unwrap()[0];
6885 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
6886 }
6887
6888 #[test]
6891 fn task_list_empty_todo_no_trailing_space() {
6892 let md = "- [ ]";
6893 let doc = markdown_to_adf(md).unwrap();
6894 assert_eq!(doc.content[0].node_type, "taskList");
6895 let items = doc.content[0].content.as_ref().unwrap();
6896 assert_eq!(items.len(), 1);
6897 assert_eq!(items[0].node_type, "taskItem");
6898 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6899 assert!(items[0].content.is_none());
6900 }
6901
6902 #[test]
6904 fn task_list_empty_done_no_trailing_space() {
6905 let md = "- [x]\n- [X]";
6906 let doc = markdown_to_adf(md).unwrap();
6907 assert_eq!(doc.content[0].node_type, "taskList");
6908 let items = doc.content[0].content.as_ref().unwrap();
6909 assert_eq!(items.len(), 2);
6910 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
6911 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6912 }
6913
6914 #[test]
6917 fn task_list_body_has_no_leading_space() {
6918 let md = "- [ ] Buy groceries";
6919 let doc = markdown_to_adf(md).unwrap();
6920 let item = &doc.content[0].content.as_ref().unwrap()[0];
6921 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6922 assert_eq!(text, "Buy groceries");
6923 }
6924
6925 #[test]
6929 fn round_trip_empty_task_items_stripped_trailing_spaces() {
6930 let json = r#"{
6931 "version": 1,
6932 "type": "doc",
6933 "content": [{
6934 "type": "taskList",
6935 "attrs": {"localId": "abc"},
6936 "content": [
6937 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
6938 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
6939 ]
6940 }]
6941 }"#;
6942 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6943 let md = adf_to_markdown(&doc).unwrap();
6944 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
6945 let parsed = markdown_to_adf(&stripped).unwrap();
6946 assert_eq!(parsed.content[0].node_type, "taskList");
6947 let items = parsed.content[0].content.as_ref().unwrap();
6948 assert_eq!(items.len(), 2);
6949 assert_eq!(items[0].node_type, "taskItem");
6950 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6951 assert_eq!(items[1].node_type, "taskItem");
6952 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6953 }
6954
6955 #[test]
6956 fn try_parse_task_marker_accepts_bare_checkbox() {
6957 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
6958 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
6959 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
6960 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
6961 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
6962 assert_eq!(try_parse_task_marker("[ ]foo"), None);
6963 assert_eq!(try_parse_task_marker("[x]foo"), None);
6964 assert_eq!(try_parse_task_marker("[y] foo"), None);
6965 }
6966
6967 #[test]
6968 fn starts_with_task_marker_matches_parser() {
6969 assert!(starts_with_task_marker("[ ]"));
6972 assert!(starts_with_task_marker("[x]"));
6973 assert!(starts_with_task_marker("[X]"));
6974 assert!(starts_with_task_marker("[ ] foo"));
6975 assert!(starts_with_task_marker("[x] foo\n"));
6976 assert!(starts_with_task_marker("[ ]\n"));
6977 assert!(!starts_with_task_marker("[ ]foo"));
6979 assert!(!starts_with_task_marker("[y] foo"));
6980 assert!(!starts_with_task_marker("foo [ ] bar"));
6981 assert!(!starts_with_task_marker(""));
6982 }
6983
6984 #[test]
6988 fn round_trip_bullet_list_with_literal_checkbox_text() {
6989 let json = r#"{
6990 "version": 1,
6991 "type": "doc",
6992 "content": [{
6993 "type": "bulletList",
6994 "content": [{
6995 "type": "listItem",
6996 "content": [{
6997 "type": "paragraph",
6998 "content": [
6999 {"type": "text", "text": "[ ] Review the "},
7000 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
7001 {"type": "text", "text": " file"}
7002 ]
7003 }]
7004 }]
7005 }]
7006 }"#;
7007 let original: AdfDocument = serde_json::from_str(json).unwrap();
7008 let md = adf_to_markdown(&original).unwrap();
7009 assert!(
7011 md.contains(r"- \[ ] Review the "),
7012 "rendered markdown: {md:?}"
7013 );
7014 let parsed = markdown_to_adf(&md).unwrap();
7015 assert_eq!(parsed.content[0].node_type, "bulletList");
7016 let item = &parsed.content[0].content.as_ref().unwrap()[0];
7017 assert_eq!(item.node_type, "listItem");
7018 let para = &item.content.as_ref().unwrap()[0];
7019 assert_eq!(para.node_type, "paragraph");
7020 let text_nodes = para.content.as_ref().unwrap();
7021 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
7022 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
7023 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
7024 }
7025
7026 #[test]
7028 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
7029 let json = r#"{
7030 "version": 1,
7031 "type": "doc",
7032 "content": [{
7033 "type": "bulletList",
7034 "content": [{
7035 "type": "listItem",
7036 "content": [{
7037 "type": "paragraph",
7038 "content": [{"type": "text", "text": "[x] not actually done"}]
7039 }]
7040 }]
7041 }]
7042 }"#;
7043 let original: AdfDocument = serde_json::from_str(json).unwrap();
7044 let md = adf_to_markdown(&original).unwrap();
7045 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
7046 let parsed = markdown_to_adf(&md).unwrap();
7047 assert_eq!(parsed.content[0].node_type, "bulletList");
7048 let item = &parsed.content[0].content.as_ref().unwrap()[0];
7049 let para = &item.content.as_ref().unwrap()[0];
7050 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7051 assert_eq!(text, "[x] not actually done");
7052 }
7053
7054 #[test]
7056 fn round_trip_bullet_list_with_bare_literal_checkbox() {
7057 let json = r#"{
7058 "version": 1,
7059 "type": "doc",
7060 "content": [{
7061 "type": "bulletList",
7062 "content": [{
7063 "type": "listItem",
7064 "content": [{
7065 "type": "paragraph",
7066 "content": [{"type": "text", "text": "[ ]"}]
7067 }]
7068 }]
7069 }]
7070 }"#;
7071 let original: AdfDocument = serde_json::from_str(json).unwrap();
7072 let md = adf_to_markdown(&original).unwrap();
7073 let parsed = markdown_to_adf(&md).unwrap();
7074 assert_eq!(parsed.content[0].node_type, "bulletList");
7075 let item = &parsed.content[0].content.as_ref().unwrap()[0];
7076 let para = &item.content.as_ref().unwrap()[0];
7077 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7078 assert_eq!(text, "[ ]");
7079 }
7080
7081 #[test]
7084 fn bullet_list_non_task_bracket_text_not_escaped() {
7085 let json = r#"{
7086 "version": 1,
7087 "type": "doc",
7088 "content": [{
7089 "type": "bulletList",
7090 "content": [{
7091 "type": "listItem",
7092 "content": [{
7093 "type": "paragraph",
7094 "content": [{"type": "text", "text": "[?] unsure"}]
7095 }]
7096 }]
7097 }]
7098 }"#;
7099 let original: AdfDocument = serde_json::from_str(json).unwrap();
7100 let md = adf_to_markdown(&original).unwrap();
7101 assert!(!md.contains(r"\["), "should not escape: {md:?}");
7102 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
7103 }
7104
7105 #[test]
7108 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
7109 let json = r#"{
7110 "version": 1,
7111 "type": "doc",
7112 "content": [{
7113 "type": "bulletList",
7114 "content": [{
7115 "type": "listItem",
7116 "content": [
7117 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
7118 {"type": "bulletList", "content": [{
7119 "type": "listItem",
7120 "content": [{
7121 "type": "paragraph",
7122 "content": [{"type": "text", "text": "[ ] inner literal"}]
7123 }]
7124 }]}
7125 ]
7126 }]
7127 }]
7128 }"#;
7129 let original: AdfDocument = serde_json::from_str(json).unwrap();
7130 let md = adf_to_markdown(&original).unwrap();
7131 let parsed = markdown_to_adf(&md).unwrap();
7132 let outer = &parsed.content[0];
7133 assert_eq!(outer.node_type, "bulletList");
7134 let outer_item = &outer.content.as_ref().unwrap()[0];
7135 let inner_list = &outer_item.content.as_ref().unwrap()[1];
7136 assert_eq!(inner_list.node_type, "bulletList");
7137 let inner_item = &inner_list.content.as_ref().unwrap()[0];
7138 assert_eq!(inner_item.node_type, "listItem");
7139 let para = &inner_item.content.as_ref().unwrap()[0];
7140 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7141 assert_eq!(text, "[ ] inner literal");
7142 }
7143
7144 #[test]
7145 fn adf_task_list_to_markdown() {
7146 let doc = AdfDocument {
7147 version: 1,
7148 doc_type: "doc".to_string(),
7149 content: vec![AdfNode::task_list(vec![
7150 AdfNode::task_item(
7151 "TODO",
7152 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
7153 ),
7154 AdfNode::task_item(
7155 "DONE",
7156 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
7157 ),
7158 ])],
7159 };
7160 let md = adf_to_markdown(&doc).unwrap();
7161 assert!(md.contains("- [ ] Todo"));
7162 assert!(md.contains("- [x] Done"));
7163 }
7164
7165 #[test]
7166 fn round_trip_task_list() {
7167 let md = "- [ ] Todo item\n- [x] Done item\n";
7168 let doc = markdown_to_adf(md).unwrap();
7169 let result = adf_to_markdown(&doc).unwrap();
7170 assert!(result.contains("- [ ] Todo item"));
7171 assert!(result.contains("- [x] Done item"));
7172 }
7173
7174 #[test]
7176 fn adf_task_item_unwrapped_inline_content() {
7177 let json = r#"{
7179 "version": 1,
7180 "type": "doc",
7181 "content": [{
7182 "type": "taskList",
7183 "attrs": {"localId": "list-001"},
7184 "content": [{
7185 "type": "taskItem",
7186 "attrs": {"localId": "task-001", "state": "TODO"},
7187 "content": [{"type": "text", "text": "Do something"}]
7188 }]
7189 }]
7190 }"#;
7191 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7192 let md = adf_to_markdown(&doc).unwrap();
7193 assert!(md.contains("- [ ] Do something"), "got: {md}");
7194 assert!(!md.contains("adf-unsupported"), "got: {md}");
7195 }
7196
7197 #[test]
7199 fn adf_task_list_multiple_unwrapped_items() {
7200 let json = r#"{
7201 "version": 1,
7202 "type": "doc",
7203 "content": [{
7204 "type": "taskList",
7205 "attrs": {"localId": "list-001"},
7206 "content": [
7207 {
7208 "type": "taskItem",
7209 "attrs": {"localId": "task-001", "state": "TODO"},
7210 "content": [{"type": "text", "text": "First task"}]
7211 },
7212 {
7213 "type": "taskItem",
7214 "attrs": {"localId": "task-002", "state": "DONE"},
7215 "content": [{"type": "text", "text": "Second task"}]
7216 }
7217 ]
7218 }]
7219 }"#;
7220 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7221 let md = adf_to_markdown(&doc).unwrap();
7222 assert!(md.contains("- [ ] First task"), "got: {md}");
7223 assert!(md.contains("- [x] Second task"), "got: {md}");
7224 assert!(!md.contains("adf-unsupported"), "got: {md}");
7225 }
7226
7227 #[test]
7229 fn adf_task_item_unwrapped_inline_with_marks() {
7230 let json = r#"{
7231 "version": 1,
7232 "type": "doc",
7233 "content": [{
7234 "type": "taskList",
7235 "attrs": {"localId": "list-001"},
7236 "content": [{
7237 "type": "taskItem",
7238 "attrs": {"localId": "task-001", "state": "TODO"},
7239 "content": [
7240 {"type": "text", "text": "Buy "},
7241 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
7242 {"type": "text", "text": " today"}
7243 ]
7244 }]
7245 }]
7246 }"#;
7247 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7248 let md = adf_to_markdown(&doc).unwrap();
7249 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
7250 }
7251
7252 #[test]
7254 fn adf_task_item_unwrapped_preserves_local_id() {
7255 let json = r#"{
7256 "version": 1,
7257 "type": "doc",
7258 "content": [{
7259 "type": "taskList",
7260 "attrs": {"localId": "list-001"},
7261 "content": [{
7262 "type": "taskItem",
7263 "attrs": {"localId": "task-001", "state": "TODO"},
7264 "content": [{"type": "text", "text": "Do something"}]
7265 }]
7266 }]
7267 }"#;
7268 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7269 let md = adf_to_markdown(&doc).unwrap();
7270 assert!(md.contains("{localId=task-001}"), "got: {md}");
7271 assert!(md.contains("{localId=list-001}"), "got: {md}");
7272 }
7273
7274 #[test]
7276 fn round_trip_task_list_unwrapped_inline() {
7277 let json = r#"{
7278 "version": 1,
7279 "type": "doc",
7280 "content": [{
7281 "type": "taskList",
7282 "attrs": {"localId": "list-001"},
7283 "content": [
7284 {
7285 "type": "taskItem",
7286 "attrs": {"localId": "task-001", "state": "TODO"},
7287 "content": [{"type": "text", "text": "Do something"}]
7288 },
7289 {
7290 "type": "taskItem",
7291 "attrs": {"localId": "task-002", "state": "DONE"},
7292 "content": [{"type": "text", "text": "Already done"}]
7293 }
7294 ]
7295 }]
7296 }"#;
7297 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7298 let md = adf_to_markdown(&doc).unwrap();
7299
7300 let doc2 = markdown_to_adf(&md).unwrap();
7302 assert_eq!(doc2.content[0].node_type, "taskList");
7303
7304 let items = doc2.content[0].content.as_ref().unwrap();
7305 assert_eq!(items.len(), 2);
7306 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
7307 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
7308
7309 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
7311 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
7312 assert_eq!(
7313 doc2.content[0].attrs.as_ref().unwrap()["localId"],
7314 "list-001"
7315 );
7316 }
7317
7318 #[test]
7320 fn adf_task_item_unwrapped_inline_then_block() {
7321 let json = r#"{
7322 "version": 1,
7323 "type": "doc",
7324 "content": [{
7325 "type": "taskList",
7326 "attrs": {"localId": "list-001"},
7327 "content": [{
7328 "type": "taskItem",
7329 "attrs": {"localId": "task-001", "state": "TODO"},
7330 "content": [
7331 {"type": "text", "text": "Parent task"},
7332 {
7333 "type": "bulletList",
7334 "content": [{
7335 "type": "listItem",
7336 "content": [{
7337 "type": "paragraph",
7338 "content": [{"type": "text", "text": "sub-item"}]
7339 }]
7340 }]
7341 }
7342 ]
7343 }]
7344 }]
7345 }"#;
7346 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7347 let md = adf_to_markdown(&doc).unwrap();
7348 assert!(md.contains("- [ ] Parent task"), "got: {md}");
7349 assert!(md.contains(" - sub-item"), "got: {md}");
7350 assert!(!md.contains("adf-unsupported"), "got: {md}");
7351 }
7352
7353 #[test]
7355 fn adf_task_item_empty_content() {
7356 let json = r#"{
7357 "version": 1,
7358 "type": "doc",
7359 "content": [{
7360 "type": "taskList",
7361 "attrs": {"localId": "list-001"},
7362 "content": [{
7363 "type": "taskItem",
7364 "attrs": {"localId": "task-001", "state": "TODO"},
7365 "content": []
7366 }]
7367 }]
7368 }"#;
7369 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7370 let md = adf_to_markdown(&doc).unwrap();
7371 assert!(md.contains("- [ ] "), "got: {md}");
7372 assert!(!md.contains("adf-unsupported"), "got: {md}");
7373 }
7374
7375 #[test]
7378 fn adf_nested_task_item_renders_without_corruption() {
7379 let json = r#"{
7380 "type": "doc",
7381 "version": 1,
7382 "content": [{
7383 "type": "taskList",
7384 "attrs": {"localId": ""},
7385 "content": [
7386 {
7387 "type": "taskItem",
7388 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
7389 "content": [{"type": "text", "text": "Normal task"}]
7390 },
7391 {
7392 "type": "taskItem",
7393 "attrs": {"localId": ""},
7394 "content": [
7395 {
7396 "type": "taskItem",
7397 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
7398 "content": [{"type": "text", "text": "Nested task one"}]
7399 },
7400 {
7401 "type": "taskItem",
7402 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
7403 "content": [{"type": "text", "text": "Nested task two"}]
7404 }
7405 ]
7406 }
7407 ]
7408 }]
7409 }"#;
7410 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7411 let md = adf_to_markdown(&doc).unwrap();
7412 assert!(md.contains("- [ ] Normal task"), "got: {md}");
7414 assert!(!md.contains("adf-unsupported"), "got: {md}");
7416 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
7417 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
7418 }
7419
7420 #[test]
7422 fn round_trip_nested_task_item() {
7423 let json = r#"{
7424 "type": "doc",
7425 "version": 1,
7426 "content": [{
7427 "type": "taskList",
7428 "attrs": {"localId": ""},
7429 "content": [
7430 {
7431 "type": "taskItem",
7432 "attrs": {"localId": "task-001", "state": "TODO"},
7433 "content": [{"type": "text", "text": "Normal task"}]
7434 },
7435 {
7436 "type": "taskItem",
7437 "attrs": {"localId": ""},
7438 "content": [
7439 {
7440 "type": "taskItem",
7441 "attrs": {"localId": "task-002", "state": "TODO"},
7442 "content": [{"type": "text", "text": "Nested one"}]
7443 },
7444 {
7445 "type": "taskItem",
7446 "attrs": {"localId": "task-003", "state": "DONE"},
7447 "content": [{"type": "text", "text": "Nested two"}]
7448 }
7449 ]
7450 }
7451 ]
7452 }]
7453 }"#;
7454 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7455 let md = adf_to_markdown(&doc).unwrap();
7456 let doc2 = markdown_to_adf(&md).unwrap();
7457
7458 assert_eq!(doc2.content[0].node_type, "taskList");
7460 let items = doc2.content[0].content.as_ref().unwrap();
7461 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
7462
7463 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
7465 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
7466 let first_content = items[0].content.as_ref().unwrap();
7467 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
7468
7469 let container = &items[1];
7471 assert_eq!(container.node_type, "taskItem");
7472 let c_attrs = container.attrs.as_ref().unwrap();
7473 assert!(
7474 c_attrs.get("state").is_none(),
7475 "container should have no state attr, got: {c_attrs:?}"
7476 );
7477
7478 let container_content = container.content.as_ref().unwrap();
7480 assert_eq!(
7481 container_content.len(),
7482 2,
7483 "expected 2 bare taskItem children"
7484 );
7485 assert_eq!(container_content[0].node_type, "taskItem");
7486 assert_eq!(
7487 container_content[0].attrs.as_ref().unwrap()["state"],
7488 "TODO"
7489 );
7490 assert_eq!(
7491 container_content[0].attrs.as_ref().unwrap()["localId"],
7492 "task-002"
7493 );
7494 assert_eq!(container_content[1].node_type, "taskItem");
7495 assert_eq!(
7496 container_content[1].attrs.as_ref().unwrap()["state"],
7497 "DONE"
7498 );
7499 assert_eq!(
7500 container_content[1].attrs.as_ref().unwrap()["localId"],
7501 "task-003"
7502 );
7503 }
7504
7505 #[test]
7507 fn adf_nested_task_item_preserves_local_ids() {
7508 let json = r#"{
7509 "type": "doc",
7510 "version": 1,
7511 "content": [{
7512 "type": "taskList",
7513 "attrs": {"localId": "list-001"},
7514 "content": [{
7515 "type": "taskItem",
7516 "attrs": {"localId": "container-001", "state": "TODO"},
7517 "content": [{
7518 "type": "taskItem",
7519 "attrs": {"localId": "child-001", "state": "DONE"},
7520 "content": [{"type": "text", "text": "Nested child"}]
7521 }]
7522 }]
7523 }]
7524 }"#;
7525 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7526 let md = adf_to_markdown(&doc).unwrap();
7527 assert!(
7529 md.contains("localId=container-001"),
7530 "container localId missing: {md}"
7531 );
7532 assert!(
7534 md.contains("localId=child-001"),
7535 "child localId missing: {md}"
7536 );
7537 assert!(!md.contains("adf-unsupported"), "got: {md}");
7538 }
7539
7540 #[test]
7543 fn adf_nested_task_item_mixed_with_block_node() {
7544 let json = r#"{
7545 "type": "doc",
7546 "version": 1,
7547 "content": [{
7548 "type": "taskList",
7549 "attrs": {"localId": ""},
7550 "content": [{
7551 "type": "taskItem",
7552 "attrs": {"localId": "", "state": "TODO"},
7553 "content": [
7554 {
7555 "type": "taskItem",
7556 "attrs": {"localId": "", "state": "TODO"},
7557 "content": [{"type": "text", "text": "A nested task"}]
7558 },
7559 {
7560 "type": "paragraph",
7561 "content": [{"type": "text", "text": "Stray paragraph"}]
7562 }
7563 ]
7564 }]
7565 }]
7566 }"#;
7567 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7568 let md = adf_to_markdown(&doc).unwrap();
7569 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
7570 assert!(md.contains(" Stray paragraph"), "got: {md}");
7571 assert!(!md.contains("adf-unsupported"), "got: {md}");
7572 }
7573
7574 #[test]
7578 fn task_item_with_text_and_nested_sub_content() {
7579 let md = "- [ ] Parent task\n - [ ] Sub task\n";
7580 let doc = markdown_to_adf(md).unwrap();
7581 assert_eq!(doc.content[0].node_type, "taskList");
7582 let items = doc.content[0].content.as_ref().unwrap();
7583 assert_eq!(items.len(), 2, "got: {items:?}");
7586 let parent = &items[0];
7587 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
7588 let parent_content = parent.content.as_ref().unwrap();
7589 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
7590 assert_eq!(items[1].node_type, "taskList");
7592 let nested = items[1].content.as_ref().unwrap();
7593 assert_eq!(nested.len(), 1);
7594 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
7595 }
7596
7597 #[test]
7601 fn task_item_empty_with_non_tasklist_sub_content() {
7602 let md = "- [ ] \n Some paragraph text\n";
7603 let doc = markdown_to_adf(md).unwrap();
7604 assert_eq!(doc.content[0].node_type, "taskList");
7605 let items = doc.content[0].content.as_ref().unwrap();
7606 assert_eq!(items.len(), 1);
7607 let item = &items[0];
7608 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
7609 let content = item.content.as_ref().unwrap();
7610 assert_eq!(content[0].node_type, "paragraph");
7612 }
7613
7614 #[test]
7616 fn adf_nested_task_item_single_child() {
7617 let json = r#"{
7618 "type": "doc",
7619 "version": 1,
7620 "content": [{
7621 "type": "taskList",
7622 "attrs": {"localId": ""},
7623 "content": [{
7624 "type": "taskItem",
7625 "attrs": {"localId": "", "state": "TODO"},
7626 "content": [{
7627 "type": "taskItem",
7628 "attrs": {"localId": "", "state": "DONE"},
7629 "content": [{"type": "text", "text": "Only child"}]
7630 }]
7631 }]
7632 }]
7633 }"#;
7634 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7635 let md = adf_to_markdown(&doc).unwrap();
7636 assert!(md.contains(" - [x] Only child"), "got: {md}");
7637 assert!(!md.contains("adf-unsupported"), "got: {md}");
7638 }
7639
7640 #[test]
7643 fn adf_nested_tasklist_sibling_renders_indented() {
7644 let json = r#"{
7645 "version": 1,
7646 "type": "doc",
7647 "content": [{
7648 "type": "taskList",
7649 "attrs": {"localId": ""},
7650 "content": [
7651 {
7652 "type": "taskItem",
7653 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
7654 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
7655 },
7656 {
7657 "type": "taskList",
7658 "attrs": {"localId": ""},
7659 "content": [{
7660 "type": "taskItem",
7661 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
7662 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
7663 }]
7664 },
7665 {
7666 "type": "taskItem",
7667 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
7668 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
7669 }
7670 ]
7671 }]
7672 }"#;
7673 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7674 let md = adf_to_markdown(&doc).unwrap();
7675 assert!(md.contains("- [ ] parent task one"), "got: {md}");
7677 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
7678 assert!(md.contains("- [ ] parent task two"), "got: {md}");
7679 }
7680
7681 #[test]
7683 fn round_trip_nested_tasklist_preserves_type() {
7684 let json = r#"{
7685 "version": 1,
7686 "type": "doc",
7687 "content": [{
7688 "type": "taskList",
7689 "attrs": {"localId": ""},
7690 "content": [
7691 {
7692 "type": "taskItem",
7693 "attrs": {"localId": "", "state": "TODO"},
7694 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
7695 },
7696 {
7697 "type": "taskList",
7698 "attrs": {"localId": ""},
7699 "content": [{
7700 "type": "taskItem",
7701 "attrs": {"localId": "", "state": "TODO"},
7702 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
7703 }]
7704 },
7705 {
7706 "type": "taskItem",
7707 "attrs": {"localId": "", "state": "TODO"},
7708 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
7709 }
7710 ]
7711 }]
7712 }"#;
7713 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7714 let md = adf_to_markdown(&doc).unwrap();
7715 let rt_doc = markdown_to_adf(&md).unwrap();
7716 assert_eq!(rt_doc.content[0].node_type, "taskList");
7718 let items = rt_doc.content[0].content.as_ref().unwrap();
7719 assert_eq!(items.len(), 3, "got: {items:?}");
7722 assert_eq!(items[0].node_type, "taskItem");
7723 assert_eq!(
7724 items[1].node_type, "taskList",
7725 "nested taskList should survive round-trip"
7726 );
7727 assert_eq!(items[2].node_type, "taskItem");
7728 let nested_items = items[1].content.as_ref().unwrap();
7729 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
7730 }
7731
7732 #[test]
7734 fn adf_nested_tasklist_done_state() {
7735 let json = r#"{
7736 "version": 1,
7737 "type": "doc",
7738 "content": [{
7739 "type": "taskList",
7740 "attrs": {"localId": ""},
7741 "content": [
7742 {
7743 "type": "taskItem",
7744 "attrs": {"localId": "", "state": "TODO"},
7745 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
7746 },
7747 {
7748 "type": "taskList",
7749 "attrs": {"localId": ""},
7750 "content": [{
7751 "type": "taskItem",
7752 "attrs": {"localId": "", "state": "DONE"},
7753 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
7754 }]
7755 }
7756 ]
7757 }]
7758 }"#;
7759 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7760 let md = adf_to_markdown(&doc).unwrap();
7761 assert!(md.contains(" - [x] done child"), "got: {md}");
7762 let rt_doc = markdown_to_adf(&md).unwrap();
7764 let items = rt_doc.content[0].content.as_ref().unwrap();
7765 assert_eq!(
7766 items[1].node_type, "taskList",
7767 "nested taskList should survive round-trip"
7768 );
7769 let nested_item = &items[1].content.as_ref().unwrap()[0];
7770 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
7771 }
7772
7773 #[test]
7775 fn adf_multiple_nested_tasklists() {
7776 let json = r#"{
7777 "version": 1,
7778 "type": "doc",
7779 "content": [{
7780 "type": "taskList",
7781 "attrs": {"localId": ""},
7782 "content": [
7783 {
7784 "type": "taskItem",
7785 "attrs": {"localId": "", "state": "TODO"},
7786 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
7787 },
7788 {
7789 "type": "taskList",
7790 "attrs": {"localId": ""},
7791 "content": [{
7792 "type": "taskItem",
7793 "attrs": {"localId": "", "state": "TODO"},
7794 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
7795 }]
7796 },
7797 {
7798 "type": "taskItem",
7799 "attrs": {"localId": "", "state": "TODO"},
7800 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
7801 },
7802 {
7803 "type": "taskList",
7804 "attrs": {"localId": ""},
7805 "content": [{
7806 "type": "taskItem",
7807 "attrs": {"localId": "", "state": "DONE"},
7808 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
7809 }]
7810 }
7811 ]
7812 }]
7813 }"#;
7814 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7815 let md = adf_to_markdown(&doc).unwrap();
7816 assert!(md.contains("- [ ] first parent"), "got: {md}");
7817 assert!(md.contains(" - [ ] child A"), "got: {md}");
7818 assert!(md.contains("- [ ] second parent"), "got: {md}");
7819 assert!(md.contains(" - [x] child B"), "got: {md}");
7820 }
7821
7822 #[test]
7825 fn round_trip_nested_tasklist_stable() {
7826 let json = r#"{
7827 "version": 1,
7828 "type": "doc",
7829 "content": [{
7830 "type": "taskList",
7831 "attrs": {"localId": ""},
7832 "content": [
7833 {
7834 "type": "taskItem",
7835 "attrs": {"localId": "", "state": "TODO"},
7836 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
7837 },
7838 {
7839 "type": "taskList",
7840 "attrs": {"localId": ""},
7841 "content": [{
7842 "type": "taskItem",
7843 "attrs": {"localId": "", "state": "TODO"},
7844 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
7845 }]
7846 }
7847 ]
7848 }]
7849 }"#;
7850 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7851 let md1 = adf_to_markdown(&doc).unwrap();
7853 let rt1 = markdown_to_adf(&md1).unwrap();
7854 let md2 = adf_to_markdown(&rt1).unwrap();
7856 let rt2 = markdown_to_adf(&md2).unwrap();
7857 assert_eq!(md1, md2, "markdown should be stable across round-trips");
7859 let rt1_json = serde_json::to_string(&rt1).unwrap();
7861 let rt2_json = serde_json::to_string(&rt2).unwrap();
7862 assert_eq!(
7863 rt1_json, rt2_json,
7864 "ADF should be stable across round-trips"
7865 );
7866 }
7867
7868 #[test]
7873 fn task_item_mixed_sub_content_splits_siblings() {
7874 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
7875 let doc = markdown_to_adf(md).unwrap();
7876 let items = doc.content[0].content.as_ref().unwrap();
7877 assert_eq!(items.len(), 2, "got: {items:?}");
7879 assert_eq!(items[0].node_type, "taskItem");
7880 let parent_content = items[0].content.as_ref().unwrap();
7881 assert!(
7883 parent_content.iter().any(|n| n.node_type == "paragraph"),
7884 "non-taskList sub-content should stay as child: {parent_content:?}"
7885 );
7886 assert_eq!(items[1].node_type, "taskList");
7888 }
7889
7890 #[test]
7894 fn empty_task_item_mixed_sub_content_none_arm() {
7895 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
7896 let doc = markdown_to_adf(md).unwrap();
7897 let items = doc.content[0].content.as_ref().unwrap();
7898 assert_eq!(items.len(), 2, "got: {items:?}");
7900 assert_eq!(items[0].node_type, "taskItem");
7901 let parent_content = items[0].content.as_ref().unwrap();
7902 assert!(
7903 parent_content.iter().any(|n| n.node_type == "paragraph"),
7904 "paragraph should be assigned to taskItem: {parent_content:?}"
7905 );
7906 assert_eq!(items[1].node_type, "taskList");
7907 }
7908
7909 #[test]
7914 fn task_item_text_with_non_tasklist_sub_content_only() {
7915 let md = "- [ ] My task\n Extra paragraph content\n";
7916 let doc = markdown_to_adf(md).unwrap();
7917 let items = doc.content[0].content.as_ref().unwrap();
7918 assert_eq!(items.len(), 1, "got: {items:?}");
7920 assert_eq!(items[0].node_type, "taskItem");
7921 let content = items[0].content.as_ref().unwrap();
7922 assert!(
7924 content.iter().any(|n| n.node_type == "paragraph"),
7925 "paragraph sub-content should be a child of taskItem: {content:?}"
7926 );
7927 }
7928
7929 #[test]
7932 fn adf_list_item_leading_block_node() {
7933 let json = r#"{
7934 "version": 1,
7935 "type": "doc",
7936 "content": [{
7937 "type": "bulletList",
7938 "content": [{
7939 "type": "listItem",
7940 "content": [{
7941 "type": "codeBlock",
7942 "attrs": {"language": "rust"},
7943 "content": [{"type": "text", "text": "let x = 1;"}]
7944 }]
7945 }]
7946 }]
7947 }"#;
7948 let doc: AdfDocument = serde_json::from_str(json).unwrap();
7949 let md = adf_to_markdown(&doc).unwrap();
7950 assert!(md.contains("```rust"), "got: {md}");
7951 assert!(md.contains("let x = 1;"), "got: {md}");
7952 for line in md.lines() {
7955 if line.starts_with("- ") {
7956 continue; }
7958 if line.trim().is_empty() {
7959 continue;
7960 }
7961 assert!(
7962 line.starts_with(" "),
7963 "continuation line not indented: {line:?}"
7964 );
7965 }
7966 }
7967
7968 #[test]
7971 fn code_block_in_list_item_backtick_roundtrip() {
7972 let json = r#"{
7973 "version": 1,
7974 "type": "doc",
7975 "content": [{
7976 "type": "bulletList",
7977 "content": [{
7978 "type": "listItem",
7979 "content": [{
7980 "type": "codeBlock",
7981 "attrs": {"language": ""},
7982 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
7983 }]
7984 }]
7985 }]
7986 }"#;
7987 let original: AdfDocument = serde_json::from_str(json).unwrap();
7988 let md = adf_to_markdown(&original).unwrap();
7989 let roundtripped = markdown_to_adf(&md).unwrap();
7990 let list = &roundtripped.content[0];
7991 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
7992 let item = &list.content.as_ref().unwrap()[0];
7993 let first_child = &item.content.as_ref().unwrap()[0];
7994 assert_eq!(
7995 first_child.node_type, "codeBlock",
7996 "expected codeBlock, got: {}",
7997 first_child.node_type
7998 );
7999 let text = first_child.content.as_ref().unwrap()[0]
8000 .text
8001 .as_deref()
8002 .unwrap();
8003 assert_eq!(text, "error: some value with a backtick ` at end");
8004 }
8005
8006 #[test]
8008 fn code_block_with_language_in_list_item_roundtrip() {
8009 let json = r#"{
8010 "version": 1,
8011 "type": "doc",
8012 "content": [{
8013 "type": "bulletList",
8014 "content": [{
8015 "type": "listItem",
8016 "content": [{
8017 "type": "codeBlock",
8018 "attrs": {"language": "rust"},
8019 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
8020 }]
8021 }]
8022 }]
8023 }"#;
8024 let original: AdfDocument = serde_json::from_str(json).unwrap();
8025 let md = adf_to_markdown(&original).unwrap();
8026 let roundtripped = markdown_to_adf(&md).unwrap();
8027 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8028 let code = &item.content.as_ref().unwrap()[0];
8029 assert_eq!(code.node_type, "codeBlock");
8030 let lang = code
8031 .attrs
8032 .as_ref()
8033 .and_then(|a| a.get("language"))
8034 .and_then(serde_json::Value::as_str)
8035 .unwrap_or("");
8036 assert_eq!(lang, "rust");
8037 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8038 assert!(text.contains("println!"), "code content: {text}");
8039 }
8040
8041 #[test]
8043 fn code_block_in_ordered_list_item_roundtrip() {
8044 let json = r#"{
8045 "version": 1,
8046 "type": "doc",
8047 "content": [{
8048 "type": "orderedList",
8049 "attrs": {"order": 1},
8050 "content": [{
8051 "type": "listItem",
8052 "content": [{
8053 "type": "codeBlock",
8054 "attrs": {"language": ""},
8055 "content": [{"type": "text", "text": "backtick ` here"}]
8056 }]
8057 }]
8058 }]
8059 }"#;
8060 let original: AdfDocument = serde_json::from_str(json).unwrap();
8061 let md = adf_to_markdown(&original).unwrap();
8062 let roundtripped = markdown_to_adf(&md).unwrap();
8063 let list = &roundtripped.content[0];
8064 assert_eq!(list.node_type, "orderedList");
8065 let item = &list.content.as_ref().unwrap()[0];
8066 let code = &item.content.as_ref().unwrap()[0];
8067 assert_eq!(code.node_type, "codeBlock");
8068 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8069 assert_eq!(text, "backtick ` here");
8070 }
8071
8072 #[test]
8074 fn code_block_then_paragraph_in_list_item() {
8075 let json = r#"{
8076 "version": 1,
8077 "type": "doc",
8078 "content": [{
8079 "type": "bulletList",
8080 "content": [{
8081 "type": "listItem",
8082 "content": [
8083 {
8084 "type": "codeBlock",
8085 "attrs": {"language": ""},
8086 "content": [{"type": "text", "text": "code with ` backtick"}]
8087 },
8088 {
8089 "type": "paragraph",
8090 "content": [{"type": "text", "text": "description"}]
8091 }
8092 ]
8093 }]
8094 }]
8095 }"#;
8096 let original: AdfDocument = serde_json::from_str(json).unwrap();
8097 let md = adf_to_markdown(&original).unwrap();
8098 let roundtripped = markdown_to_adf(&md).unwrap();
8099 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8100 let children = item.content.as_ref().unwrap();
8101 assert_eq!(children[0].node_type, "codeBlock");
8102 assert_eq!(children[1].node_type, "paragraph");
8103 }
8104
8105 #[test]
8107 fn code_block_multiple_backticks_in_list_item() {
8108 let json = r#"{
8109 "version": 1,
8110 "type": "doc",
8111 "content": [{
8112 "type": "bulletList",
8113 "content": [{
8114 "type": "listItem",
8115 "content": [{
8116 "type": "codeBlock",
8117 "attrs": {"language": ""},
8118 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
8119 }]
8120 }]
8121 }]
8122 }"#;
8123 let original: AdfDocument = serde_json::from_str(json).unwrap();
8124 let md = adf_to_markdown(&original).unwrap();
8125 let roundtripped = markdown_to_adf(&md).unwrap();
8126 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8127 let code = &item.content.as_ref().unwrap()[0];
8128 assert_eq!(code.node_type, "codeBlock");
8129 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8130 assert_eq!(text, "a ` b `` c ``` d");
8131 }
8132
8133 #[test]
8136 fn media_first_child_with_sub_content_in_list_item() {
8137 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
8138 {"type":"listItem","content":[
8139 {"type":"mediaSingle","attrs":{"layout":"center"},
8140 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
8141 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
8142 ]}
8143 ]}]}"#;
8144 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8145 let md = adf_to_markdown(&doc).unwrap();
8146 let rt = markdown_to_adf(&md).unwrap();
8147 let item = &rt.content[0].content.as_ref().unwrap()[0];
8148 let children = item.content.as_ref().unwrap();
8149 assert_eq!(
8150 children.len(),
8151 2,
8152 "expected 2 children, got {}",
8153 children.len()
8154 );
8155 assert_eq!(children[0].node_type, "mediaSingle");
8156 let media = &children[0].content.as_ref().unwrap()[0];
8157 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
8158 assert_eq!(children[1].node_type, "paragraph");
8159 }
8160
8161 #[test]
8162 fn inline_bold() {
8163 let doc = markdown_to_adf("Some **bold** text").unwrap();
8164 let content = doc.content[0].content.as_ref().unwrap();
8165 assert!(content.len() >= 3);
8166 let bold_node = &content[1];
8167 assert_eq!(bold_node.text.as_deref(), Some("bold"));
8168 let marks = bold_node.marks.as_ref().unwrap();
8169 assert_eq!(marks[0].mark_type, "strong");
8170 }
8171
8172 #[test]
8173 fn inline_italic() {
8174 let doc = markdown_to_adf("Some *italic* text").unwrap();
8175 let content = doc.content[0].content.as_ref().unwrap();
8176 let italic_node = &content[1];
8177 assert_eq!(italic_node.text.as_deref(), Some("italic"));
8178 let marks = italic_node.marks.as_ref().unwrap();
8179 assert_eq!(marks[0].mark_type, "em");
8180 }
8181
8182 #[test]
8183 fn inline_code() {
8184 let doc = markdown_to_adf("Use `code` here").unwrap();
8185 let content = doc.content[0].content.as_ref().unwrap();
8186 let code_node = &content[1];
8187 assert_eq!(code_node.text.as_deref(), Some("code"));
8188 let marks = code_node.marks.as_ref().unwrap();
8189 assert_eq!(marks[0].mark_type, "code");
8190 }
8191
8192 #[test]
8196 fn inline_code_with_backtick_emitted_with_double_delimiters() {
8197 let doc = AdfDocument {
8198 version: 1,
8199 doc_type: "doc".to_string(),
8200 content: vec![AdfNode::paragraph(vec![
8201 AdfNode::text("Run "),
8202 AdfNode::text_with_marks(
8203 "ADD `custom_threshold` TEXT NOT NULL",
8204 vec![AdfMark::code()],
8205 ),
8206 AdfNode::text(" to update the schema."),
8207 ])],
8208 };
8209 let md = adf_to_markdown(&doc).unwrap();
8210 assert!(
8211 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
8212 "expected double-backtick delimiters, got: {md}"
8213 );
8214 }
8215
8216 #[test]
8219 fn inline_code_double_backtick_delimiters_parse() {
8220 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
8221 let content = doc.content[0].content.as_ref().unwrap();
8222 assert_eq!(content.len(), 3, "content: {content:?}");
8223 let code_node = &content[1];
8224 assert_eq!(
8225 code_node.text.as_deref(),
8226 Some("ADD `custom_threshold` TEXT NOT NULL")
8227 );
8228 let marks = code_node.marks.as_ref().unwrap();
8229 assert_eq!(marks[0].mark_type, "code");
8230 }
8231
8232 #[test]
8235 fn inline_code_with_backtick_roundtrip() {
8236 let json = r#"{
8237 "version": 1,
8238 "type": "doc",
8239 "content": [{
8240 "type": "paragraph",
8241 "content": [
8242 {"type": "text", "text": "Run "},
8243 {
8244 "type": "text",
8245 "text": "ADD `custom_threshold` TEXT NOT NULL",
8246 "marks": [{"type": "code"}]
8247 },
8248 {"type": "text", "text": " to update the schema."}
8249 ]
8250 }]
8251 }"#;
8252 let original: AdfDocument = serde_json::from_str(json).unwrap();
8253 let md = adf_to_markdown(&original).unwrap();
8254 let roundtripped = markdown_to_adf(&md).unwrap();
8255 let para = &roundtripped.content[0];
8256 let children = para.content.as_ref().unwrap();
8257 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
8258 assert_eq!(children[0].text.as_deref(), Some("Run "));
8259 assert_eq!(
8260 children[1].text.as_deref(),
8261 Some("ADD `custom_threshold` TEXT NOT NULL")
8262 );
8263 let marks = children[1].marks.as_ref().unwrap();
8264 assert_eq!(marks.len(), 1);
8265 assert_eq!(marks[0].mark_type, "code");
8266 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
8267 }
8268
8269 #[test]
8274 fn inline_code_with_double_backtick_roundtrip() {
8275 let doc = AdfDocument {
8276 version: 1,
8277 doc_type: "doc".to_string(),
8278 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8279 "x `` y",
8280 vec![AdfMark::code()],
8281 )])],
8282 };
8283 let md = adf_to_markdown(&doc).unwrap();
8284 let roundtripped = markdown_to_adf(&md).unwrap();
8285 let content = roundtripped.content[0].content.as_ref().unwrap();
8286 assert_eq!(content.len(), 1);
8287 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
8288 let marks = content[0].marks.as_ref().unwrap();
8289 assert_eq!(marks[0].mark_type, "code");
8290 }
8291
8292 #[test]
8295 fn inline_code_leading_backtick_roundtrip() {
8296 let doc = AdfDocument {
8297 version: 1,
8298 doc_type: "doc".to_string(),
8299 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8300 "`start",
8301 vec![AdfMark::code()],
8302 )])],
8303 };
8304 let md = adf_to_markdown(&doc).unwrap();
8305 let roundtripped = markdown_to_adf(&md).unwrap();
8306 let content = roundtripped.content[0].content.as_ref().unwrap();
8307 assert_eq!(content[0].text.as_deref(), Some("`start"));
8308 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
8309 }
8310
8311 #[test]
8313 fn inline_code_trailing_backtick_roundtrip() {
8314 let doc = AdfDocument {
8315 version: 1,
8316 doc_type: "doc".to_string(),
8317 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8318 "end`",
8319 vec![AdfMark::code()],
8320 )])],
8321 };
8322 let md = adf_to_markdown(&doc).unwrap();
8323 let roundtripped = markdown_to_adf(&md).unwrap();
8324 let content = roundtripped.content[0].content.as_ref().unwrap();
8325 assert_eq!(content[0].text.as_deref(), Some("end`"));
8326 }
8327
8328 #[test]
8331 fn inline_code_space_padded_content_roundtrip() {
8332 let doc = AdfDocument {
8333 version: 1,
8334 doc_type: "doc".to_string(),
8335 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8336 " foo ",
8337 vec![AdfMark::code()],
8338 )])],
8339 };
8340 let md = adf_to_markdown(&doc).unwrap();
8341 let roundtripped = markdown_to_adf(&md).unwrap();
8342 let content = roundtripped.content[0].content.as_ref().unwrap();
8343 assert_eq!(content[0].text.as_deref(), Some(" foo "));
8344 }
8345
8346 #[test]
8349 fn inline_code_all_spaces_roundtrip() {
8350 let doc = AdfDocument {
8351 version: 1,
8352 doc_type: "doc".to_string(),
8353 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8354 " ",
8355 vec![AdfMark::code()],
8356 )])],
8357 };
8358 let md = adf_to_markdown(&doc).unwrap();
8359 let roundtripped = markdown_to_adf(&md).unwrap();
8360 let content = roundtripped.content[0].content.as_ref().unwrap();
8361 assert_eq!(content[0].text.as_deref(), Some(" "));
8362 }
8363
8364 #[test]
8367 fn inline_code_with_link_and_backtick_roundtrip() {
8368 let doc = AdfDocument {
8369 version: 1,
8370 doc_type: "doc".to_string(),
8371 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8372 "fn `inner`",
8373 vec![AdfMark::code(), AdfMark::link("https://example.com")],
8374 )])],
8375 };
8376 let md = adf_to_markdown(&doc).unwrap();
8377 assert!(
8378 md.contains("`` fn `inner` ``"),
8379 "expected padded double-backtick delimiters inside link, got: {md}"
8380 );
8381 let roundtripped = markdown_to_adf(&md).unwrap();
8382 let content = roundtripped.content[0].content.as_ref().unwrap();
8383 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
8384 let mark_types: Vec<&str> = content[0]
8385 .marks
8386 .as_ref()
8387 .unwrap()
8388 .iter()
8389 .map(|m| m.mark_type.as_str())
8390 .collect();
8391 assert!(mark_types.contains(&"code"));
8392 assert!(mark_types.contains(&"link"));
8393 }
8394
8395 #[test]
8397 fn inline_code_unmatched_run_is_plain_text() {
8398 let doc = markdown_to_adf("foo ``bar baz").unwrap();
8399 let content = doc.content[0].content.as_ref().unwrap();
8400 assert_eq!(content.len(), 1);
8401 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
8402 assert!(content[0].marks.is_none());
8403 }
8404
8405 #[test]
8409 fn inline_code_mismatched_delimiters_is_plain_text() {
8410 let doc = markdown_to_adf("``foo` bar").unwrap();
8411 let content = doc.content[0].content.as_ref().unwrap();
8412 assert_eq!(content.len(), 1);
8413 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
8414 assert!(content[0].marks.is_none());
8415 }
8416
8417 #[test]
8418 fn inline_code_delimiter_chooses_correct_length() {
8419 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
8420 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
8421 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
8422 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
8423 assert_eq!(inline_code_delimiter("`leading"), (2, true));
8424 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
8425 assert_eq!(inline_code_delimiter(" foo "), (1, true));
8426 assert_eq!(inline_code_delimiter(" "), (1, false));
8427 assert_eq!(inline_code_delimiter(" "), (1, false));
8428 assert_eq!(inline_code_delimiter(" foo"), (1, false));
8429 }
8430
8431 #[test]
8432 fn try_parse_inline_code_strips_paired_spaces() {
8433 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
8434 assert_eq!(end, 11);
8435 assert_eq!(content, "`foo`");
8436 }
8437
8438 #[test]
8439 fn try_parse_inline_code_all_space_content_is_preserved() {
8440 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
8441 assert_eq!(content, " ");
8442 }
8443
8444 #[test]
8445 fn try_parse_inline_code_single_run_matches_first_close() {
8446 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
8447 assert_eq!(end, 5);
8448 assert_eq!(content, "foo");
8449 }
8450
8451 #[test]
8452 fn try_parse_inline_code_no_match_returns_none() {
8453 assert!(try_parse_inline_code("``unmatched", 0).is_none());
8454 assert!(try_parse_inline_code("plain text", 0).is_none());
8455 }
8456
8457 #[test]
8458 fn is_code_fence_opener_rejects_info_with_backtick() {
8459 assert!(is_code_fence_opener("```"));
8460 assert!(is_code_fence_opener("```rust"));
8461 assert!(is_code_fence_opener("```\"\""));
8462 assert!(!is_code_fence_opener("```x `` y```"));
8463 assert!(!is_code_fence_opener("``not-enough"));
8464 assert!(!is_code_fence_opener("no fence"));
8465 }
8466
8467 #[test]
8468 fn inline_strikethrough() {
8469 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
8470 let content = doc.content[0].content.as_ref().unwrap();
8471 let strike_node = &content[1];
8472 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
8473 let marks = strike_node.marks.as_ref().unwrap();
8474 assert_eq!(marks[0].mark_type, "strike");
8475 }
8476
8477 #[test]
8478 fn inline_link() {
8479 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
8480 let content = doc.content[0].content.as_ref().unwrap();
8481 let link_node = &content[1];
8482 assert_eq!(link_node.text.as_deref(), Some("here"));
8483 let marks = link_node.marks.as_ref().unwrap();
8484 assert_eq!(marks[0].mark_type, "link");
8485 }
8486
8487 #[test]
8488 fn block_image() {
8489 let md = "";
8490 let doc = markdown_to_adf(md).unwrap();
8491 assert_eq!(doc.content[0].node_type, "mediaSingle");
8492 }
8493
8494 #[test]
8495 fn table() {
8496 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
8497 let doc = markdown_to_adf(md).unwrap();
8498 assert_eq!(doc.content[0].node_type, "table");
8499 let rows = doc.content[0].content.as_ref().unwrap();
8500 assert_eq!(rows.len(), 2); }
8502
8503 #[test]
8506 fn adf_paragraph_to_markdown() {
8507 let doc = AdfDocument {
8508 version: 1,
8509 doc_type: "doc".to_string(),
8510 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
8511 };
8512 let md = adf_to_markdown(&doc).unwrap();
8513 assert_eq!(md.trim(), "Hello world");
8514 }
8515
8516 #[test]
8517 fn adf_heading_to_markdown() {
8518 let doc = AdfDocument {
8519 version: 1,
8520 doc_type: "doc".to_string(),
8521 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
8522 };
8523 let md = adf_to_markdown(&doc).unwrap();
8524 assert_eq!(md.trim(), "## Title");
8525 }
8526
8527 #[test]
8528 fn adf_bold_to_markdown() {
8529 let doc = AdfDocument {
8530 version: 1,
8531 doc_type: "doc".to_string(),
8532 content: vec![AdfNode::paragraph(vec![
8533 AdfNode::text("Normal "),
8534 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
8535 AdfNode::text(" text"),
8536 ])],
8537 };
8538 let md = adf_to_markdown(&doc).unwrap();
8539 assert_eq!(md.trim(), "Normal **bold** text");
8540 }
8541
8542 #[test]
8543 fn adf_code_block_to_markdown() {
8544 let doc = AdfDocument {
8545 version: 1,
8546 doc_type: "doc".to_string(),
8547 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
8548 };
8549 let md = adf_to_markdown(&doc).unwrap();
8550 assert!(md.contains("```rust"));
8551 assert!(md.contains("let x = 1;"));
8552 assert!(md.contains("```"));
8553 }
8554
8555 #[test]
8556 fn adf_rule_to_markdown() {
8557 let doc = AdfDocument {
8558 version: 1,
8559 doc_type: "doc".to_string(),
8560 content: vec![AdfNode::rule()],
8561 };
8562 let md = adf_to_markdown(&doc).unwrap();
8563 assert!(md.contains("---"));
8564 }
8565
8566 #[test]
8567 fn adf_bullet_list_to_markdown() {
8568 let doc = AdfDocument {
8569 version: 1,
8570 doc_type: "doc".to_string(),
8571 content: vec![AdfNode::bullet_list(vec![
8572 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
8573 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
8574 ])],
8575 };
8576 let md = adf_to_markdown(&doc).unwrap();
8577 assert!(md.contains("- A"));
8578 assert!(md.contains("- B"));
8579 }
8580
8581 #[test]
8582 fn adf_link_to_markdown() {
8583 let doc = AdfDocument {
8584 version: 1,
8585 doc_type: "doc".to_string(),
8586 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8587 "click",
8588 vec![AdfMark::link("https://example.com")],
8589 )])],
8590 };
8591 let md = adf_to_markdown(&doc).unwrap();
8592 assert_eq!(md.trim(), "[click](https://example.com)");
8593 }
8594
8595 #[test]
8596 fn unsupported_block_preserved_as_json() {
8597 let doc = AdfDocument {
8598 version: 1,
8599 doc_type: "doc".to_string(),
8600 content: vec![AdfNode {
8601 node_type: "unknownBlock".to_string(),
8602 attrs: Some(serde_json::json!({"key": "value"})),
8603 content: None,
8604 text: None,
8605 marks: None,
8606 local_id: None,
8607 parameters: None,
8608 }],
8609 };
8610 let md = adf_to_markdown(&doc).unwrap();
8611 assert!(md.contains("```adf-unsupported"));
8612 assert!(md.contains("\"unknownBlock\""));
8613 }
8614
8615 #[test]
8616 fn unsupported_block_round_trips() {
8617 let original = AdfDocument {
8618 version: 1,
8619 doc_type: "doc".to_string(),
8620 content: vec![AdfNode {
8621 node_type: "unknownBlock".to_string(),
8622 attrs: Some(serde_json::json!({"key": "value"})),
8623 content: None,
8624 text: None,
8625 marks: None,
8626 local_id: None,
8627 parameters: None,
8628 }],
8629 };
8630 let md = adf_to_markdown(&original).unwrap();
8631 let restored = markdown_to_adf(&md).unwrap();
8632 assert_eq!(restored.content[0].node_type, "unknownBlock");
8633 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
8634 }
8635
8636 #[test]
8639 fn round_trip_simple_document() {
8640 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
8641 let adf = markdown_to_adf(md).unwrap();
8642 let restored = adf_to_markdown(&adf).unwrap();
8643
8644 assert!(restored.contains("# Hello"));
8645 assert!(restored.contains("**bold**"));
8646 assert!(restored.contains("*italic*"));
8647 assert!(restored.contains("- Item 1"));
8648 assert!(restored.contains("- Item 2"));
8649 }
8650
8651 #[test]
8652 fn round_trip_code_block() {
8653 let md = "```python\nprint('hello')\n```\n";
8654 let adf = markdown_to_adf(md).unwrap();
8655 let restored = adf_to_markdown(&adf).unwrap();
8656
8657 assert!(restored.contains("```python"));
8658 assert!(restored.contains("print('hello')"));
8659 }
8660
8661 #[test]
8662 fn round_trip_code_block_no_attrs() {
8663 let adf_json = r#"{"version":1,"type":"doc","content":[
8664 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
8665 ]}"#;
8666 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8667 assert!(doc.content[0].attrs.is_none());
8668 let md = adf_to_markdown(&doc).unwrap();
8669 let round_tripped = markdown_to_adf(&md).unwrap();
8670 assert!(round_tripped.content[0].attrs.is_none());
8671 }
8672
8673 #[test]
8674 fn round_trip_code_block_empty_language() {
8675 let adf_json = r#"{"version":1,"type":"doc","content":[
8676 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
8677 ]}"#;
8678 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8679 let attrs = doc.content[0].attrs.as_ref().unwrap();
8680 assert_eq!(attrs["language"], "");
8681 let md = adf_to_markdown(&doc).unwrap();
8682 let round_tripped = markdown_to_adf(&md).unwrap();
8683 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
8684 assert_eq!(rt_attrs["language"], "");
8685 }
8686
8687 #[test]
8688 fn round_trip_code_block_with_language() {
8689 let adf_json = r#"{"version":1,"type":"doc","content":[
8690 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
8691 ]}"#;
8692 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8693 let md = adf_to_markdown(&doc).unwrap();
8694 let round_tripped = markdown_to_adf(&md).unwrap();
8695 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
8696 assert_eq!(rt_attrs["language"], "python");
8697 }
8698
8699 #[test]
8700 fn multiple_paragraphs() {
8701 let md = "First paragraph.\n\nSecond paragraph.\n";
8702 let adf = markdown_to_adf(md).unwrap();
8703 assert_eq!(adf.content.len(), 2);
8704 assert_eq!(adf.content[0].node_type, "paragraph");
8705 assert_eq!(adf.content[1].node_type, "paragraph");
8706 }
8707
8708 #[test]
8711 fn horizontal_rule_underscores() {
8712 let doc = markdown_to_adf("___").unwrap();
8713 assert_eq!(doc.content[0].node_type, "rule");
8714 }
8715
8716 #[test]
8717 fn not_a_horizontal_rule_too_short() {
8718 let doc = markdown_to_adf("--").unwrap();
8719 assert_eq!(doc.content[0].node_type, "paragraph");
8720 }
8721
8722 #[test]
8723 fn bullet_list_star_marker() {
8724 let md = "* Apple\n* Banana";
8725 let doc = markdown_to_adf(md).unwrap();
8726 assert_eq!(doc.content[0].node_type, "bulletList");
8727 let items = doc.content[0].content.as_ref().unwrap();
8728 assert_eq!(items.len(), 2);
8729 }
8730
8731 #[test]
8732 fn bullet_list_plus_marker() {
8733 let md = "+ One\n+ Two";
8734 let doc = markdown_to_adf(md).unwrap();
8735 assert_eq!(doc.content[0].node_type, "bulletList");
8736 }
8737
8738 #[test]
8739 fn ordered_list_non_one_start() {
8740 let md = "5. Fifth\n6. Sixth";
8741 let doc = markdown_to_adf(md).unwrap();
8742 let node = &doc.content[0];
8743 assert_eq!(node.node_type, "orderedList");
8744 let attrs = node.attrs.as_ref().unwrap();
8745 assert_eq!(attrs["order"], 5);
8746 }
8747
8748 #[test]
8749 fn ordered_list_start_at_one_omits_order_attr() {
8750 let md = "1. First\n2. Second";
8754 let doc = markdown_to_adf(md).unwrap();
8755 let node = &doc.content[0];
8756 assert_eq!(node.node_type, "orderedList");
8757 assert!(
8758 node.attrs.is_none(),
8759 "attrs should be omitted when order=1, got: {:?}",
8760 node.attrs
8761 );
8762 }
8763
8764 #[test]
8765 fn blockquote_bare_marker() {
8766 let md = ">quoted text";
8768 let doc = markdown_to_adf(md).unwrap();
8769 assert_eq!(doc.content[0].node_type, "blockquote");
8770 }
8771
8772 #[test]
8773 fn image_no_alt() {
8774 let md = "";
8775 let doc = markdown_to_adf(md).unwrap();
8776 let node = &doc.content[0];
8777 assert_eq!(node.node_type, "mediaSingle");
8778 let media = &node.content.as_ref().unwrap()[0];
8780 let attrs = media.attrs.as_ref().unwrap();
8781 assert!(attrs.get("alt").is_none());
8782 }
8783
8784 #[test]
8785 fn image_with_alt() {
8786 let md = "";
8787 let doc = markdown_to_adf(md).unwrap();
8788 let media = &doc.content[0].content.as_ref().unwrap()[0];
8789 let attrs = media.attrs.as_ref().unwrap();
8790 assert_eq!(attrs["alt"], "A photo");
8791 }
8792
8793 #[test]
8794 fn table_multi_body_rows() {
8795 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
8796 let doc = markdown_to_adf(md).unwrap();
8797 let rows = doc.content[0].content.as_ref().unwrap();
8798 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
8801 assert_eq!(header_cells[0].node_type, "tableHeader");
8802 let body_cells = rows[1].content.as_ref().unwrap();
8804 assert_eq!(body_cells[0].node_type, "tableCell");
8805 }
8806
8807 #[test]
8808 fn table_no_separator_is_not_table() {
8809 let md = "| not | a table |";
8811 let doc = markdown_to_adf(md).unwrap();
8812 assert_eq!(doc.content[0].node_type, "paragraph");
8813 }
8814
8815 #[test]
8816 fn inline_underscore_bold() {
8817 let doc = markdown_to_adf("Some __bold__ text").unwrap();
8818 let content = doc.content[0].content.as_ref().unwrap();
8819 let bold_node = &content[1];
8820 assert_eq!(bold_node.text.as_deref(), Some("bold"));
8821 let marks = bold_node.marks.as_ref().unwrap();
8822 assert_eq!(marks[0].mark_type, "strong");
8823 }
8824
8825 #[test]
8826 fn inline_underscore_italic() {
8827 let doc = markdown_to_adf("Some _italic_ text").unwrap();
8828 let content = doc.content[0].content.as_ref().unwrap();
8829 let italic_node = &content[1];
8830 assert_eq!(italic_node.text.as_deref(), Some("italic"));
8831 let marks = italic_node.marks.as_ref().unwrap();
8832 assert_eq!(marks[0].mark_type, "em");
8833 }
8834
8835 #[test]
8836 fn intraword_underscore_not_emphasis() {
8837 let doc = markdown_to_adf("call do_something_useful now").unwrap();
8839 let content = doc.content[0].content.as_ref().unwrap();
8840 assert_eq!(content.len(), 1, "should be a single text node");
8841 assert_eq!(
8842 content[0].text.as_deref(),
8843 Some("call do_something_useful now")
8844 );
8845 assert!(content[0].marks.is_none());
8846 }
8847
8848 #[test]
8849 fn intraword_underscore_multiple() {
8850 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
8852 let content = doc.content[0].content.as_ref().unwrap();
8853 assert_eq!(content.len(), 1);
8854 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
8855 assert!(content[0].marks.is_none());
8856 }
8857
8858 #[test]
8859 fn intraword_double_underscore_not_bold() {
8860 let doc = markdown_to_adf("foo__bar__baz").unwrap();
8862 let content = doc.content[0].content.as_ref().unwrap();
8863 assert_eq!(content.len(), 1);
8864 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
8865 assert!(content[0].marks.is_none());
8866 }
8867
8868 #[test]
8869 fn intraword_triple_underscore_not_bold_italic() {
8870 let doc = markdown_to_adf("x___y___z").unwrap();
8872 let content = doc.content[0].content.as_ref().unwrap();
8873 assert_eq!(content.len(), 1);
8874 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
8875 assert!(content[0].marks.is_none());
8876 }
8877
8878 #[test]
8879 fn underscore_emphasis_still_works_with_spaces() {
8880 let doc = markdown_to_adf("some _italic_ here").unwrap();
8882 let content = doc.content[0].content.as_ref().unwrap();
8883 assert_eq!(content.len(), 3);
8884 assert_eq!(content[1].text.as_deref(), Some("italic"));
8885 let marks = content[1].marks.as_ref().unwrap();
8886 assert_eq!(marks[0].mark_type, "em");
8887 }
8888
8889 #[test]
8890 fn underscore_bold_still_works_with_spaces() {
8891 let doc = markdown_to_adf("some __bold__ here").unwrap();
8893 let content = doc.content[0].content.as_ref().unwrap();
8894 assert_eq!(content.len(), 3);
8895 assert_eq!(content[1].text.as_deref(), Some("bold"));
8896 let marks = content[1].marks.as_ref().unwrap();
8897 assert_eq!(marks[0].mark_type, "strong");
8898 }
8899
8900 #[test]
8901 fn intraword_underscore_closing_only() {
8902 let doc = markdown_to_adf("_foo_bar").unwrap();
8904 let content = doc.content[0].content.as_ref().unwrap();
8905 assert_eq!(content.len(), 1);
8906 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
8907 }
8908
8909 #[test]
8910 fn intraword_double_underscore_closing_only() {
8911 let doc = markdown_to_adf("__foo__bar").unwrap();
8913 let content = doc.content[0].content.as_ref().unwrap();
8914 assert_eq!(content.len(), 1);
8915 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
8916 }
8917
8918 #[test]
8919 fn intraword_triple_underscore_closing_only() {
8920 let doc = markdown_to_adf("___foo___bar").unwrap();
8922 let content = doc.content[0].content.as_ref().unwrap();
8923 assert_eq!(content.len(), 1);
8924 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
8925 }
8926
8927 #[test]
8928 fn asterisk_emphasis_unaffected_by_intraword_fix() {
8929 let doc = markdown_to_adf("foo*bar*baz").unwrap();
8931 let content = doc.content[0].content.as_ref().unwrap();
8932 assert!(content.len() > 1 || content[0].marks.is_some());
8934 }
8935
8936 #[test]
8937 fn intraword_underscore_at_start_of_text() {
8938 let doc = markdown_to_adf("_italic_ word").unwrap();
8940 let content = doc.content[0].content.as_ref().unwrap();
8941 assert_eq!(content[0].text.as_deref(), Some("italic"));
8942 let marks = content[0].marks.as_ref().unwrap();
8943 assert_eq!(marks[0].mark_type, "em");
8944 }
8945
8946 #[test]
8947 fn intraword_underscore_at_end_of_text() {
8948 let doc = markdown_to_adf("word _italic_").unwrap();
8950 let content = doc.content[0].content.as_ref().unwrap();
8951 let last = content.last().unwrap();
8952 assert_eq!(last.text.as_deref(), Some("italic"));
8953 let marks = last.marks.as_ref().unwrap();
8954 assert_eq!(marks[0].mark_type, "em");
8955 }
8956
8957 #[test]
8958 fn intraword_underscore_opening_only() {
8959 let doc = markdown_to_adf("a_b c_d").unwrap();
8962 let content = doc.content[0].content.as_ref().unwrap();
8963 assert_eq!(content.len(), 1);
8964 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
8965 }
8966
8967 #[test]
8968 fn intraword_underscore_roundtrip() {
8969 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
8971 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8972 let jfm = adf_to_markdown(&adf).unwrap();
8973 let roundtripped = markdown_to_adf(&jfm).unwrap();
8974 let content = roundtripped.content[0].content.as_ref().unwrap();
8975 assert_eq!(content.len(), 1, "should round-trip as a single text node");
8976 assert_eq!(
8977 content[0].text.as_deref(),
8978 Some("call the do_something_useful function")
8979 );
8980 assert!(content[0].marks.is_none());
8981 }
8982
8983 #[test]
8984 fn asterisk_emphasis_roundtrip() {
8985 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
8987 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8988 let jfm = adf_to_markdown(&adf).unwrap();
8989 let roundtripped = markdown_to_adf(&jfm).unwrap();
8990 let content = roundtripped.content[0].content.as_ref().unwrap();
8991 assert_eq!(content.len(), 1, "should round-trip as a single text node");
8992 assert_eq!(
8993 content[0].text.as_deref(),
8994 Some("Status: *confirmed* and active")
8995 );
8996 assert!(content[0].marks.is_none());
8997 }
8998
8999 #[test]
9000 fn double_asterisk_roundtrip() {
9001 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
9003 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9004 let jfm = adf_to_markdown(&adf).unwrap();
9005 let roundtripped = markdown_to_adf(&jfm).unwrap();
9006 let content = roundtripped.content[0].content.as_ref().unwrap();
9007 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9008 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
9009 assert!(content[0].marks.is_none());
9010 }
9011
9012 #[test]
9013 fn asterisk_with_em_mark_roundtrip() {
9014 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
9016 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9017 let jfm = adf_to_markdown(&adf).unwrap();
9018 let roundtripped = markdown_to_adf(&jfm).unwrap();
9019 let content = roundtripped.content[0].content.as_ref().unwrap();
9020 let em_node = content.iter().find(|n| {
9022 n.marks
9023 .as_ref()
9024 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
9025 });
9026 assert!(em_node.is_some(), "should have an em-marked node");
9027 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
9028 }
9029
9030 #[test]
9031 fn lone_asterisk_roundtrip() {
9032 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
9034 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9035 let jfm = adf_to_markdown(&adf).unwrap();
9036 let roundtripped = markdown_to_adf(&jfm).unwrap();
9037 let content = roundtripped.content[0].content.as_ref().unwrap();
9038 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9039 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
9040 }
9041
9042 #[test]
9043 fn escape_emphasis_markers_unit() {
9044 assert_eq!(escape_emphasis_markers("hello"), "hello");
9045 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
9046 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
9047 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
9048 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
9049 assert_eq!(escape_emphasis_markers(""), "");
9050 }
9051
9052 #[test]
9053 fn escape_emphasis_markers_underscore_intraword() {
9054 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
9057 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
9058 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
9059 assert_eq!(
9060 escape_emphasis_markers("call do_something_useful"),
9061 "call do_something_useful"
9062 );
9063 }
9064
9065 #[test]
9066 fn escape_emphasis_markers_underscore_at_boundary() {
9067 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
9070 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
9071 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
9072 assert_eq!(escape_emphasis_markers(" _"), r" \_");
9073 assert_eq!(escape_emphasis_markers("_"), r"\_");
9074 }
9075
9076 #[test]
9077 fn escape_emphasis_markers_underscore_with_punctuation() {
9078 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
9080 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
9081 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
9082 }
9083
9084 #[test]
9085 fn find_unescaped_skips_backslash_escaped() {
9086 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
9088 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
9090 assert_eq!(find_unescaped("a**b", "**"), Some(1));
9092 assert_eq!(find_unescaped("", "**"), None);
9094 }
9095
9096 #[test]
9097 fn find_unescaped_char_skips_backslash_escaped() {
9098 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
9100 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
9102 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
9104 assert_eq!(find_unescaped_char("", b'*'), None);
9106 }
9107
9108 #[test]
9109 fn double_asterisk_in_strong_mark_roundtrip() {
9110 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
9112 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9113 let jfm = adf_to_markdown(&adf).unwrap();
9114 let roundtripped = markdown_to_adf(&jfm).unwrap();
9115 let content = roundtripped.content[0].content.as_ref().unwrap();
9116 let strong_node = content.iter().find(|n| {
9117 n.marks
9118 .as_ref()
9119 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
9120 });
9121 assert!(strong_node.is_some(), "should have a strong-marked node");
9122 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
9123 }
9124
9125 #[test]
9126 fn backtick_code_roundtrip() {
9127 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
9129 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9130 let jfm = adf_to_markdown(&adf).unwrap();
9131 let roundtripped = markdown_to_adf(&jfm).unwrap();
9132 let content = roundtripped.content[0].content.as_ref().unwrap();
9133 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9134 assert_eq!(
9135 content[0].text.as_deref(),
9136 Some("Set `max_retries` to 3 in the config")
9137 );
9138 assert!(content[0].marks.is_none());
9139 }
9140
9141 #[test]
9142 fn multiple_backtick_spans_roundtrip() {
9143 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
9145 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9146 let jfm = adf_to_markdown(&adf).unwrap();
9147 let roundtripped = markdown_to_adf(&jfm).unwrap();
9148 let content = roundtripped.content[0].content.as_ref().unwrap();
9149 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9150 assert_eq!(
9151 content[0].text.as_deref(),
9152 Some("Use `foo` and `bar` together")
9153 );
9154 assert!(content[0].marks.is_none());
9155 }
9156
9157 #[test]
9158 fn lone_backtick_roundtrip() {
9159 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
9161 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9162 let jfm = adf_to_markdown(&adf).unwrap();
9163 let roundtripped = markdown_to_adf(&jfm).unwrap();
9164 let content = roundtripped.content[0].content.as_ref().unwrap();
9165 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9166 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
9167 assert!(content[0].marks.is_none());
9168 }
9169
9170 #[test]
9171 fn backtick_with_code_mark_roundtrip() {
9172 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
9174 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9175 let jfm = adf_to_markdown(&adf).unwrap();
9176 assert_eq!(jfm.trim(), "`max_retries`");
9177 let roundtripped = markdown_to_adf(&jfm).unwrap();
9178 let content = roundtripped.content[0].content.as_ref().unwrap();
9179 let code_node = content.iter().find(|n| {
9180 n.marks
9181 .as_ref()
9182 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
9183 });
9184 assert!(code_node.is_some(), "should have a code-marked node");
9185 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
9186 }
9187
9188 #[test]
9189 fn backtick_with_em_mark_roundtrip() {
9190 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
9192 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9193 let jfm = adf_to_markdown(&adf).unwrap();
9194 let roundtripped = markdown_to_adf(&jfm).unwrap();
9195 let content = roundtripped.content[0].content.as_ref().unwrap();
9196 let em_node = content.iter().find(|n| {
9197 n.marks
9198 .as_ref()
9199 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
9200 });
9201 assert!(em_node.is_some(), "should have an em-marked node");
9202 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
9203 }
9204
9205 #[test]
9206 fn escape_pipes_in_cell_unit() {
9207 assert_eq!(escape_pipes_in_cell("hello"), "hello");
9208 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
9209 assert_eq!(escape_pipes_in_cell("|"), r"\|");
9210 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
9211 assert_eq!(escape_pipes_in_cell(""), "");
9212 assert_eq!(
9213 escape_pipes_in_cell("`parser.decode[T|json]`"),
9214 r"`parser.decode[T\|json]`"
9215 );
9216 }
9217
9218 #[test]
9219 fn escape_backticks_unit() {
9220 assert_eq!(escape_backticks("hello"), "hello");
9221 assert_eq!(escape_backticks("`code`"), r"\`code\`");
9222 assert_eq!(escape_backticks("no ticks"), "no ticks");
9223 assert_eq!(escape_backticks("a ` b"), r"a \` b");
9224 assert_eq!(escape_backticks(""), "");
9225 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
9226 }
9227
9228 #[test]
9231 fn backslash_in_text_roundtrip() {
9232 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
9234 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9235 let jfm = adf_to_markdown(&adf).unwrap();
9236 let roundtripped = markdown_to_adf(&jfm).unwrap();
9237 let content = roundtripped.content[0].content.as_ref().unwrap();
9238 assert_eq!(content.len(), 1, "should round-trip as a single text node");
9239 assert_eq!(
9240 content[0].text.as_deref(),
9241 Some(r"The path is C:\Users\admin\file.txt")
9242 );
9243 }
9244
9245 #[test]
9246 fn backslash_emitted_as_double_backslash() {
9247 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
9248 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9249 let jfm = adf_to_markdown(&adf).unwrap();
9250 assert!(
9251 jfm.contains(r"a\\b"),
9252 "JFM should contain escaped backslash: {jfm}"
9253 );
9254 }
9255
9256 #[test]
9257 fn consecutive_backslashes_roundtrip() {
9258 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
9259 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9260 let jfm = adf_to_markdown(&adf).unwrap();
9261 let roundtripped = markdown_to_adf(&jfm).unwrap();
9262 let content = roundtripped.content[0].content.as_ref().unwrap();
9263 assert_eq!(
9264 content[0].text.as_deref(),
9265 Some(r"a\\b"),
9266 "consecutive backslashes should survive round-trip"
9267 );
9268 }
9269
9270 #[test]
9271 fn backslash_with_strong_mark_roundtrip() {
9272 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
9273 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9274 let jfm = adf_to_markdown(&adf).unwrap();
9275 let roundtripped = markdown_to_adf(&jfm).unwrap();
9276 let content = roundtripped.content[0].content.as_ref().unwrap();
9277 let strong_node = content.iter().find(|n| {
9278 n.marks
9279 .as_ref()
9280 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
9281 });
9282 assert!(strong_node.is_some(), "should have a strong-marked node");
9283 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
9284 }
9285
9286 #[test]
9287 fn backslash_with_code_mark_not_escaped() {
9288 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
9290 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9291 let jfm = adf_to_markdown(&adf).unwrap();
9292 assert_eq!(jfm.trim(), r"`C:\Users`");
9293 let roundtripped = markdown_to_adf(&jfm).unwrap();
9294 let content = roundtripped.content[0].content.as_ref().unwrap();
9295 let code_node = content.iter().find(|n| {
9296 n.marks
9297 .as_ref()
9298 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
9299 });
9300 assert!(code_node.is_some(), "should have a code-marked node");
9301 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
9302 }
9303
9304 #[test]
9305 fn backslash_before_special_chars_roundtrip() {
9306 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
9308 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9309 let jfm = adf_to_markdown(&adf).unwrap();
9310 let roundtripped = markdown_to_adf(&jfm).unwrap();
9311 let content = roundtripped.content[0].content.as_ref().unwrap();
9312 assert_eq!(
9313 content[0].text.as_deref(),
9314 Some(r"\*not bold\*"),
9315 "backslash before special char should survive round-trip"
9316 );
9317 }
9318
9319 #[test]
9320 fn backslash_and_newline_in_text_roundtrip() {
9321 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
9323 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9324 let jfm = adf_to_markdown(&adf).unwrap();
9325 let roundtripped = markdown_to_adf(&jfm).unwrap();
9326 let content = roundtripped.content[0].content.as_ref().unwrap();
9327 assert_eq!(
9328 content[0].text.as_deref(),
9329 Some("C:\\path\nline2"),
9330 "backslash and newline should both survive round-trip"
9331 );
9332 }
9333
9334 #[test]
9335 fn lone_backslash_roundtrip() {
9336 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
9337 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9338 let jfm = adf_to_markdown(&adf).unwrap();
9339 let roundtripped = markdown_to_adf(&jfm).unwrap();
9340 let content = roundtripped.content[0].content.as_ref().unwrap();
9341 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
9342 }
9343
9344 #[test]
9345 fn trailing_backslash_in_text_roundtrip() {
9346 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
9348 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9349 let jfm = adf_to_markdown(&adf).unwrap();
9350 let roundtripped = markdown_to_adf(&jfm).unwrap();
9351 let content = roundtripped.content[0].content.as_ref().unwrap();
9352 assert_eq!(
9353 content[0].text.as_deref(),
9354 Some(r"end\"),
9355 "trailing backslash should survive round-trip"
9356 );
9357 }
9358
9359 #[test]
9360 fn escape_bare_urls_unit() {
9361 assert_eq!(escape_bare_urls("hello"), "hello");
9362 assert_eq!(escape_bare_urls(""), "");
9363 assert_eq!(
9364 escape_bare_urls("https://example.com"),
9365 r"\https://example.com"
9366 );
9367 assert_eq!(
9368 escape_bare_urls("http://example.com"),
9369 r"\http://example.com"
9370 );
9371 assert_eq!(
9372 escape_bare_urls("see https://a.com and https://b.com"),
9373 r"see \https://a.com and \https://b.com"
9374 );
9375 assert_eq!(escape_bare_urls("http header"), "http header");
9377 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
9378 }
9379
9380 #[test]
9381 fn heading_not_valid_without_space() {
9382 let doc = markdown_to_adf("#Title").unwrap();
9384 assert_eq!(doc.content[0].node_type, "paragraph");
9385 }
9386
9387 #[test]
9388 fn heading_level_too_high() {
9389 let doc = markdown_to_adf("####### Not a heading").unwrap();
9391 assert_eq!(doc.content[0].node_type, "paragraph");
9392 }
9393
9394 #[test]
9395 fn empty_document() {
9396 let doc = markdown_to_adf("").unwrap();
9397 assert!(doc.content.is_empty());
9398 }
9399
9400 #[test]
9401 fn only_blank_lines() {
9402 let doc = markdown_to_adf("\n\n\n").unwrap();
9403 assert!(doc.content.is_empty());
9404 }
9405
9406 #[test]
9407 fn code_block_unterminated() {
9408 let md = "```rust\nfn main() {}";
9410 let doc = markdown_to_adf(md).unwrap();
9411 assert_eq!(doc.content[0].node_type, "codeBlock");
9412 }
9413
9414 #[test]
9415 fn mixed_document() {
9416 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
9417 let doc = markdown_to_adf(md).unwrap();
9418 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
9419 assert_eq!(
9420 types,
9421 vec![
9422 "heading",
9423 "paragraph",
9424 "bulletList",
9425 "codeBlock",
9426 "blockquote",
9427 "rule",
9428 "orderedList",
9429 ]
9430 );
9431 }
9432
9433 #[test]
9436 fn adf_ordered_list_to_markdown() {
9437 let doc = AdfDocument {
9438 version: 1,
9439 doc_type: "doc".to_string(),
9440 content: vec![AdfNode::ordered_list(
9441 vec![
9442 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
9443 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
9444 ],
9445 None,
9446 )],
9447 };
9448 let md = adf_to_markdown(&doc).unwrap();
9449 assert!(md.contains("1. First"));
9450 assert!(md.contains("2. Second"));
9451 }
9452
9453 #[test]
9454 fn adf_ordered_list_custom_start() {
9455 let doc = AdfDocument {
9456 version: 1,
9457 doc_type: "doc".to_string(),
9458 content: vec![AdfNode::ordered_list(
9459 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
9460 AdfNode::text("Third"),
9461 ])])],
9462 Some(3),
9463 )],
9464 };
9465 let md = adf_to_markdown(&doc).unwrap();
9466 assert!(md.contains("3. Third"));
9467 }
9468
9469 #[test]
9470 fn adf_blockquote_to_markdown() {
9471 let doc = AdfDocument {
9472 version: 1,
9473 doc_type: "doc".to_string(),
9474 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
9475 AdfNode::text("A quote"),
9476 ])])],
9477 };
9478 let md = adf_to_markdown(&doc).unwrap();
9479 assert!(md.contains("> A quote"));
9480 }
9481
9482 #[test]
9483 fn adf_table_to_markdown() {
9484 let doc = AdfDocument {
9485 version: 1,
9486 doc_type: "doc".to_string(),
9487 content: vec![AdfNode::table(vec![
9488 AdfNode::table_row(vec![
9489 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
9490 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
9491 ]),
9492 AdfNode::table_row(vec![
9493 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
9494 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
9495 ]),
9496 ])],
9497 };
9498 let md = adf_to_markdown(&doc).unwrap();
9499 assert!(md.contains("| Name | Value |"));
9500 assert!(md.contains("| --- | --- |"));
9501 assert!(md.contains("| a | 1 |"));
9502 }
9503
9504 #[test]
9505 fn adf_media_to_markdown() {
9506 let doc = AdfDocument {
9507 version: 1,
9508 doc_type: "doc".to_string(),
9509 content: vec![AdfNode::media_single(
9510 "https://example.com/img.png",
9511 Some("Alt"),
9512 )],
9513 };
9514 let md = adf_to_markdown(&doc).unwrap();
9515 assert!(md.contains(""));
9516 }
9517
9518 #[test]
9519 fn adf_media_no_alt_to_markdown() {
9520 let doc = AdfDocument {
9521 version: 1,
9522 doc_type: "doc".to_string(),
9523 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
9524 };
9525 let md = adf_to_markdown(&doc).unwrap();
9526 assert!(md.contains(""));
9527 }
9528
9529 #[test]
9530 fn adf_italic_to_markdown() {
9531 let doc = AdfDocument {
9532 version: 1,
9533 doc_type: "doc".to_string(),
9534 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9535 "emphasis",
9536 vec![AdfMark::em()],
9537 )])],
9538 };
9539 let md = adf_to_markdown(&doc).unwrap();
9540 assert_eq!(md.trim(), "*emphasis*");
9541 }
9542
9543 #[test]
9544 fn adf_strikethrough_to_markdown() {
9545 let doc = AdfDocument {
9546 version: 1,
9547 doc_type: "doc".to_string(),
9548 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9549 "deleted",
9550 vec![AdfMark::strike()],
9551 )])],
9552 };
9553 let md = adf_to_markdown(&doc).unwrap();
9554 assert_eq!(md.trim(), "~~deleted~~");
9555 }
9556
9557 #[test]
9558 fn adf_inline_code_to_markdown() {
9559 let doc = AdfDocument {
9560 version: 1,
9561 doc_type: "doc".to_string(),
9562 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9563 "code",
9564 vec![AdfMark::code()],
9565 )])],
9566 };
9567 let md = adf_to_markdown(&doc).unwrap();
9568 assert_eq!(md.trim(), "`code`");
9569 }
9570
9571 #[test]
9572 fn adf_code_with_link_to_markdown() {
9573 let doc = AdfDocument {
9574 version: 1,
9575 doc_type: "doc".to_string(),
9576 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9577 "func",
9578 vec![AdfMark::code(), AdfMark::link("https://example.com")],
9579 )])],
9580 };
9581 let md = adf_to_markdown(&doc).unwrap();
9582 assert_eq!(md.trim(), "[`func`](https://example.com)");
9583 }
9584
9585 #[test]
9586 fn adf_bold_italic_to_markdown() {
9587 let doc = AdfDocument {
9588 version: 1,
9589 doc_type: "doc".to_string(),
9590 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9591 "both",
9592 vec![AdfMark::strong(), AdfMark::em()],
9593 )])],
9594 };
9595 let md = adf_to_markdown(&doc).unwrap();
9596 assert_eq!(md.trim(), "***both***");
9597 }
9598
9599 #[test]
9600 fn adf_bold_link_to_markdown() {
9601 let doc = AdfDocument {
9602 version: 1,
9603 doc_type: "doc".to_string(),
9604 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9605 "bold link",
9606 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
9607 )])],
9608 };
9609 let md = adf_to_markdown(&doc).unwrap();
9610 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
9611 }
9612
9613 #[test]
9614 fn adf_strikethrough_bold_to_markdown() {
9615 let doc = AdfDocument {
9616 version: 1,
9617 doc_type: "doc".to_string(),
9618 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9619 "struck",
9620 vec![AdfMark::strike(), AdfMark::strong()],
9621 )])],
9622 };
9623 let md = adf_to_markdown(&doc).unwrap();
9624 assert_eq!(md.trim(), "~~**struck**~~");
9625 }
9626
9627 #[test]
9628 fn adf_hard_break_to_markdown() {
9629 let doc = AdfDocument {
9630 version: 1,
9631 doc_type: "doc".to_string(),
9632 content: vec![AdfNode::paragraph(vec![
9633 AdfNode::text("Line 1"),
9634 AdfNode::hard_break(),
9635 AdfNode::text("Line 2"),
9636 ])],
9637 };
9638 let md = adf_to_markdown(&doc).unwrap();
9639 assert!(md.contains("Line 1\\\n Line 2"));
9640 }
9641
9642 #[test]
9643 fn adf_unsupported_inline_to_markdown() {
9644 let doc = AdfDocument {
9645 version: 1,
9646 doc_type: "doc".to_string(),
9647 content: vec![AdfNode::paragraph(vec![AdfNode {
9648 node_type: "unknownInline".to_string(),
9649 attrs: Some(serde_json::json!({"key": "value"})),
9650 content: None,
9651 text: None,
9652 marks: None,
9653 local_id: None,
9654 parameters: None,
9655 }])],
9656 };
9657 let md = adf_to_markdown(&doc).unwrap();
9658 assert!(md.contains(":adf-unsupported[]{json="));
9662 assert!(md.contains("unknownInline"));
9663 assert!(!md.contains("<!-- unsupported inline"));
9664 }
9665
9666 #[test]
9667 fn unsupported_inline_round_trips() {
9668 let original = AdfDocument {
9669 version: 1,
9670 doc_type: "doc".to_string(),
9671 content: vec![AdfNode::paragraph(vec![
9672 AdfNode::text("before "),
9673 AdfNode {
9674 node_type: "unknownInline".to_string(),
9675 attrs: Some(serde_json::json!({"key": "value", "n": 3})),
9676 content: None,
9677 text: Some("fallback".to_string()),
9678 marks: None,
9679 local_id: None,
9680 parameters: None,
9681 },
9682 AdfNode::text(" after"),
9683 ])],
9684 };
9685 let md = adf_to_markdown(&original).unwrap();
9686 let restored = markdown_to_adf(&md).unwrap();
9687 let para = &restored.content[0];
9689 let unknown = para
9690 .content
9691 .as_ref()
9692 .unwrap()
9693 .iter()
9694 .find(|n| n.node_type == "unknownInline")
9695 .expect("unknownInline node preserved");
9696 assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
9697 assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
9698 assert_eq!(unknown.text.as_deref(), Some("fallback"));
9699 }
9700
9701 #[test]
9704 fn adf_media_inline_to_markdown() {
9705 let doc = AdfDocument {
9706 version: 1,
9707 doc_type: "doc".to_string(),
9708 content: vec![AdfNode::paragraph(vec![
9709 AdfNode::text("see "),
9710 AdfNode::media_inline(serde_json::json!({
9711 "type": "image",
9712 "id": "abcdef01-2345-6789-abcd-abcdef012345",
9713 "collection": "contentId-111111",
9714 "width": 200,
9715 "height": 100
9716 })),
9717 AdfNode::text(" for details"),
9718 ])],
9719 };
9720 let md = adf_to_markdown(&doc).unwrap();
9721 assert!(md.contains(":media-inline[]{"), "got: {md}");
9722 assert!(md.contains("type=image"));
9723 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
9724 assert!(md.contains("collection=contentId-111111"));
9725 assert!(md.contains("width=200"));
9726 assert!(md.contains("height=100"));
9727 assert!(!md.contains("<!-- unsupported inline"));
9728 }
9729
9730 #[test]
9731 fn media_inline_round_trip() {
9732 let doc = AdfDocument {
9733 version: 1,
9734 doc_type: "doc".to_string(),
9735 content: vec![AdfNode::paragraph(vec![
9736 AdfNode::text("see "),
9737 AdfNode::media_inline(serde_json::json!({
9738 "type": "image",
9739 "id": "abcdef01-2345-6789-abcd-abcdef012345",
9740 "collection": "contentId-111111",
9741 "width": 200,
9742 "height": 100
9743 })),
9744 AdfNode::text(" for details"),
9745 ])],
9746 };
9747 let md = adf_to_markdown(&doc).unwrap();
9748 let rt = markdown_to_adf(&md).unwrap();
9749
9750 let content = rt.content[0].content.as_ref().unwrap();
9751 assert_eq!(content[0].text.as_deref(), Some("see "));
9752 assert_eq!(content[1].node_type, "mediaInline");
9753 let attrs = content[1].attrs.as_ref().unwrap();
9754 assert_eq!(attrs["type"], "image");
9755 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
9756 assert_eq!(attrs["collection"], "contentId-111111");
9757 assert_eq!(attrs["width"], 200);
9758 assert_eq!(attrs["height"], 100);
9759 assert_eq!(content[2].text.as_deref(), Some(" for details"));
9760 }
9761
9762 #[test]
9763 fn media_inline_external_url_round_trip() {
9764 let doc = AdfDocument {
9765 version: 1,
9766 doc_type: "doc".to_string(),
9767 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
9768 serde_json::json!({
9769 "type": "external",
9770 "url": "https://example.com/image.png",
9771 "alt": "example",
9772 "width": 400,
9773 "height": 300
9774 }),
9775 )])],
9776 };
9777 let md = adf_to_markdown(&doc).unwrap();
9778 let rt = markdown_to_adf(&md).unwrap();
9779
9780 let content = rt.content[0].content.as_ref().unwrap();
9781 assert_eq!(content[0].node_type, "mediaInline");
9782 let attrs = content[0].attrs.as_ref().unwrap();
9783 assert_eq!(attrs["type"], "external");
9784 assert_eq!(attrs["url"], "https://example.com/image.png");
9785 assert_eq!(attrs["alt"], "example");
9786 assert_eq!(attrs["width"], 400);
9787 assert_eq!(attrs["height"], 300);
9788 }
9789
9790 #[test]
9791 fn media_inline_minimal_attrs() {
9792 let doc = AdfDocument {
9793 version: 1,
9794 doc_type: "doc".to_string(),
9795 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
9796 serde_json::json!({"type": "file", "id": "abc-123"}),
9797 )])],
9798 };
9799 let md = adf_to_markdown(&doc).unwrap();
9800 let rt = markdown_to_adf(&md).unwrap();
9801
9802 let content = rt.content[0].content.as_ref().unwrap();
9803 assert_eq!(content[0].node_type, "mediaInline");
9804 let attrs = content[0].attrs.as_ref().unwrap();
9805 assert_eq!(attrs["type"], "file");
9806 assert_eq!(attrs["id"], "abc-123");
9807 }
9808
9809 #[test]
9810 fn media_inline_from_issue_476_reproducer() {
9811 let adf_json: serde_json::Value = serde_json::json!({
9813 "type": "doc",
9814 "version": 1,
9815 "content": [
9816 {
9817 "type": "paragraph",
9818 "content": [
9819 {"type": "text", "text": "see "},
9820 {
9821 "type": "mediaInline",
9822 "attrs": {
9823 "collection": "contentId-111111",
9824 "height": 100,
9825 "id": "abcdef01-2345-6789-abcd-abcdef012345",
9826 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
9827 "type": "image",
9828 "width": 200
9829 }
9830 },
9831 {"type": "text", "text": " for details"}
9832 ]
9833 }
9834 ]
9835 });
9836 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
9837 let md = adf_to_markdown(&doc).unwrap();
9838 assert!(
9839 !md.contains("<!-- unsupported inline"),
9840 "mediaInline should not be unsupported; got: {md}"
9841 );
9842
9843 let rt = markdown_to_adf(&md).unwrap();
9844 let content = rt.content[0].content.as_ref().unwrap();
9845 assert_eq!(content[1].node_type, "mediaInline");
9846 let attrs = content[1].attrs.as_ref().unwrap();
9847 assert_eq!(attrs["type"], "image");
9848 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
9849 assert_eq!(attrs["collection"], "contentId-111111");
9850 assert_eq!(attrs["width"], 200);
9851 assert_eq!(attrs["height"], 100);
9852 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
9853 }
9854
9855 #[test]
9856 fn emoji_shortcode() {
9857 let doc = markdown_to_adf("Hello :wave: world").unwrap();
9858 let content = doc.content[0].content.as_ref().unwrap();
9859 assert_eq!(content[0].text.as_deref(), Some("Hello "));
9860 assert_eq!(content[1].node_type, "emoji");
9861 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
9862 assert_eq!(content[2].text.as_deref(), Some(" world"));
9863 }
9864
9865 #[test]
9866 fn adf_emoji_to_markdown() {
9867 let doc = AdfDocument {
9868 version: 1,
9869 doc_type: "doc".to_string(),
9870 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
9871 };
9872 let md = adf_to_markdown(&doc).unwrap();
9873 assert!(md.contains(":thumbsup:"));
9874 }
9875
9876 #[test]
9877 fn adf_emoji_with_colon_prefix_to_markdown() {
9878 let doc = AdfDocument {
9880 version: 1,
9881 doc_type: "doc".to_string(),
9882 content: vec![AdfNode::paragraph(vec![AdfNode {
9883 node_type: "emoji".to_string(),
9884 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
9885 content: None,
9886 text: None,
9887 marks: None,
9888 local_id: None,
9889 parameters: None,
9890 }])],
9891 };
9892 let md = adf_to_markdown(&doc).unwrap();
9893 assert!(md.contains(":thumbsup:"));
9894 assert!(!md.contains("::thumbsup::"));
9896 }
9897
9898 #[test]
9899 fn round_trip_emoji() {
9900 let md = "Hello :wave: world\n";
9901 let doc = markdown_to_adf(md).unwrap();
9902 let result = adf_to_markdown(&doc).unwrap();
9903 assert!(result.contains(":wave:"));
9904 }
9905
9906 #[test]
9907 fn emoji_with_id_and_text_round_trips() {
9908 let doc = AdfDocument {
9909 version: 1,
9910 doc_type: "doc".to_string(),
9911 content: vec![AdfNode::paragraph(vec![AdfNode {
9912 node_type: "emoji".to_string(),
9913 attrs: Some(
9914 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
9915 ),
9916 content: None,
9917 text: None,
9918 marks: None,
9919 local_id: None,
9920 parameters: None,
9921 }])],
9922 };
9923 let md = adf_to_markdown(&doc).unwrap();
9924 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
9925 assert!(md.contains("id="), "id attr present: {md}");
9926 assert!(md.contains("text="), "text attr present: {md}");
9927
9928 let round_tripped = markdown_to_adf(&md).unwrap();
9930 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9931 let attrs = emoji.attrs.as_ref().unwrap();
9932 assert_eq!(attrs["shortName"], ":check_mark:");
9933 assert_eq!(attrs["id"], "2705");
9934 assert_eq!(attrs["text"], "✅");
9935 }
9936
9937 #[test]
9938 fn emoji_without_extra_attrs_still_works() {
9939 let md = "Hello :wave: world\n";
9940 let doc = markdown_to_adf(md).unwrap();
9941 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
9942 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
9943 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
9945 }
9946
9947 #[test]
9948 fn emoji_shortname_preserves_colons_round_trip() {
9949 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9951 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
9952 ]}]}"#;
9953 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9954
9955 let md = adf_to_markdown(&doc).unwrap();
9957 let round_tripped = markdown_to_adf(&md).unwrap();
9958 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9959 let attrs = emoji.attrs.as_ref().unwrap();
9960 assert_eq!(
9961 attrs["shortName"], ":cross_mark:",
9962 "shortName should preserve colons, got: {}",
9963 attrs["shortName"]
9964 );
9965 assert_eq!(attrs["id"], "atlassian-cross_mark");
9966 assert_eq!(attrs["text"], "❌");
9967 }
9968
9969 #[test]
9970 fn emoji_shortname_without_colons_preserved() {
9971 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9973 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
9974 ]}]}"#;
9975 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9976 let md = adf_to_markdown(&doc).unwrap();
9977 let round_tripped = markdown_to_adf(&md).unwrap();
9978 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9979 let attrs = emoji.attrs.as_ref().unwrap();
9980 assert_eq!(
9981 attrs["shortName"], "white_check_mark",
9982 "shortName without colons should stay without colons, got: {}",
9983 attrs["shortName"]
9984 );
9985 }
9986
9987 #[test]
9988 fn colon_in_text_not_emoji() {
9989 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
9991 let content = doc.content[0].content.as_ref().unwrap();
9992 assert_eq!(content.len(), 1);
9993 assert_eq!(content[0].node_type, "text");
9994 }
9995
9996 #[test]
9997 fn text_with_shortcode_pattern_round_trips_as_text() {
9998 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
10000 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10001
10002 let md = adf_to_markdown(&doc).unwrap();
10003 let round_tripped = markdown_to_adf(&md).unwrap();
10004 let content = round_tripped.content[0].content.as_ref().unwrap();
10005
10006 assert_eq!(
10007 content.len(),
10008 1,
10009 "should be a single text node, got: {content:?}"
10010 );
10011 assert_eq!(content[0].node_type, "text");
10012 assert_eq!(
10013 content[0].text.as_deref().unwrap(),
10014 "Alert :fire: triggered on pod:pod42"
10015 );
10016 }
10017
10018 #[test]
10019 fn double_colon_pattern_round_trips_as_text() {
10020 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
10022 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10023
10024 let md = adf_to_markdown(&doc).unwrap();
10025 let round_tripped = markdown_to_adf(&md).unwrap();
10026 let content = round_tripped.content[0].content.as_ref().unwrap();
10027
10028 assert_eq!(
10029 content.len(),
10030 1,
10031 "should be a single text node, got: {content:?}"
10032 );
10033 assert_eq!(content[0].node_type, "text");
10034 assert_eq!(
10035 content[0].text.as_deref().unwrap(),
10036 "Status::Active::Running"
10037 );
10038 }
10039
10040 #[test]
10041 fn real_emoji_node_still_round_trips() {
10042 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10044 {"type":"text","text":"Hello "},
10045 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
10046 {"type":"text","text":" world"}
10047 ]}]}"#;
10048 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10049
10050 let md = adf_to_markdown(&doc).unwrap();
10051 let round_tripped = markdown_to_adf(&md).unwrap();
10052 let content = round_tripped.content[0].content.as_ref().unwrap();
10053
10054 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
10056 assert_eq!(content[0].text.as_deref(), Some("Hello "));
10057 assert_eq!(content[1].node_type, "emoji");
10058 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
10059 assert_eq!(content[2].text.as_deref(), Some(" world"));
10060 }
10061
10062 #[test]
10063 fn combined_emoji_shortname_round_trips_as_single_node() {
10064 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10069 {"type":"text","text":"Thanks for the help "},
10070 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
10071 ]}]}"#;
10072 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10073
10074 let md = adf_to_markdown(&doc).unwrap();
10075 let round_tripped = markdown_to_adf(&md).unwrap();
10076 let content = round_tripped.content[0].content.as_ref().unwrap();
10077
10078 assert_eq!(
10079 content.len(),
10080 2,
10081 "should have text + single combined emoji: {content:?}"
10082 );
10083 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
10084 assert_eq!(content[1].node_type, "emoji");
10085 let attrs = content[1].attrs.as_ref().unwrap();
10086 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
10087 assert_eq!(attrs["id"], "");
10088 assert_eq!(attrs["text"], "");
10089 }
10090
10091 #[test]
10092 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
10093 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10095 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
10096 ]}]}"#;
10097 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10098
10099 let md = adf_to_markdown(&doc).unwrap();
10100 let round_tripped = markdown_to_adf(&md).unwrap();
10101 let content = round_tripped.content[0].content.as_ref().unwrap();
10102
10103 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
10104 assert_eq!(content[0].node_type, "emoji");
10105 let attrs = content[0].attrs.as_ref().unwrap();
10106 assert_eq!(attrs["shortName"], ":a::b::c:");
10107 assert_eq!(attrs["id"], "x");
10108 }
10109
10110 #[test]
10111 fn consecutive_emojis_remain_separate_nodes() {
10112 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10116 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
10117 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
10118 ]}]}"#;
10119 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10120
10121 let md = adf_to_markdown(&doc).unwrap();
10122 let round_tripped = markdown_to_adf(&md).unwrap();
10123 let content = round_tripped.content[0].content.as_ref().unwrap();
10124
10125 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
10126 assert_eq!(content[0].node_type, "emoji");
10127 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
10128 assert_eq!(content[1].node_type, "emoji");
10129 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
10130 }
10131
10132 #[test]
10133 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
10134 let md = ":fire::water:";
10137 let doc = markdown_to_adf(md).unwrap();
10138 let content = doc.content[0].content.as_ref().unwrap();
10139
10140 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
10141 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
10142 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
10143 }
10144
10145 #[test]
10146 fn combined_emoji_shortname_preserves_local_id() {
10147 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
10150 let doc = markdown_to_adf(md).unwrap();
10151 let content = doc.content[0].content.as_ref().unwrap();
10152
10153 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
10154 let attrs = content[0].attrs.as_ref().unwrap();
10155 assert_eq!(attrs["shortName"], ":a::b:");
10156 assert_eq!(attrs["id"], "x");
10157 assert_eq!(attrs["text"], "y");
10158 assert_eq!(attrs["localId"], "abc");
10159 }
10160
10161 #[test]
10162 fn text_shortcode_with_marks_round_trips() {
10163 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10165 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
10166 ]}]}"#;
10167 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10168
10169 let md = adf_to_markdown(&doc).unwrap();
10170 let round_tripped = markdown_to_adf(&md).unwrap();
10171 let content = round_tripped.content[0].content.as_ref().unwrap();
10172
10173 assert_eq!(
10174 content.len(),
10175 1,
10176 "should be single bold text node: {content:?}"
10177 );
10178 assert_eq!(content[0].node_type, "text");
10179 assert_eq!(
10180 content[0].text.as_deref().unwrap(),
10181 "Alert :fire: triggered"
10182 );
10183 assert!(content[0]
10184 .marks
10185 .as_ref()
10186 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
10187 }
10188
10189 #[test]
10190 fn mixed_emoji_node_and_text_shortcode_round_trips() {
10191 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10193 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
10194 {"type":"text","text":" says :hello: to you"}
10195 ]}]}"#;
10196 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10197
10198 let md = adf_to_markdown(&doc).unwrap();
10199 let round_tripped = markdown_to_adf(&md).unwrap();
10200 let content = round_tripped.content[0].content.as_ref().unwrap();
10201
10202 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
10204 assert_eq!(content[0].node_type, "emoji");
10205 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
10206 assert_eq!(content[1].node_type, "text");
10207 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
10208 }
10209
10210 #[test]
10211 fn code_block_with_shortcode_pattern_round_trips() {
10212 let adf_json = r#"{"version":1,"type":"doc","content":[
10215 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10216 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
10217 ]}
10218 ]}"#;
10219 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10220
10221 let md = adf_to_markdown(&doc).unwrap();
10222 let round_tripped = markdown_to_adf(&md).unwrap();
10223
10224 assert_eq!(
10225 round_tripped.content.len(),
10226 1,
10227 "should be a single codeBlock"
10228 );
10229 let cb = &round_tripped.content[0];
10230 assert_eq!(cb.node_type, "codeBlock");
10231 let content = cb.content.as_ref().expect("codeBlock content");
10232 assert_eq!(
10233 content.len(),
10234 1,
10235 "should be a single text node: {content:?}"
10236 );
10237 assert_eq!(content[0].node_type, "text");
10238 assert_eq!(
10239 content[0].text.as_deref().unwrap(),
10240 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
10241 );
10242 assert!(
10243 content.iter().all(|n| n.node_type != "emoji"),
10244 "no emoji nodes should be present, got: {content:?}"
10245 );
10246 }
10247
10248 #[test]
10249 fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
10250 let adf_json = r#"{"version":1,"type":"doc","content":[
10252 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10253 {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
10254 ]}
10255 ]}"#;
10256 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10257
10258 let md = adf_to_markdown(&doc).unwrap();
10259 let round_tripped = markdown_to_adf(&md).unwrap();
10260
10261 let cb = &round_tripped.content[0];
10262 assert_eq!(cb.node_type, "codeBlock");
10263 let content = cb.content.as_ref().expect("codeBlock content");
10264 assert_eq!(content.len(), 1, "should be a single text node");
10265 assert_eq!(
10266 content[0].text.as_deref().unwrap(),
10267 "class ZBC::Acme::PlanType::Professional < Base"
10268 );
10269 }
10270
10271 #[test]
10272 fn code_block_with_literal_shortcode_round_trips() {
10273 let adf_json = r#"{"version":1,"type":"doc","content":[
10276 {"type":"codeBlock","attrs":{"language":"text"},"content":[
10277 {"type":"text","text":":fire: :wave: :thumbsup:"}
10278 ]}
10279 ]}"#;
10280 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10281
10282 let md = adf_to_markdown(&doc).unwrap();
10283 let round_tripped = markdown_to_adf(&md).unwrap();
10284
10285 let cb = &round_tripped.content[0];
10286 assert_eq!(cb.node_type, "codeBlock");
10287 let content = cb.content.as_ref().expect("codeBlock content");
10288 assert_eq!(
10289 content.len(),
10290 1,
10291 "should be a single text node: {content:?}"
10292 );
10293 assert_eq!(content[0].node_type, "text");
10294 assert_eq!(
10295 content[0].text.as_deref().unwrap(),
10296 ":fire: :wave: :thumbsup:"
10297 );
10298 }
10299
10300 #[test]
10301 fn inline_code_with_shortcode_pattern_round_trips() {
10302 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10305 {"type":"text","text":"See "},
10306 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
10307 {"type":"text","text":" for details"}
10308 ]}]}"#;
10309 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10310
10311 let md = adf_to_markdown(&doc).unwrap();
10312 let round_tripped = markdown_to_adf(&md).unwrap();
10313 let content = round_tripped.content[0].content.as_ref().unwrap();
10314
10315 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
10316 assert_eq!(content[0].text.as_deref(), Some("See "));
10317 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
10318 assert!(content[1]
10319 .marks
10320 .as_ref()
10321 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10322 assert_eq!(content[2].text.as_deref(), Some(" for details"));
10323 assert!(
10324 content.iter().all(|n| n.node_type != "emoji"),
10325 "no emoji nodes should be present"
10326 );
10327 }
10328
10329 #[test]
10330 fn inline_code_with_literal_shortcode_round_trips() {
10331 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10334 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
10335 ]}]}"#;
10336 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10337
10338 let md = adf_to_markdown(&doc).unwrap();
10339 let round_tripped = markdown_to_adf(&md).unwrap();
10340 let content = round_tripped.content[0].content.as_ref().unwrap();
10341
10342 assert_eq!(
10343 content.len(),
10344 1,
10345 "should be a single code node: {content:?}"
10346 );
10347 assert_eq!(content[0].node_type, "text");
10348 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
10349 assert!(content[0]
10350 .marks
10351 .as_ref()
10352 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10353 }
10354
10355 #[test]
10356 fn code_block_in_list_with_shortcode_pattern_round_trips() {
10357 let adf_json = r#"{"version":1,"type":"doc","content":[
10360 {"type":"bulletList","content":[
10361 {"type":"listItem","content":[
10362 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10363 {"type":"text","text":"Foo::Bar::Baz"}
10364 ]}
10365 ]}
10366 ]}
10367 ]}"#;
10368 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10369
10370 let md = adf_to_markdown(&doc).unwrap();
10371 let round_tripped = markdown_to_adf(&md).unwrap();
10372
10373 let list = &round_tripped.content[0];
10374 assert_eq!(list.node_type, "bulletList");
10375 let item = &list.content.as_ref().unwrap()[0];
10376 assert_eq!(item.node_type, "listItem");
10377 let cb = &item.content.as_ref().unwrap()[0];
10378 assert_eq!(cb.node_type, "codeBlock");
10379 let cb_content = cb.content.as_ref().unwrap();
10380 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
10381 assert_eq!(cb_content[0].node_type, "text");
10382 }
10383
10384 #[test]
10385 fn code_block_with_unicode_shortcode_pattern_round_trips() {
10386 let adf_json = r#"{"version":1,"type":"doc","content":[
10390 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10391 {"type":"text","text":"class ZBC::配置::Production < Base"}
10392 ]}
10393 ]}"#;
10394 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10395
10396 let md = adf_to_markdown(&doc).unwrap();
10397 let round_tripped = markdown_to_adf(&md).unwrap();
10398
10399 let cb = &round_tripped.content[0];
10400 assert_eq!(cb.node_type, "codeBlock");
10401 let content = cb.content.as_ref().expect("codeBlock content");
10402 assert_eq!(content.len(), 1);
10403 assert_eq!(
10404 content[0].text.as_deref().unwrap(),
10405 "class ZBC::配置::Production < Base"
10406 );
10407 }
10408
10409 #[test]
10410 fn list_item_hardbreak_then_code_block_round_trips() {
10411 let adf_json = r#"{"version":1,"type":"doc","content":[
10417 {"type":"bulletList","content":[
10418 {"type":"listItem","content":[
10419 {"type":"paragraph","content":[
10420 {"type":"text","text":"Consider removing this check."},
10421 {"type":"hardBreak"}
10422 ]},
10423 {"type":"codeBlock","content":[
10424 {"type":"text","text":"x = Foo::Bar::Baz.new"}
10425 ]}
10426 ]}
10427 ]}
10428 ]}"#;
10429 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10430
10431 let md = adf_to_markdown(&doc).unwrap();
10432 let round_tripped = markdown_to_adf(&md).unwrap();
10433
10434 let list = &round_tripped.content[0];
10435 assert_eq!(list.node_type, "bulletList");
10436 let item = &list.content.as_ref().unwrap()[0];
10437 assert_eq!(item.node_type, "listItem");
10438 let item_content = item.content.as_ref().unwrap();
10439 assert_eq!(
10440 item_content.len(),
10441 2,
10442 "listItem should have paragraph + codeBlock, got: {item_content:?}"
10443 );
10444 assert_eq!(item_content[0].node_type, "paragraph");
10445 assert_eq!(item_content[1].node_type, "codeBlock");
10446
10447 let cb_content = item_content[1].content.as_ref().unwrap();
10449 assert_eq!(cb_content[0].node_type, "text");
10450 assert_eq!(
10451 cb_content[0].text.as_deref().unwrap(),
10452 "x = Foo::Bar::Baz.new"
10453 );
10454
10455 assert!(
10457 item_content
10458 .iter()
10459 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
10460 .all(|n| n.node_type != "emoji"),
10461 "no emoji nodes should be present, got: {item_content:?}"
10462 );
10463 }
10464
10465 #[test]
10466 fn list_item_hardbreak_then_nested_list_still_works() {
10467 let md = "- first\\\n continuation text\\\n - nested item\n";
10471 let doc = markdown_to_adf(md).unwrap();
10472 let list = &doc.content[0];
10473 assert_eq!(list.node_type, "bulletList");
10474 let item = &list.content.as_ref().unwrap()[0];
10475 let item_content = item.content.as_ref().unwrap();
10477 let para = &item_content[0];
10478 assert_eq!(para.node_type, "paragraph");
10479 let para_nodes = para.content.as_ref().unwrap();
10480 assert!(
10481 para_nodes
10482 .iter()
10483 .any(|n| n.text.as_deref() == Some("continuation text")),
10484 "continuation text should survive: {para_nodes:?}"
10485 );
10486 }
10487
10488 #[test]
10489 fn list_item_hardbreak_then_image_still_works() {
10490 let md = "- first\\\n {type=file id=x}\n";
10495 let doc = markdown_to_adf(md).unwrap();
10496 let list = &doc.content[0];
10497 let item = &list.content.as_ref().unwrap()[0];
10498 let item_content = item.content.as_ref().unwrap();
10499 assert!(
10501 item_content.iter().any(|n| n.node_type == "mediaSingle"),
10502 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
10503 );
10504 }
10505
10506 #[test]
10507 fn list_item_hardbreak_then_container_directive_round_trips() {
10508 let adf_json = r#"{"version":1,"type":"doc","content":[
10512 {"type":"bulletList","content":[
10513 {"type":"listItem","content":[
10514 {"type":"paragraph","content":[
10515 {"type":"text","text":"intro"},
10516 {"type":"hardBreak"}
10517 ]},
10518 {"type":"panel","attrs":{"panelType":"info"},"content":[
10519 {"type":"paragraph","content":[
10520 {"type":"text","text":"panel body"}
10521 ]}
10522 ]}
10523 ]}
10524 ]}
10525 ]}"#;
10526 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10527 let md = adf_to_markdown(&doc).unwrap();
10528 let round_tripped = markdown_to_adf(&md).unwrap();
10529
10530 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
10531 let item_content = item.content.as_ref().unwrap();
10532 assert!(
10533 item_content.iter().any(|n| n.node_type == "panel"),
10534 "panel should survive as block-level sibling, got: {item_content:?}"
10535 );
10536 }
10537
10538 #[test]
10539 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
10540 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10543 {"type":"text","text":"See "},
10544 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
10545 {"type":"text","text":" for prod"}
10546 ]}]}"#;
10547 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10548
10549 let md = adf_to_markdown(&doc).unwrap();
10550 let round_tripped = markdown_to_adf(&md).unwrap();
10551 let content = round_tripped.content[0].content.as_ref().unwrap();
10552
10553 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
10554 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
10555 assert!(content[1]
10556 .marks
10557 .as_ref()
10558 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10559 }
10560
10561 #[test]
10562 fn code_block_followed_by_shortcode_text_round_trips() {
10563 let adf_json = r#"{"version":1,"type":"doc","content":[
10566 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10567 {"type":"text","text":"Foo::Bar::Baz"}
10568 ]},
10569 {"type":"paragraph","content":[
10570 {"type":"text","text":"Status::Active::Running"}
10571 ]}
10572 ]}"#;
10573 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10574
10575 let md = adf_to_markdown(&doc).unwrap();
10576 let round_tripped = markdown_to_adf(&md).unwrap();
10577
10578 assert_eq!(round_tripped.content.len(), 2);
10579 let cb = &round_tripped.content[0];
10580 assert_eq!(cb.node_type, "codeBlock");
10581 let cb_content = cb.content.as_ref().unwrap();
10582 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
10583
10584 let para = &round_tripped.content[1];
10585 assert_eq!(para.node_type, "paragraph");
10586 let para_content = para.content.as_ref().unwrap();
10587 assert_eq!(para_content.len(), 1);
10588 assert_eq!(para_content[0].node_type, "text");
10589 assert_eq!(
10590 para_content[0].text.as_deref(),
10591 Some("Status::Active::Running")
10592 );
10593 }
10594
10595 #[test]
10596 fn adf_inline_card_to_markdown() {
10597 let doc = AdfDocument {
10598 version: 1,
10599 doc_type: "doc".to_string(),
10600 content: vec![AdfNode::paragraph(vec![AdfNode {
10601 node_type: "inlineCard".to_string(),
10602 attrs: Some(
10603 serde_json::json!({"url": "https://org.atlassian.net/browse/ACME-4382"}),
10604 ),
10605 content: None,
10606 text: None,
10607 marks: None,
10608 local_id: None,
10609 parameters: None,
10610 }])],
10611 };
10612 let md = adf_to_markdown(&doc).unwrap();
10613 assert!(md.contains(":card[https://org.atlassian.net/browse/ACME-4382]"));
10614 assert!(!md.contains("<!-- unsupported inline"));
10615 }
10616
10617 #[test]
10618 fn inline_card_directive_round_trips() {
10619 let original = AdfDocument {
10621 version: 1,
10622 doc_type: "doc".to_string(),
10623 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
10624 "https://org.atlassian.net/browse/ACME-4382",
10625 )])],
10626 };
10627 let md = adf_to_markdown(&original).unwrap();
10628 assert!(md.contains(":card[https://org.atlassian.net/browse/ACME-4382]"));
10629 let restored = markdown_to_adf(&md).unwrap();
10630 let node = &restored.content[0].content.as_ref().unwrap()[0];
10631 assert_eq!(node.node_type, "inlineCard");
10632 assert_eq!(
10633 node.attrs.as_ref().unwrap()["url"],
10634 "https://org.atlassian.net/browse/ACME-4382"
10635 );
10636 }
10637
10638 #[test]
10639 fn inline_card_directive_parsed_from_jfm() {
10640 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
10642 let nodes = doc.content[0].content.as_ref().unwrap();
10643 assert_eq!(nodes[0].node_type, "text");
10644 assert_eq!(nodes[0].text.as_deref(), Some("See "));
10645 assert_eq!(nodes[1].node_type, "inlineCard");
10646 assert_eq!(
10647 nodes[1].attrs.as_ref().unwrap()["url"],
10648 "https://example.com/issue/123"
10649 );
10650 assert_eq!(nodes[2].node_type, "text");
10651 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
10652 }
10653
10654 #[test]
10655 fn self_link_becomes_link_mark_not_inline_card() {
10656 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
10659 let node = &doc.content[0].content.as_ref().unwrap()[0];
10660 assert_eq!(node.node_type, "text");
10661 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10662 let mark = &node.marks.as_ref().unwrap()[0];
10663 assert_eq!(mark.mark_type, "link");
10664 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10665 }
10666
10667 #[test]
10668 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
10669 let doc =
10672 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
10673 let node = &doc.content[0].content.as_ref().unwrap()[0];
10674 assert_eq!(node.node_type, "text");
10675 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
10676 let mark = &node.marks.as_ref().unwrap()[0];
10677 assert_eq!(mark.mark_type, "link");
10678 assert_eq!(
10679 mark.attrs.as_ref().unwrap()["href"],
10680 "https://octopz.example.com/"
10681 );
10682 }
10683
10684 #[test]
10685 fn named_link_does_not_become_inline_card() {
10686 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
10688 let node = &doc.content[0].content.as_ref().unwrap()[0];
10689 assert_eq!(node.node_type, "text");
10690 assert_eq!(node.text.as_deref(), Some("#4668"));
10691 let mark = &node.marks.as_ref().unwrap()[0];
10692 assert_eq!(mark.mark_type, "link");
10693 }
10694
10695 #[test]
10696 fn adf_inline_card_no_url_to_markdown() {
10697 let doc = AdfDocument {
10698 version: 1,
10699 doc_type: "doc".to_string(),
10700 content: vec![AdfNode::paragraph(vec![AdfNode {
10701 node_type: "inlineCard".to_string(),
10702 attrs: Some(serde_json::json!({})),
10703 content: None,
10704 text: None,
10705 marks: None,
10706 local_id: None,
10707 parameters: None,
10708 }])],
10709 };
10710 let md = adf_to_markdown(&doc).unwrap();
10711 assert!(!md.contains("<!-- unsupported inline"));
10713 }
10714
10715 #[test]
10716 fn adf_code_block_no_language_to_markdown() {
10717 let doc = AdfDocument {
10718 version: 1,
10719 doc_type: "doc".to_string(),
10720 content: vec![AdfNode::code_block(None, "plain code")],
10721 };
10722 let md = adf_to_markdown(&doc).unwrap();
10723 assert!(md.contains("```\n"));
10724 assert!(md.contains("plain code"));
10725 }
10726
10727 #[test]
10728 fn adf_code_block_empty_language_to_markdown() {
10729 let doc = AdfDocument {
10730 version: 1,
10731 doc_type: "doc".to_string(),
10732 content: vec![AdfNode::code_block(Some(""), "plain code")],
10733 };
10734 let md = adf_to_markdown(&doc).unwrap();
10735 assert!(md.contains("```\"\"\n"));
10736 assert!(md.contains("plain code"));
10737 }
10738
10739 #[test]
10742 fn round_trip_table() {
10743 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
10744 let adf = markdown_to_adf(md).unwrap();
10745 let restored = adf_to_markdown(&adf).unwrap();
10746 assert!(restored.contains("| A | B |"));
10747 assert!(restored.contains("| 1 | 2 |"));
10748 }
10749
10750 #[test]
10751 fn round_trip_blockquote() {
10752 let md = "> This is quoted\n";
10753 let adf = markdown_to_adf(md).unwrap();
10754 let restored = adf_to_markdown(&adf).unwrap();
10755 assert!(restored.contains("> This is quoted"));
10756 }
10757
10758 #[test]
10759 fn round_trip_image() {
10760 let md = "\n";
10761 let adf = markdown_to_adf(md).unwrap();
10762 let restored = adf_to_markdown(&adf).unwrap();
10763 assert!(restored.contains(""));
10764 }
10765
10766 #[test]
10767 fn round_trip_ordered_list() {
10768 let md = "1. A\n2. B\n3. C\n";
10769 let adf = markdown_to_adf(md).unwrap();
10770 let restored = adf_to_markdown(&adf).unwrap();
10771 assert!(restored.contains("1. A"));
10772 assert!(restored.contains("2. B"));
10773 assert!(restored.contains("3. C"));
10774 }
10775
10776 #[test]
10777 fn round_trip_inline_marks() {
10778 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
10779 let adf = markdown_to_adf(md).unwrap();
10780 let restored = adf_to_markdown(&adf).unwrap();
10781 assert!(restored.contains("`code`"));
10782 assert!(restored.contains("~~strike~~"));
10783 assert!(restored.contains("[link](https://x.com)"));
10784 }
10785
10786 #[test]
10789 fn panel_info() {
10790 let md = ":::panel{type=info}\nThis is informational.\n:::";
10791 let doc = markdown_to_adf(md).unwrap();
10792 assert_eq!(doc.content[0].node_type, "panel");
10793 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
10794 let inner = doc.content[0].content.as_ref().unwrap();
10795 assert_eq!(inner[0].node_type, "paragraph");
10796 }
10797
10798 #[test]
10799 fn adf_panel_to_markdown() {
10800 let doc = AdfDocument {
10801 version: 1,
10802 doc_type: "doc".to_string(),
10803 content: vec![AdfNode::panel(
10804 "warning",
10805 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
10806 )],
10807 };
10808 let md = adf_to_markdown(&doc).unwrap();
10809 assert!(md.contains(":::panel{type=warning}"));
10810 assert!(md.contains("Be careful."));
10811 assert!(md.contains(":::"));
10812 }
10813
10814 #[test]
10815 fn round_trip_panel() {
10816 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
10817 let doc = markdown_to_adf(md).unwrap();
10818 let result = adf_to_markdown(&doc).unwrap();
10819 assert!(result.contains(":::panel{type=info}"));
10820 assert!(result.contains("This is informational."));
10821 }
10822
10823 #[test]
10824 fn expand_with_title() {
10825 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
10826 let doc = markdown_to_adf(md).unwrap();
10827 assert_eq!(doc.content[0].node_type, "expand");
10828 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
10829 }
10830
10831 #[test]
10832 fn adf_expand_to_markdown() {
10833 let doc = AdfDocument {
10834 version: 1,
10835 doc_type: "doc".to_string(),
10836 content: vec![AdfNode::expand(
10837 Some("Details"),
10838 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
10839 )],
10840 };
10841 let md = adf_to_markdown(&doc).unwrap();
10842 assert!(md.contains(":::expand{title=\"Details\"}"));
10843 assert!(md.contains("Inner."));
10844 }
10845
10846 #[test]
10847 fn round_trip_expand() {
10848 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
10849 let doc = markdown_to_adf(md).unwrap();
10850 let result = adf_to_markdown(&doc).unwrap();
10851 assert!(result.contains(":::expand{title=\"Details\"}"));
10852 assert!(result.contains("Inner content."));
10853 }
10854
10855 #[test]
10856 fn layout_two_columns() {
10857 let md =
10858 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
10859 let doc = markdown_to_adf(md).unwrap();
10860 assert_eq!(doc.content[0].node_type, "layoutSection");
10861 let columns = doc.content[0].content.as_ref().unwrap();
10862 assert_eq!(columns.len(), 2);
10863 assert_eq!(columns[0].node_type, "layoutColumn");
10864 assert_eq!(columns[1].node_type, "layoutColumn");
10865 }
10866
10867 #[test]
10868 fn adf_layout_to_markdown() {
10869 let doc = AdfDocument {
10870 version: 1,
10871 doc_type: "doc".to_string(),
10872 content: vec![AdfNode::layout_section(vec![
10873 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
10874 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
10875 ])],
10876 };
10877 let md = adf_to_markdown(&doc).unwrap();
10878 assert!(md.contains("::::layout"));
10879 assert!(md.contains(":::column{width=50}"));
10880 assert!(md.contains("Left."));
10881 assert!(md.contains("Right."));
10882 }
10883
10884 #[test]
10885 fn layout_column_localid_roundtrip() {
10886 let adf_json = r#"{
10887 "version": 1,
10888 "type": "doc",
10889 "content": [{
10890 "type": "layoutSection",
10891 "content": [
10892 {
10893 "type": "layoutColumn",
10894 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
10895 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
10896 },
10897 {
10898 "type": "layoutColumn",
10899 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
10900 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
10901 }
10902 ]
10903 }]
10904 }"#;
10905 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10906 let md = adf_to_markdown(&doc).unwrap();
10907 assert!(
10908 md.contains("localId=aabb112233cc"),
10909 "first column localId should appear in markdown: {md}"
10910 );
10911 assert!(
10912 md.contains("localId=ddeeff445566"),
10913 "second column localId should appear in markdown: {md}"
10914 );
10915 let rt = markdown_to_adf(&md).unwrap();
10916 let cols = rt.content[0].content.as_ref().unwrap();
10917 assert_eq!(
10918 cols[0].attrs.as_ref().unwrap()["localId"],
10919 "aabb112233cc",
10920 "first column localId should round-trip"
10921 );
10922 assert_eq!(
10923 cols[1].attrs.as_ref().unwrap()["localId"],
10924 "ddeeff445566",
10925 "second column localId should round-trip"
10926 );
10927 }
10928
10929 #[test]
10930 fn layout_column_without_localid() {
10931 let md =
10932 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
10933 let doc = markdown_to_adf(md).unwrap();
10934 let cols = doc.content[0].content.as_ref().unwrap();
10935 assert!(
10936 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
10937 "column without localId should not gain one"
10938 );
10939 let md2 = adf_to_markdown(&doc).unwrap();
10940 assert!(
10941 !md2.contains("localId"),
10942 "no localId should appear in output: {md2}"
10943 );
10944 }
10945
10946 #[test]
10947 fn layout_column_localid_stripped_when_option_set() {
10948 let adf_json = r#"{
10949 "version": 1,
10950 "type": "doc",
10951 "content": [{
10952 "type": "layoutSection",
10953 "content": [{
10954 "type": "layoutColumn",
10955 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
10956 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
10957 }]
10958 }]
10959 }"#;
10960 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10961 let opts = RenderOptions {
10962 strip_local_ids: true,
10963 ..Default::default()
10964 };
10965 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10966 assert!(!md.contains("localId"), "localId should be stripped: {md}");
10967 }
10968
10969 #[test]
10970 fn layout_column_localid_flush_previous() {
10971 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
10973 let doc = markdown_to_adf(md).unwrap();
10974 let cols = doc.content[0].content.as_ref().unwrap();
10975 assert_eq!(
10976 cols[0].attrs.as_ref().unwrap()["localId"],
10977 "aabb112233cc",
10978 "flush-previous column should preserve localId"
10979 );
10980 assert_eq!(
10981 cols[1].attrs.as_ref().unwrap()["localId"],
10982 "ddeeff445566",
10983 "second column localId should be preserved"
10984 );
10985 }
10986
10987 #[test]
10988 fn layout_column_localid_flush_last() {
10989 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
10991 let doc = markdown_to_adf(md).unwrap();
10992 let cols = doc.content[0].content.as_ref().unwrap();
10993 assert_eq!(
10994 cols[0].attrs.as_ref().unwrap()["localId"],
10995 "aabb112233cc",
10996 "flush-last column should preserve localId"
10997 );
10998 }
10999
11000 #[test]
11002 fn issue_555_layout_column_fractional_width_roundtrip() {
11003 let adf_json = r#"{
11004 "version": 1,
11005 "type": "doc",
11006 "content": [{
11007 "type": "layoutSection",
11008 "content": [
11009 {
11010 "type": "layoutColumn",
11011 "attrs": {"width": 66.66},
11012 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
11013 },
11014 {
11015 "type": "layoutColumn",
11016 "attrs": {"width": 33.34},
11017 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
11018 }
11019 ]
11020 }]
11021 }"#;
11022 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11023 let md = adf_to_markdown(&doc).unwrap();
11024 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
11025 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
11026 let rt = markdown_to_adf(&md).unwrap();
11027 let cols = rt.content[0].content.as_ref().unwrap();
11028 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
11029 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
11030 }
11031
11032 #[test]
11034 fn issue_555_layout_column_five_sixths_width_roundtrip() {
11035 let adf_json = r#"{
11036 "version": 1,
11037 "type": "doc",
11038 "content": [{
11039 "type": "layoutSection",
11040 "content": [
11041 {
11042 "type": "layoutColumn",
11043 "attrs": {"width": 83.33},
11044 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
11045 },
11046 {
11047 "type": "layoutColumn",
11048 "attrs": {"width": 16.67},
11049 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
11050 }
11051 ]
11052 }]
11053 }"#;
11054 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055 let md = adf_to_markdown(&doc).unwrap();
11056 let rt = markdown_to_adf(&md).unwrap();
11057 let cols = rt.content[0].content.as_ref().unwrap();
11058 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
11059 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
11060 }
11061
11062 #[test]
11064 fn issue_555_layout_column_integer_width_preserved() {
11065 let adf_json = r#"{
11066 "version": 1,
11067 "type": "doc",
11068 "content": [{
11069 "type": "layoutSection",
11070 "content": [
11071 {
11072 "type": "layoutColumn",
11073 "attrs": {"width": 50},
11074 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
11075 },
11076 {
11077 "type": "layoutColumn",
11078 "attrs": {"width": 50},
11079 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
11080 }
11081 ]
11082 }]
11083 }"#;
11084 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11085 let md = adf_to_markdown(&doc).unwrap();
11086 assert!(
11087 md.contains("width=50") && !md.contains("width=50."),
11088 "integer width should render without decimal: {md}"
11089 );
11090 let rt = markdown_to_adf(&md).unwrap();
11091 let cols = rt.content[0].content.as_ref().unwrap();
11092 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
11093 assert!(
11094 w0.is_i64() || w0.is_u64(),
11095 "width should remain a JSON integer, got: {w0}"
11096 );
11097 assert_eq!(w0.as_i64(), Some(50));
11098 }
11099
11100 #[test]
11102 fn issue_555_media_single_integer_width_preserved() {
11103 let adf_json = r#"{
11104 "version": 1,
11105 "type": "doc",
11106 "content": [{
11107 "type": "mediaSingle",
11108 "attrs": {"layout": "center", "width": 800},
11109 "content": [
11110 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
11111 ]
11112 }]
11113 }"#;
11114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11115 let md = adf_to_markdown(&doc).unwrap();
11116 assert!(
11117 md.contains("width=800") && !md.contains("width=800."),
11118 "integer width should render without decimal: {md}"
11119 );
11120 let rt = markdown_to_adf(&md).unwrap();
11121 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11122 let w = &ms_attrs["width"];
11123 assert!(
11124 w.is_i64() || w.is_u64(),
11125 "mediaSingle width should remain JSON integer, got: {w}"
11126 );
11127 assert_eq!(w.as_i64(), Some(800));
11128 }
11129
11130 #[test]
11134 fn issue_555_media_single_fractional_width_preserved() {
11135 let adf_json = r#"{
11136 "version": 1,
11137 "type": "doc",
11138 "content": [{
11139 "type": "mediaSingle",
11140 "attrs": {"layout": "center", "width": 66.5},
11141 "content": [
11142 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
11143 ]
11144 }]
11145 }"#;
11146 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11147 let md = adf_to_markdown(&doc).unwrap();
11148 assert!(
11149 md.contains("width=66.5"),
11150 "fractional width must appear in JFM: {md}"
11151 );
11152 let rt = markdown_to_adf(&md).unwrap();
11153 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11154 assert_eq!(ms_attrs["width"], 66.5);
11155 }
11156
11157 #[test]
11159 fn issue_555_media_single_float_width_preserved() {
11160 let adf_json = r#"{
11161 "version": 1,
11162 "type": "doc",
11163 "content": [{
11164 "type": "mediaSingle",
11165 "attrs": {"layout": "center", "width": 800.0},
11166 "content": [
11167 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
11168 ]
11169 }]
11170 }"#;
11171 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11172 let md = adf_to_markdown(&doc).unwrap();
11173 assert!(
11174 md.contains("width=800.0"),
11175 "float width should render with decimal: {md}"
11176 );
11177 let rt = markdown_to_adf(&md).unwrap();
11178 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11179 let w = &ms_attrs["width"];
11180 assert!(
11181 w.is_f64(),
11182 "mediaSingle float width should stay a JSON float, got: {w}"
11183 );
11184 assert_eq!(w.as_f64(), Some(800.0));
11185 }
11186
11187 #[test]
11189 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
11190 let adf_json = r#"{
11191 "version": 1,
11192 "type": "doc",
11193 "content": [{
11194 "type": "mediaSingle",
11195 "attrs": {"layout": "wide", "width": 1420},
11196 "content": [
11197 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
11198 ]
11199 }]
11200 }"#;
11201 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11202 let md = adf_to_markdown(&doc).unwrap();
11203 let rt = markdown_to_adf(&md).unwrap();
11204 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11205 assert_eq!(ms_attrs["layout"], "wide");
11206 let w = &ms_attrs["width"];
11207 assert!(
11208 w.is_i64() || w.is_u64(),
11209 "mediaSingle width should remain JSON integer, got: {w}"
11210 );
11211 assert_eq!(w.as_i64(), Some(1420));
11212 }
11213
11214 #[test]
11217 fn issue_555_file_media_single_integer_width_preserved() {
11218 let adf_json = r#"{
11219 "version": 1,
11220 "type": "doc",
11221 "content": [{
11222 "type": "mediaSingle",
11223 "attrs": {"layout": "wide", "width": 1420},
11224 "content": [
11225 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
11226 ]
11227 }]
11228 }"#;
11229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11230 let md = adf_to_markdown(&doc).unwrap();
11231 let rt = markdown_to_adf(&md).unwrap();
11232 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11233 let ms_w = &ms_attrs["width"];
11234 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
11235 assert_eq!(ms_w.as_i64(), Some(1420));
11236 let media = &rt.content[0].content.as_ref().unwrap()[0];
11237 let media_attrs = media.attrs.as_ref().unwrap();
11238 let mw = &media_attrs["width"];
11239 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
11240 assert_eq!(mw.as_i64(), Some(1200));
11241 let mh = &media_attrs["height"];
11242 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
11243 assert_eq!(mh.as_i64(), Some(800));
11244 }
11245
11246 #[test]
11248 fn issue_555_fmt_numeric_attr_preserves_type() {
11249 assert_eq!(
11250 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
11251 Some("50")
11252 );
11253 assert_eq!(
11254 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
11255 Some("50.0")
11256 );
11257 assert_eq!(
11258 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
11259 Some("66.66")
11260 );
11261 assert_eq!(
11262 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
11263 Some("-5")
11264 );
11265 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
11266 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
11268 assert_eq!(
11269 fmt_numeric_attr(&big).as_deref(),
11270 Some("18446744073709551615")
11271 );
11272 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
11274 }
11275
11276 #[test]
11278 fn issue_555_parse_numeric_attr_detects_type() {
11279 let v = parse_numeric_attr("800").unwrap();
11280 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
11281 assert_eq!(v.as_i64(), Some(800));
11282
11283 let v = parse_numeric_attr("800.0").unwrap();
11284 assert!(v.is_f64(), "'800.0' should parse as float");
11285 assert_eq!(v.as_f64(), Some(800.0));
11286
11287 let v = parse_numeric_attr("66.66").unwrap();
11288 assert!(v.is_f64());
11289 assert_eq!(v.as_f64(), Some(66.66));
11290
11291 let v = parse_numeric_attr("1e2").unwrap();
11293 assert!(v.is_f64());
11294 assert_eq!(v.as_f64(), Some(100.0));
11295 let v = parse_numeric_attr("1E2").unwrap();
11296 assert!(v.is_f64());
11297 assert_eq!(v.as_f64(), Some(100.0));
11298
11299 let v = parse_numeric_attr("-42").unwrap();
11301 assert!(v.is_i64());
11302 assert_eq!(v.as_i64(), Some(-42));
11303
11304 assert!(parse_numeric_attr("not-a-number").is_none());
11305 assert!(parse_numeric_attr("").is_none());
11306 assert!(parse_numeric_attr("1.2.3").is_none());
11307 }
11308
11309 #[test]
11312 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
11313 let adf_json = r#"{
11314 "version": 1,
11315 "type": "doc",
11316 "content": [{
11317 "type": "mediaSingle",
11318 "attrs": {"layout": "wide", "width": 83.33},
11319 "content": [
11320 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
11321 ]
11322 }]
11323 }"#;
11324 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11325 let md = adf_to_markdown(&doc).unwrap();
11326 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
11327 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
11328 let rt = markdown_to_adf(&md).unwrap();
11329 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11330 assert_eq!(ms_attrs["layout"], "wide");
11331 assert_eq!(ms_attrs["width"], 83.33);
11332 }
11333
11334 #[test]
11338 fn issue_555_file_media_single_fractional_media_width_preserved() {
11339 let adf_json = r#"{
11340 "version": 1,
11341 "type": "doc",
11342 "content": [{
11343 "type": "mediaSingle",
11344 "attrs": {"layout": "wide", "width": 66.5},
11345 "content": [
11346 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
11347 ]
11348 }]
11349 }"#;
11350 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11351 let md = adf_to_markdown(&doc).unwrap();
11352 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
11353 let rt = markdown_to_adf(&md).unwrap();
11354 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11355 assert_eq!(ms_attrs["width"], 66.5);
11356 }
11357
11358 #[test]
11362 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
11363 let adf_json = r#"{
11364 "version": 1,
11365 "type": "doc",
11366 "content": [{
11367 "type": "mediaSingle",
11368 "attrs": {"layout": "center"},
11369 "content": [
11370 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
11371 ]
11372 }]
11373 }"#;
11374 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11375 let md = adf_to_markdown(&doc).unwrap();
11376 assert!(md.contains("width=1200.5"), "width in md: {md}");
11377 assert!(md.contains("height=800.25"), "height in md: {md}");
11378 let rt = markdown_to_adf(&md).unwrap();
11379 let media = &rt.content[0].content.as_ref().unwrap()[0];
11380 let attrs = media.attrs.as_ref().unwrap();
11381 assert_eq!(attrs["width"], 1200.5);
11382 assert_eq!(attrs["height"], 800.25);
11383 }
11384
11385 #[test]
11386 fn decisions_list() {
11387 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
11388 let doc = markdown_to_adf(md).unwrap();
11389 assert_eq!(doc.content[0].node_type, "decisionList");
11390 let items = doc.content[0].content.as_ref().unwrap();
11391 assert_eq!(items.len(), 2);
11392 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
11393 }
11394
11395 #[test]
11398 fn decision_item_content_is_inline_not_paragraph() {
11399 let md = ":::decisions\n- <> Use Rust\n:::";
11400 let doc = markdown_to_adf(md).unwrap();
11401 let items = doc.content[0].content.as_ref().unwrap();
11402 let first_child = &items[0].content.as_ref().unwrap()[0];
11403 assert_eq!(
11404 first_child.node_type, "text",
11405 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
11406 );
11407 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
11408 }
11409
11410 #[test]
11411 fn adf_decisions_to_markdown() {
11412 let doc = AdfDocument {
11413 version: 1,
11414 doc_type: "doc".to_string(),
11415 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
11416 "DECIDED",
11417 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
11418 )])],
11419 };
11420 let md = adf_to_markdown(&doc).unwrap();
11421 assert!(md.contains(":::decisions"));
11422 assert!(md.contains("- <> Use PostgreSQL"));
11423 }
11424
11425 #[test]
11426 fn bodied_extension_container() {
11427 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
11428 let doc = markdown_to_adf(md).unwrap();
11429 assert_eq!(doc.content[0].node_type, "bodiedExtension");
11430 assert_eq!(
11431 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
11432 "com.forge"
11433 );
11434 }
11435
11436 #[test]
11437 fn adf_bodied_extension_to_markdown() {
11438 let doc = AdfDocument {
11439 version: 1,
11440 doc_type: "doc".to_string(),
11441 content: vec![AdfNode::bodied_extension(
11442 "com.forge",
11443 "my-macro",
11444 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
11445 )],
11446 };
11447 let md = adf_to_markdown(&doc).unwrap();
11448 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
11449 assert!(md.contains("Content."));
11450 }
11451
11452 #[test]
11455 fn leaf_block_card() {
11456 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
11457 assert_eq!(doc.content[0].node_type, "blockCard");
11458 assert_eq!(
11459 doc.content[0].attrs.as_ref().unwrap()["url"],
11460 "https://example.com/browse/PROJ-123"
11461 );
11462 }
11463
11464 #[test]
11465 fn adf_block_card_to_markdown() {
11466 let doc = AdfDocument {
11467 version: 1,
11468 doc_type: "doc".to_string(),
11469 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
11470 };
11471 let md = adf_to_markdown(&doc).unwrap();
11472 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
11473 }
11474
11475 #[test]
11476 fn round_trip_block_card() {
11477 let md = "::card[https://example.com/browse/PROJ-123]\n";
11478 let doc = markdown_to_adf(md).unwrap();
11479 let result = adf_to_markdown(&doc).unwrap();
11480 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
11481 }
11482
11483 #[test]
11484 fn leaf_embed_card() {
11485 let doc =
11486 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
11487 assert_eq!(doc.content[0].node_type, "embedCard");
11488 let attrs = doc.content[0].attrs.as_ref().unwrap();
11489 assert_eq!(attrs["url"], "https://figma.com/file/abc");
11490 assert_eq!(attrs["layout"], "wide");
11491 assert_eq!(attrs["width"], 80.0);
11492 }
11493
11494 #[test]
11495 fn leaf_embed_card_with_original_height() {
11496 let doc = markdown_to_adf(
11497 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
11498 )
11499 .unwrap();
11500 assert_eq!(doc.content[0].node_type, "embedCard");
11501 let attrs = doc.content[0].attrs.as_ref().unwrap();
11502 assert_eq!(attrs["url"], "https://example.com");
11503 assert_eq!(attrs["layout"], "center");
11504 assert_eq!(attrs["originalHeight"], 732.0);
11505 assert_eq!(attrs["width"], 100.0);
11506 }
11507
11508 #[test]
11509 fn adf_embed_card_to_markdown() {
11510 let doc = AdfDocument {
11511 version: 1,
11512 doc_type: "doc".to_string(),
11513 content: vec![AdfNode::embed_card(
11514 "https://figma.com/file/abc",
11515 Some("wide"),
11516 None,
11517 Some(80.0),
11518 )],
11519 };
11520 let md = adf_to_markdown(&doc).unwrap();
11521 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
11522 }
11523
11524 #[test]
11525 fn adf_embed_card_original_height_to_markdown() {
11526 let doc = AdfDocument {
11527 version: 1,
11528 doc_type: "doc".to_string(),
11529 content: vec![AdfNode::embed_card(
11530 "https://example.com",
11531 Some("center"),
11532 Some(732.0),
11533 Some(100.0),
11534 )],
11535 };
11536 let md = adf_to_markdown(&doc).unwrap();
11537 assert!(
11538 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
11539 "expected originalHeight and width in md: {md}"
11540 );
11541 }
11542
11543 #[test]
11544 fn embed_card_roundtrip_with_all_attrs() {
11545 let adf_json = r#"{"version":1,"type":"doc","content":[{
11546 "type":"embedCard",
11547 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
11548 }]}"#;
11549 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11550 let md = adf_to_markdown(&doc).unwrap();
11551 assert!(
11552 md.contains("originalHeight=732"),
11553 "originalHeight missing from md: {md}"
11554 );
11555 assert!(md.contains("width=100"), "width missing from md: {md}");
11556 let rt = markdown_to_adf(&md).unwrap();
11557 let attrs = rt.content[0].attrs.as_ref().unwrap();
11558 assert_eq!(attrs["originalHeight"], 732.0);
11559 assert_eq!(attrs["width"], 100.0);
11560 assert_eq!(attrs["layout"], "center");
11561 assert_eq!(attrs["url"], "https://example.com");
11562 }
11563
11564 #[test]
11565 fn embed_card_fractional_dimensions() {
11566 let doc = AdfDocument {
11567 version: 1,
11568 doc_type: "doc".to_string(),
11569 content: vec![AdfNode::embed_card(
11570 "https://example.com",
11571 Some("center"),
11572 Some(732.5),
11573 Some(99.9),
11574 )],
11575 };
11576 let md = adf_to_markdown(&doc).unwrap();
11577 assert!(
11578 md.contains("originalHeight=732.5"),
11579 "fractional originalHeight missing: {md}"
11580 );
11581 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
11582 let rt = markdown_to_adf(&md).unwrap();
11583 let attrs = rt.content[0].attrs.as_ref().unwrap();
11584 assert_eq!(attrs["originalHeight"], 732.5);
11585 assert_eq!(attrs["width"], 99.9);
11586 }
11587
11588 #[test]
11589 fn embed_card_integer_width_in_json() {
11590 let adf_json = r#"{"version":1,"type":"doc","content":[{
11592 "type":"embedCard",
11593 "attrs":{"url":"https://example.com","width":100}
11594 }]}"#;
11595 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11596 let md = adf_to_markdown(&doc).unwrap();
11597 assert!(
11598 md.contains("width=100"),
11599 "integer width missing from md: {md}"
11600 );
11601 let rt = markdown_to_adf(&md).unwrap();
11602 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
11603 }
11604
11605 #[test]
11606 fn embed_card_only_original_height() {
11607 let adf_json = r#"{"version":1,"type":"doc","content":[{
11609 "type":"embedCard",
11610 "attrs":{"url":"https://example.com","originalHeight":500.0}
11611 }]}"#;
11612 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11613 let md = adf_to_markdown(&doc).unwrap();
11614 assert!(
11615 md.contains("originalHeight=500"),
11616 "originalHeight missing: {md}"
11617 );
11618 assert!(!md.contains("width="), "width should not appear: {md}");
11619 let rt = markdown_to_adf(&md).unwrap();
11620 let attrs = rt.content[0].attrs.as_ref().unwrap();
11621 assert_eq!(attrs["originalHeight"], 500.0);
11622 assert!(attrs.get("width").is_none());
11623 }
11624
11625 #[test]
11626 fn leaf_void_extension() {
11627 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
11628 assert_eq!(doc.content[0].node_type, "extension");
11629 assert_eq!(
11630 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
11631 "com.atlassian.macro"
11632 );
11633 assert_eq!(
11634 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
11635 "jira-chart"
11636 );
11637 }
11638
11639 #[test]
11640 fn adf_void_extension_to_markdown() {
11641 let doc = AdfDocument {
11642 version: 1,
11643 doc_type: "doc".to_string(),
11644 content: vec![AdfNode::extension(
11645 "com.atlassian.macro",
11646 "jira-chart",
11647 None,
11648 )],
11649 };
11650 let md = adf_to_markdown(&doc).unwrap();
11651 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
11652 }
11653
11654 #[test]
11657 fn bare_url_autolink() {
11658 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
11659 let content = doc.content[0].content.as_ref().unwrap();
11660 assert_eq!(content[0].text.as_deref(), Some("Visit "));
11661 assert_eq!(content[1].node_type, "inlineCard");
11662 assert_eq!(
11663 content[1].attrs.as_ref().unwrap()["url"],
11664 "https://example.com"
11665 );
11666 assert_eq!(content[2].text.as_deref(), Some(" today"));
11667 }
11668
11669 #[test]
11670 fn bare_url_strips_trailing_punctuation() {
11671 let doc = markdown_to_adf("See https://example.com.").unwrap();
11672 let content = doc.content[0].content.as_ref().unwrap();
11673 assert_eq!(
11674 content[1].attrs.as_ref().unwrap()["url"],
11675 "https://example.com"
11676 );
11677 }
11678
11679 #[test]
11680 fn bare_url_round_trip() {
11681 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
11682 let md = adf_to_markdown(&doc).unwrap();
11683 assert!(md.contains(":card[https://example.com/path]"));
11684 }
11685
11686 #[test]
11689 fn plain_text_url_round_trips_as_text() {
11690 let adf_json = r#"{
11693 "version": 1,
11694 "type": "doc",
11695 "content": [{
11696 "type": "paragraph",
11697 "content": [
11698 {"type": "text", "text": "https://example.com/some/path/to/resource"}
11699 ]
11700 }]
11701 }"#;
11702 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11703 let jfm = adf_to_markdown(&adf).unwrap();
11704 let roundtripped = markdown_to_adf(&jfm).unwrap();
11705 let content = roundtripped.content[0].content.as_ref().unwrap();
11706 assert_eq!(content.len(), 1, "should be a single node");
11707 assert_eq!(content[0].node_type, "text");
11708 assert_eq!(
11709 content[0].text.as_deref(),
11710 Some("https://example.com/some/path/to/resource")
11711 );
11712 }
11713
11714 #[test]
11715 fn url_text_with_link_mark_round_trips_as_text_node() {
11716 let adf_json = r#"{
11720 "version": 1,
11721 "type": "doc",
11722 "content": [{
11723 "type": "paragraph",
11724 "content": [{
11725 "type": "text",
11726 "text": "https://octopz.example.com",
11727 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
11728 }]
11729 }]
11730 }"#;
11731 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11732 let jfm = adf_to_markdown(&adf).unwrap();
11733 let roundtripped = markdown_to_adf(&jfm).unwrap();
11734 let content = roundtripped.content[0].content.as_ref().unwrap();
11735 assert_eq!(content.len(), 1, "should be a single node");
11736 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
11737 assert_eq!(
11738 content[0].text.as_deref(),
11739 Some("https://octopz.example.com")
11740 );
11741 let mark = &content[0].marks.as_ref().unwrap()[0];
11742 assert_eq!(mark.mark_type, "link");
11743 assert_eq!(
11744 mark.attrs.as_ref().unwrap()["href"],
11745 "https://octopz.example.com/"
11746 );
11747 }
11748
11749 #[test]
11750 fn url_text_with_exact_link_mark_round_trips() {
11751 let adf_json = r#"{
11753 "version": 1,
11754 "type": "doc",
11755 "content": [{
11756 "type": "paragraph",
11757 "content": [{
11758 "type": "text",
11759 "text": "https://example.com/path",
11760 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
11761 }]
11762 }]
11763 }"#;
11764 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11765 let jfm = adf_to_markdown(&adf).unwrap();
11766 let roundtripped = markdown_to_adf(&jfm).unwrap();
11767 let content = roundtripped.content[0].content.as_ref().unwrap();
11768 assert_eq!(content.len(), 1, "should be a single node");
11769 assert_eq!(content[0].node_type, "text");
11770 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
11771 let mark = &content[0].marks.as_ref().unwrap()[0];
11772 assert_eq!(mark.mark_type, "link");
11773 }
11774
11775 #[test]
11776 fn plain_text_url_amid_text_round_trips() {
11777 let adf_json = r#"{
11779 "version": 1,
11780 "type": "doc",
11781 "content": [{
11782 "type": "paragraph",
11783 "content": [
11784 {"type": "text", "text": "see https://example.com for info"}
11785 ]
11786 }]
11787 }"#;
11788 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11789 let jfm = adf_to_markdown(&adf).unwrap();
11790 let roundtripped = markdown_to_adf(&jfm).unwrap();
11791 let content = roundtripped.content[0].content.as_ref().unwrap();
11792 assert_eq!(content.len(), 1);
11793 assert_eq!(content[0].node_type, "text");
11794 assert_eq!(
11795 content[0].text.as_deref(),
11796 Some("see https://example.com for info")
11797 );
11798 }
11799
11800 #[test]
11801 fn plain_text_multiple_urls_round_trips() {
11802 let adf_json = r#"{
11803 "version": 1,
11804 "type": "doc",
11805 "content": [{
11806 "type": "paragraph",
11807 "content": [
11808 {"type": "text", "text": "http://a.com and https://b.com"}
11809 ]
11810 }]
11811 }"#;
11812 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11813 let jfm = adf_to_markdown(&adf).unwrap();
11814 let roundtripped = markdown_to_adf(&jfm).unwrap();
11815 let content = roundtripped.content[0].content.as_ref().unwrap();
11816 assert_eq!(content.len(), 1);
11817 assert_eq!(content[0].node_type, "text");
11818 assert_eq!(
11819 content[0].text.as_deref(),
11820 Some("http://a.com and https://b.com")
11821 );
11822 }
11823
11824 #[test]
11825 fn plain_text_http_prefix_no_url_unchanged() {
11826 let adf_json = r#"{
11828 "version": 1,
11829 "type": "doc",
11830 "content": [{
11831 "type": "paragraph",
11832 "content": [
11833 {"type": "text", "text": "the http header is important"}
11834 ]
11835 }]
11836 }"#;
11837 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11838 let jfm = adf_to_markdown(&adf).unwrap();
11839 let roundtripped = markdown_to_adf(&jfm).unwrap();
11840 let content = roundtripped.content[0].content.as_ref().unwrap();
11841 assert_eq!(
11842 content[0].text.as_deref(),
11843 Some("the http header is important")
11844 );
11845 }
11846
11847 #[test]
11848 fn linked_url_text_round_trips() {
11849 let adf_json = r#"{
11853 "version": 1,
11854 "type": "doc",
11855 "content": [{
11856 "type": "paragraph",
11857 "content": [{
11858 "type": "text",
11859 "text": "https://example.com",
11860 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
11861 }]
11862 }]
11863 }"#;
11864 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11865 let jfm = adf_to_markdown(&adf).unwrap();
11866 let roundtripped = markdown_to_adf(&jfm).unwrap();
11867 let content = roundtripped.content[0].content.as_ref().unwrap();
11868 assert_eq!(content.len(), 1);
11869 assert_eq!(content[0].node_type, "text");
11870 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
11871 let mark = &content[0].marks.as_ref().unwrap()[0];
11872 assert_eq!(mark.mark_type, "link");
11873 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
11874 }
11875
11876 #[test]
11879 fn escape_link_brackets_unit() {
11880 assert_eq!(escape_link_brackets("hello"), "hello");
11881 assert_eq!(escape_link_brackets("["), "\\[");
11882 assert_eq!(escape_link_brackets("]"), "\\]");
11883 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
11884 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
11885 }
11886
11887 #[test]
11888 fn bracket_text_with_link_mark_escapes_brackets() {
11889 let doc = AdfDocument {
11892 version: 1,
11893 doc_type: "doc".to_string(),
11894 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11895 "[",
11896 vec![AdfMark::link("https://example.com")],
11897 )])],
11898 };
11899 let md = adf_to_markdown(&doc).unwrap();
11900 assert_eq!(md.trim(), "[\\[](https://example.com)");
11901 }
11902
11903 #[test]
11904 fn bracket_text_with_link_mark_round_trips() {
11905 let adf_json = r#"{
11908 "type": "doc",
11909 "version": 1,
11910 "content": [{
11911 "type": "paragraph",
11912 "content": [
11913 {
11914 "type": "text",
11915 "text": "[",
11916 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
11917 },
11918 {
11919 "type": "text",
11920 "text": "PROJ-456] Fix the auth bug",
11921 "marks": [
11922 {"type": "underline"},
11923 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
11924 ]
11925 }
11926 ]
11927 }]
11928 }"#;
11929 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11930 let jfm = adf_to_markdown(&adf).unwrap();
11931
11932 assert!(jfm.contains("\\["), "opening bracket should be escaped");
11934
11935 let rt = markdown_to_adf(&jfm).unwrap();
11937 let content = rt.content[0].content.as_ref().unwrap();
11938
11939 let link_nodes: Vec<_> = content
11941 .iter()
11942 .filter(|n| {
11943 n.marks
11944 .as_ref()
11945 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
11946 })
11947 .collect();
11948 assert!(
11949 !link_nodes.is_empty(),
11950 "link mark must be preserved on round-trip"
11951 );
11952
11953 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
11955 assert!(
11956 all_text.contains('['),
11957 "literal '[' must survive round-trip"
11958 );
11959 assert!(
11960 all_text.contains("PROJ-456]"),
11961 "continuation text must survive round-trip"
11962 );
11963 }
11964
11965 #[test]
11966 fn closing_bracket_in_link_text_round_trips() {
11967 let doc = AdfDocument {
11970 version: 1,
11971 doc_type: "doc".to_string(),
11972 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11973 "item]",
11974 vec![AdfMark::link("https://example.com")],
11975 )])],
11976 };
11977 let md = adf_to_markdown(&doc).unwrap();
11978 assert_eq!(md.trim(), "[item\\]](https://example.com)");
11979
11980 let rt = markdown_to_adf(&md).unwrap();
11981 let content = rt.content[0].content.as_ref().unwrap();
11982 assert_eq!(content[0].text.as_deref(), Some("item]"));
11983 assert!(content[0]
11984 .marks
11985 .as_ref()
11986 .unwrap()
11987 .iter()
11988 .any(|m| m.mark_type == "link"));
11989 }
11990
11991 #[test]
11992 fn brackets_in_link_text_round_trip() {
11993 let doc = AdfDocument {
11995 version: 1,
11996 doc_type: "doc".to_string(),
11997 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11998 "[PROJ-123]",
11999 vec![AdfMark::link("https://example.com")],
12000 )])],
12001 };
12002 let md = adf_to_markdown(&doc).unwrap();
12003 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
12004
12005 let rt = markdown_to_adf(&md).unwrap();
12006 let content = rt.content[0].content.as_ref().unwrap();
12007 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
12008 assert!(content[0]
12009 .marks
12010 .as_ref()
12011 .unwrap()
12012 .iter()
12013 .any(|m| m.mark_type == "link"));
12014 }
12015
12016 #[test]
12017 fn plain_text_brackets_not_escaped() {
12018 let doc = AdfDocument {
12020 version: 1,
12021 doc_type: "doc".to_string(),
12022 content: vec![AdfNode::paragraph(vec![AdfNode::text(
12023 "see [PROJ-123] for details",
12024 )])],
12025 };
12026 let md = adf_to_markdown(&doc).unwrap();
12027 assert_eq!(md.trim(), "see [PROJ-123] for details");
12028 }
12029
12030 #[test]
12031 fn link_with_no_brackets_unchanged() {
12032 let doc = AdfDocument {
12034 version: 1,
12035 doc_type: "doc".to_string(),
12036 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12037 "click here",
12038 vec![AdfMark::link("https://example.com")],
12039 )])],
12040 };
12041 let md = adf_to_markdown(&doc).unwrap();
12042 assert_eq!(md.trim(), "[click here](https://example.com)");
12043 }
12044
12045 #[test]
12048 fn url_with_brackets_as_link_text_round_trips() {
12049 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
12054 let doc = AdfDocument {
12055 version: 1,
12056 doc_type: "doc".to_string(),
12057 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12058 href,
12059 vec![AdfMark::link(href)],
12060 )])],
12061 };
12062 let md = adf_to_markdown(&doc).unwrap();
12063 let rt = markdown_to_adf(&md).unwrap();
12064 let content = rt.content[0].content.as_ref().unwrap();
12065 assert_eq!(content.len(), 1);
12066 assert_eq!(content[0].node_type, "text");
12067 assert_eq!(content[0].text.as_deref(), Some(href));
12068 let mark = &content[0].marks.as_ref().unwrap()[0];
12069 assert_eq!(mark.mark_type, "link");
12070 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12071 }
12072
12073 #[test]
12074 fn url_with_brackets_embedded_in_link_text_round_trips() {
12075 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
12082 let text =
12083 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
12084 let doc = AdfDocument {
12085 version: 1,
12086 doc_type: "doc".to_string(),
12087 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12088 text,
12089 vec![AdfMark::link(href)],
12090 )])],
12091 };
12092 let md = adf_to_markdown(&doc).unwrap();
12093 let rt = markdown_to_adf(&md).unwrap();
12094 let content = rt.content[0].content.as_ref().unwrap();
12095 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
12096 assert_eq!(content[0].node_type, "text");
12097 assert_eq!(content[0].text.as_deref(), Some(text));
12098 let mark = &content[0].marks.as_ref().unwrap()[0];
12099 assert_eq!(mark.mark_type, "link");
12100 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12101 }
12102
12103 #[test]
12104 fn url_with_brackets_plain_text_round_trips() {
12105 let text =
12108 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
12109 let doc = AdfDocument {
12110 version: 1,
12111 doc_type: "doc".to_string(),
12112 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
12113 };
12114 let md = adf_to_markdown(&doc).unwrap();
12115 let rt = markdown_to_adf(&md).unwrap();
12116 let content = rt.content[0].content.as_ref().unwrap();
12117 assert_eq!(content.len(), 1);
12118 assert_eq!(content[0].node_type, "text");
12119 assert_eq!(content[0].text.as_deref(), Some(text));
12120 assert!(content[0].marks.is_none());
12121 }
12122
12123 #[test]
12124 fn url_with_link_mark_embedded_no_brackets_round_trips() {
12125 let href = "https://example.com/";
12128 let text = "See https://example.com/ for more";
12129 let doc = AdfDocument {
12130 version: 1,
12131 doc_type: "doc".to_string(),
12132 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12133 text,
12134 vec![AdfMark::link(href)],
12135 )])],
12136 };
12137 let md = adf_to_markdown(&doc).unwrap();
12138 let rt = markdown_to_adf(&md).unwrap();
12139 let content = rt.content[0].content.as_ref().unwrap();
12140 assert_eq!(content.len(), 1);
12141 assert_eq!(content[0].node_type, "text");
12142 assert_eq!(content[0].text.as_deref(), Some(text));
12143 let mark = &content[0].marks.as_ref().unwrap()[0];
12144 assert_eq!(mark.mark_type, "link");
12145 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12146 }
12147
12148 #[test]
12149 fn nested_brackets_in_link_text_round_trip() {
12150 let href = "https://x.com";
12153 let text = "foo [a[b]c] bar";
12154 let doc = AdfDocument {
12155 version: 1,
12156 doc_type: "doc".to_string(),
12157 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12158 text,
12159 vec![AdfMark::link(href)],
12160 )])],
12161 };
12162 let md = adf_to_markdown(&doc).unwrap();
12163 let rt = markdown_to_adf(&md).unwrap();
12164 let content = rt.content[0].content.as_ref().unwrap();
12165 assert_eq!(content.len(), 1);
12166 assert_eq!(content[0].node_type, "text");
12167 assert_eq!(content[0].text.as_deref(), Some(text));
12168 }
12169
12170 #[test]
12171 fn bracket_url_bracket_in_link_text_round_trips() {
12172 let href = "https://y.com";
12178 let text = "[see https://x.com/a[0]=1 here]";
12179 let doc = AdfDocument {
12180 version: 1,
12181 doc_type: "doc".to_string(),
12182 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12183 text,
12184 vec![AdfMark::link(href)],
12185 )])],
12186 };
12187 let md = adf_to_markdown(&doc).unwrap();
12188 let rt = markdown_to_adf(&md).unwrap();
12189 let content = rt.content[0].content.as_ref().unwrap();
12190 assert_eq!(content.len(), 1);
12191 assert_eq!(content[0].node_type, "text");
12192 assert_eq!(content[0].text.as_deref(), Some(text));
12193 let mark = &content[0].marks.as_ref().unwrap()[0];
12194 assert_eq!(mark.mark_type, "link");
12195 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12196 }
12197
12198 #[test]
12199 fn escape_bare_urls_applied_inside_link_text() {
12200 let doc = AdfDocument {
12206 version: 1,
12207 doc_type: "doc".to_string(),
12208 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12209 "See https://example.com/",
12210 vec![AdfMark::link("https://target.example.com/")],
12211 )])],
12212 };
12213 let md = adf_to_markdown(&doc).unwrap();
12214 assert!(
12215 md.contains(r"\https://example.com/"),
12216 "bare URL inside link text must be escaped, got: {md}"
12217 );
12218 }
12219
12220 #[test]
12221 fn inline_card_still_round_trips() {
12222 let adf_json = r#"{
12225 "version": 1,
12226 "type": "doc",
12227 "content": [{
12228 "type": "paragraph",
12229 "content": [
12230 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
12231 ]
12232 }]
12233 }"#;
12234 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12235 let jfm = adf_to_markdown(&adf).unwrap();
12236 assert!(jfm.contains(":card[https://example.com/page]"));
12237 let roundtripped = markdown_to_adf(&jfm).unwrap();
12238 let content = roundtripped.content[0].content.as_ref().unwrap();
12239 assert_eq!(content[0].node_type, "inlineCard");
12240 assert_eq!(
12241 content[0].attrs.as_ref().unwrap()["url"],
12242 "https://example.com/page"
12243 );
12244 }
12245
12246 #[test]
12249 fn url_safe_in_bracket_content_balanced() {
12250 assert!(url_safe_in_bracket_content("https://example.com"));
12252 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
12253 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
12254 assert!(url_safe_in_bracket_content(""));
12255 }
12256
12257 #[test]
12258 fn url_safe_in_bracket_content_unbalanced() {
12259 assert!(!url_safe_in_bracket_content("a]b"));
12261 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
12262 assert!(!url_safe_in_bracket_content("a\nb"));
12264 }
12265
12266 #[test]
12267 fn inline_card_url_with_closing_bracket_round_trip() {
12268 let adf_json = r#"{
12273 "version": 1,
12274 "type": "doc",
12275 "content": [{
12276 "type": "paragraph",
12277 "content": [
12278 {"type": "text", "text": "See: "},
12279 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
12280 ]
12281 }]
12282 }"#;
12283 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12284 let jfm = adf_to_markdown(&adf).unwrap();
12285 assert!(
12286 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
12287 "expected attr-form for URL with `]`, got: {jfm}"
12288 );
12289 let rt = markdown_to_adf(&jfm).unwrap();
12290 let content = rt.content[0].content.as_ref().unwrap();
12291 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
12292 assert_eq!(content[0].node_type, "text");
12293 assert_eq!(content[0].text.as_deref(), Some("See: "));
12294 assert_eq!(content[1].node_type, "inlineCard");
12295 assert_eq!(
12296 content[1].attrs.as_ref().unwrap()["url"],
12297 "https://example.com/path]end/?q=1"
12298 );
12299 }
12300
12301 #[test]
12302 fn inline_card_url_with_closing_bracket_preserves_local_id() {
12303 let adf_json = r#"{
12305 "version": 1,
12306 "type": "doc",
12307 "content": [{
12308 "type": "paragraph",
12309 "content": [
12310 {"type": "inlineCard", "attrs": {
12311 "url": "https://example.com/a]b",
12312 "localId": "c-77"
12313 }}
12314 ]
12315 }]
12316 }"#;
12317 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12318 let jfm = adf_to_markdown(&adf).unwrap();
12319 assert!(
12320 jfm.contains(r#"url="https://example.com/a]b""#),
12321 "jfm: {jfm}"
12322 );
12323 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
12324 let rt = markdown_to_adf(&jfm).unwrap();
12325 let card = &rt.content[0].content.as_ref().unwrap()[0];
12326 assert_eq!(card.node_type, "inlineCard");
12327 assert_eq!(
12328 card.attrs.as_ref().unwrap()["url"],
12329 "https://example.com/a]b"
12330 );
12331 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
12332 }
12333
12334 #[test]
12335 fn block_card_url_with_closing_bracket_round_trip() {
12336 let adf_json = r#"{
12338 "version": 1,
12339 "type": "doc",
12340 "content": [
12341 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
12342 ]
12343 }"#;
12344 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12345 let jfm = adf_to_markdown(&adf).unwrap();
12346 assert!(
12347 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
12348 "expected attr-form for blockCard with `]`, got: {jfm}"
12349 );
12350 let rt = markdown_to_adf(&jfm).unwrap();
12351 assert_eq!(rt.content[0].node_type, "blockCard");
12352 assert_eq!(
12353 rt.content[0].attrs.as_ref().unwrap()["url"],
12354 "https://example.com/path]end"
12355 );
12356 }
12357
12358 #[test]
12359 fn block_card_attr_form_parses_without_renderer() {
12360 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
12364 assert_eq!(doc.content[0].node_type, "blockCard");
12365 assert_eq!(
12366 doc.content[0].attrs.as_ref().unwrap()["url"],
12367 "https://example.com/a"
12368 );
12369 }
12370
12371 #[test]
12372 fn block_card_attr_form_url_overrides_content() {
12373 let doc =
12377 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
12378 .unwrap();
12379 assert_eq!(doc.content[0].node_type, "blockCard");
12380 assert_eq!(
12381 doc.content[0].attrs.as_ref().unwrap()["url"],
12382 "https://new.example.com"
12383 );
12384 }
12385
12386 #[test]
12387 fn block_card_attr_form_with_layout_and_width() {
12388 let doc =
12391 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
12392 .unwrap();
12393 let attrs = doc.content[0].attrs.as_ref().unwrap();
12394 assert_eq!(attrs["url"], "https://example.com/a]b");
12395 assert_eq!(attrs["layout"], "wide");
12396 assert_eq!(attrs["width"], 80);
12397 }
12398
12399 #[test]
12400 fn inline_card_issue_553_reproducer() {
12401 let adf_json = r#"{
12405 "version": 1,
12406 "type": "doc",
12407 "content": [{
12408 "type": "paragraph",
12409 "content": [
12410 {"type": "text", "text": "See the related page: "},
12411 {"type": "inlineCard", "attrs": {
12412 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
12413 }}
12414 ]
12415 }]
12416 }"#;
12417 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12418 let jfm = adf_to_markdown(&adf).unwrap();
12419 let rt = markdown_to_adf(&jfm).unwrap();
12420 let content = rt.content[0].content.as_ref().unwrap();
12421 assert_eq!(content.len(), 2);
12422 assert_eq!(content[0].node_type, "text");
12423 assert_eq!(content[1].node_type, "inlineCard");
12424 assert_eq!(
12425 content[1].attrs.as_ref().unwrap()["url"],
12426 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
12427 );
12428 }
12429
12430 #[test]
12431 fn inline_card_attr_form_parses_even_without_renderer() {
12432 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
12434 let node = &doc.content[0].content.as_ref().unwrap()[0];
12435 assert_eq!(node.node_type, "inlineCard");
12436 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
12437 }
12438
12439 #[test]
12440 fn inline_card_attr_form_url_overrides_content() {
12441 let doc =
12445 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
12446 .unwrap();
12447 let node = &doc.content[0].content.as_ref().unwrap()[0];
12448 assert_eq!(node.node_type, "inlineCard");
12449 assert_eq!(
12450 node.attrs.as_ref().unwrap()["url"],
12451 "https://new.example.com"
12452 );
12453 }
12454
12455 #[test]
12458 fn url_with_link_and_underline_marks_round_trip() {
12459 let adf_json = r#"{
12463 "version": 1,
12464 "type": "doc",
12465 "content": [{
12466 "type": "paragraph",
12467 "content": [
12468 {"type": "text", "text": "See results at: "},
12469 {"type": "text",
12470 "text": "https://example.com/projects/abc123/analytics",
12471 "marks": [
12472 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
12473 {"type": "underline"}
12474 ]},
12475 {"type": "text", "text": " for details."}
12476 ]
12477 }]
12478 }"#;
12479 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12480 let jfm = adf_to_markdown(&adf).unwrap();
12481 let rt = markdown_to_adf(&jfm).unwrap();
12482 let content = rt.content[0].content.as_ref().unwrap();
12483 assert_eq!(
12484 content.len(),
12485 3,
12486 "expected 3 inline nodes, got: {content:?}"
12487 );
12488 assert_eq!(content[0].node_type, "text");
12489 assert_eq!(
12490 content[1].node_type, "text",
12491 "must stay text, not inlineCard"
12492 );
12493 assert_eq!(
12494 content[1].text.as_deref(),
12495 Some("https://example.com/projects/abc123/analytics")
12496 );
12497 let mark_types: Vec<&str> = content[1]
12498 .marks
12499 .as_deref()
12500 .unwrap_or(&[])
12501 .iter()
12502 .map(|m| m.mark_type.as_str())
12503 .collect();
12504 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
12505 assert_eq!(content[2].node_type, "text");
12506 }
12507
12508 #[test]
12509 fn url_inside_bracketed_span_stays_text() {
12510 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
12514 let node = &doc.content[0].content.as_ref().unwrap()[0];
12515 assert_eq!(node.node_type, "text");
12516 assert_eq!(node.text.as_deref(), Some("https://example.com"));
12517 let mark_types: Vec<&str> = node
12518 .marks
12519 .as_deref()
12520 .unwrap_or(&[])
12521 .iter()
12522 .map(|m| m.mark_type.as_str())
12523 .collect();
12524 assert_eq!(mark_types, vec!["underline"]);
12525 }
12526
12527 #[test]
12528 fn url_inside_emphasis_stays_text() {
12529 for (md, mark) in [
12532 ("**https://example.com**", "strong"),
12533 ("*https://example.com*", "em"),
12534 ("~~https://example.com~~", "strike"),
12535 ] {
12536 let doc = markdown_to_adf(md).unwrap();
12537 let node = &doc.content[0].content.as_ref().unwrap()[0];
12538 assert_eq!(node.node_type, "text", "md={md}: must be text");
12539 assert_eq!(node.text.as_deref(), Some("https://example.com"));
12540 let mark_types: Vec<&str> = node
12541 .marks
12542 .as_deref()
12543 .unwrap_or(&[])
12544 .iter()
12545 .map(|m| m.mark_type.as_str())
12546 .collect();
12547 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
12548 }
12549 }
12550
12551 #[test]
12552 fn url_inside_span_directive_stays_text() {
12553 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
12555 let node = &doc.content[0].content.as_ref().unwrap()[0];
12556 assert_eq!(node.node_type, "text");
12557 assert_eq!(node.text.as_deref(), Some("https://example.com"));
12558 let mark = &node.marks.as_ref().unwrap()[0];
12559 assert_eq!(mark.mark_type, "textColor");
12560 }
12561
12562 #[test]
12563 fn url_as_link_text_with_underline_after_link_mark_order() {
12564 let adf_json = r#"{
12568 "version": 1,
12569 "type": "doc",
12570 "content": [{
12571 "type": "paragraph",
12572 "content": [
12573 {"type": "text",
12574 "text": "https://example.com",
12575 "marks": [
12576 {"type": "underline"},
12577 {"type": "link", "attrs": {"href": "https://example.com"}}
12578 ]}
12579 ]
12580 }]
12581 }"#;
12582 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583 let jfm = adf_to_markdown(&adf).unwrap();
12584 let rt = markdown_to_adf(&jfm).unwrap();
12585 let node = &rt.content[0].content.as_ref().unwrap()[0];
12586 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
12587 assert_eq!(node.text.as_deref(), Some("https://example.com"));
12588 let mark_types: Vec<&str> = node
12589 .marks
12590 .as_deref()
12591 .unwrap_or(&[])
12592 .iter()
12593 .map(|m| m.mark_type.as_str())
12594 .collect();
12595 assert_eq!(mark_types, vec!["underline", "link"]);
12596 }
12597
12598 #[test]
12599 fn bare_url_at_top_level_still_becomes_inline_card() {
12600 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
12604 let content = doc.content[0].content.as_ref().unwrap();
12605 assert_eq!(content.len(), 3);
12606 assert_eq!(content[0].node_type, "text");
12607 assert_eq!(content[1].node_type, "inlineCard");
12608 assert_eq!(
12609 content[1].attrs.as_ref().unwrap()["url"],
12610 "https://example.com"
12611 );
12612 assert_eq!(content[2].node_type, "text");
12613 }
12614
12615 #[test]
12618 fn paragraph_align_center() {
12619 let md = "Centered text.\n{align=center}";
12620 let doc = markdown_to_adf(md).unwrap();
12621 let marks = doc.content[0].marks.as_ref().unwrap();
12622 assert_eq!(marks[0].mark_type, "alignment");
12623 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
12624 }
12625
12626 #[test]
12627 fn adf_alignment_to_markdown() {
12628 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
12629 node.marks = Some(vec![AdfMark::alignment("center")]);
12630 let doc = AdfDocument {
12631 version: 1,
12632 doc_type: "doc".to_string(),
12633 content: vec![node],
12634 };
12635 let md = adf_to_markdown(&doc).unwrap();
12636 assert!(md.contains("Centered."));
12637 assert!(md.contains("{align=center}"));
12638 }
12639
12640 #[test]
12641 fn round_trip_alignment() {
12642 let md = "Centered.\n{align=center}\n";
12643 let doc = markdown_to_adf(md).unwrap();
12644 let result = adf_to_markdown(&doc).unwrap();
12645 assert!(result.contains("{align=center}"));
12646 }
12647
12648 #[test]
12649 fn paragraph_indent() {
12650 let md = "Indented.\n{indent=2}";
12651 let doc = markdown_to_adf(md).unwrap();
12652 let marks = doc.content[0].marks.as_ref().unwrap();
12653 assert_eq!(marks[0].mark_type, "indentation");
12654 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
12655 }
12656
12657 #[test]
12658 fn code_block_breakout() {
12659 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
12660 let doc = markdown_to_adf(md).unwrap();
12661 let marks = doc.content[0].marks.as_ref().unwrap();
12662 assert_eq!(marks[0].mark_type, "breakout");
12663 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
12664 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
12665 }
12666
12667 #[test]
12668 fn code_block_breakout_with_width() {
12669 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
12670 let doc = markdown_to_adf(md).unwrap();
12671 let marks = doc.content[0].marks.as_ref().unwrap();
12672 assert_eq!(marks[0].mark_type, "breakout");
12673 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
12674 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
12675 }
12676
12677 #[test]
12678 fn adf_breakout_to_markdown() {
12679 let mut node = AdfNode::code_block(Some("python"), "pass");
12680 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
12681 let doc = AdfDocument {
12682 version: 1,
12683 doc_type: "doc".to_string(),
12684 content: vec![node],
12685 };
12686 let md = adf_to_markdown(&doc).unwrap();
12687 assert!(md.contains("{breakout=wide}"));
12688 assert!(!md.contains("breakoutWidth"));
12689 }
12690
12691 #[test]
12692 fn adf_breakout_with_width_to_markdown() {
12693 let mut node = AdfNode::code_block(Some("python"), "pass");
12694 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
12695 let doc = AdfDocument {
12696 version: 1,
12697 doc_type: "doc".to_string(),
12698 content: vec![node],
12699 };
12700 let md = adf_to_markdown(&doc).unwrap();
12701 assert!(md.contains("breakout=wide"));
12702 assert!(md.contains("breakoutWidth=1200"));
12703 }
12704
12705 #[test]
12706 fn breakout_width_round_trip() {
12707 let adf_json = r#"{"version":1,"type":"doc","content":[{
12708 "type":"codeBlock",
12709 "attrs":{"language":"text"},
12710 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
12711 "content":[{"type":"text","text":"some code"}]
12712 }]}"#;
12713 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12714 let md = adf_to_markdown(&doc).unwrap();
12715 assert!(md.contains("breakout=wide"));
12716 assert!(md.contains("breakoutWidth=1200"));
12717 let round_tripped = markdown_to_adf(&md).unwrap();
12718 let marks = round_tripped.content[0].marks.as_ref().unwrap();
12719 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
12720 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
12721 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
12722 }
12723
12724 #[test]
12727 fn image_with_layout_attrs() {
12728 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
12729 let node = &doc.content[0];
12730 assert_eq!(node.node_type, "mediaSingle");
12731 let attrs = node.attrs.as_ref().unwrap();
12732 assert_eq!(attrs["layout"], "wide");
12733 assert_eq!(attrs["width"], 80);
12734 }
12735
12736 #[test]
12737 fn adf_image_with_layout_to_markdown() {
12738 let mut node = AdfNode::media_single("url", Some("alt"));
12739 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
12740 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
12741 let doc = AdfDocument {
12742 version: 1,
12743 doc_type: "doc".to_string(),
12744 content: vec![node],
12745 };
12746 let md = adf_to_markdown(&doc).unwrap();
12747 assert!(md.contains("{layout=wide width=80}"));
12748 }
12749
12750 #[test]
12751 fn table_with_layout_attrs() {
12752 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
12753 let doc = markdown_to_adf(md).unwrap();
12754 let table = &doc.content[0];
12755 assert_eq!(table.node_type, "table");
12756 let attrs = table.attrs.as_ref().unwrap();
12757 assert_eq!(attrs["layout"], "wide");
12758 assert_eq!(attrs["isNumberColumnEnabled"], true);
12759 }
12760
12761 #[test]
12762 fn adf_table_with_attrs_to_markdown() {
12763 let mut table = AdfNode::table(vec![
12764 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
12765 AdfNode::text("H"),
12766 ])])]),
12767 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
12768 AdfNode::text("C"),
12769 ])])]),
12770 ]);
12771 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
12772 let doc = AdfDocument {
12773 version: 1,
12774 doc_type: "doc".to_string(),
12775 content: vec![table],
12776 };
12777 let md = adf_to_markdown(&doc).unwrap();
12778 assert!(md.contains("{layout=wide numbered}"));
12779 }
12780
12781 #[test]
12784 fn underline_bracketed_span() {
12785 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
12786 let content = doc.content[0].content.as_ref().unwrap();
12787 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
12788 let marks = content[1].marks.as_ref().unwrap();
12789 assert_eq!(marks[0].mark_type, "underline");
12790 }
12791
12792 #[test]
12793 fn adf_underline_to_markdown() {
12794 let doc = AdfDocument {
12795 version: 1,
12796 doc_type: "doc".to_string(),
12797 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12798 "underlined",
12799 vec![AdfMark::underline()],
12800 )])],
12801 };
12802 let md = adf_to_markdown(&doc).unwrap();
12803 assert!(md.contains("[underlined]{underline}"));
12804 }
12805
12806 #[test]
12807 fn round_trip_underline() {
12808 let md = "This is [underlined text]{underline} here.\n";
12809 let doc = markdown_to_adf(md).unwrap();
12810 let result = adf_to_markdown(&doc).unwrap();
12811 assert!(result.contains("[underlined text]{underline}"));
12812 }
12813
12814 #[test]
12815 fn mark_ordering_underline_strong_preserved() {
12816 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12818 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
12819 ]}]}"#;
12820 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12821 let md = adf_to_markdown(&doc).unwrap();
12822 let round_tripped = markdown_to_adf(&md).unwrap();
12823 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12824 let mark_types: Vec<&str> = node
12825 .marks
12826 .as_ref()
12827 .unwrap()
12828 .iter()
12829 .map(|m| m.mark_type.as_str())
12830 .collect();
12831 assert_eq!(
12832 mark_types,
12833 vec!["underline", "strong"],
12834 "mark order should be preserved, got: {mark_types:?}"
12835 );
12836 }
12837
12838 #[test]
12839 fn mark_ordering_link_strong_preserved() {
12840 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12842 {"type":"text","text":"bold link","marks":[
12843 {"type":"link","attrs":{"href":"https://example.com"}},
12844 {"type":"strong"}
12845 ]}
12846 ]}]}"#;
12847 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12848 let md = adf_to_markdown(&doc).unwrap();
12849 let round_tripped = markdown_to_adf(&md).unwrap();
12850 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12851 let mark_types: Vec<&str> = node
12852 .marks
12853 .as_ref()
12854 .unwrap()
12855 .iter()
12856 .map(|m| m.mark_type.as_str())
12857 .collect();
12858 assert_eq!(
12859 mark_types,
12860 vec!["link", "strong"],
12861 "mark order should be preserved, got: {mark_types:?}"
12862 );
12863 }
12864
12865 #[test]
12866 fn mark_ordering_link_textcolor_preserved() {
12867 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12869 {"type":"text","text":"red link","marks":[
12870 {"type":"link","attrs":{"href":"https://example.com"}},
12871 {"type":"textColor","attrs":{"color":"#ff0000"}}
12872 ]}
12873 ]}]}"##;
12874 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12875 let md = adf_to_markdown(&doc).unwrap();
12876 let round_tripped = markdown_to_adf(&md).unwrap();
12877 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12878 let mark_types: Vec<&str> = node
12879 .marks
12880 .as_ref()
12881 .unwrap()
12882 .iter()
12883 .map(|m| m.mark_type.as_str())
12884 .collect();
12885 assert_eq!(
12886 mark_types,
12887 vec!["link", "textColor"],
12888 "mark order should be preserved, got: {mark_types:?}"
12889 );
12890 }
12891
12892 #[test]
12893 fn mark_ordering_link_em_preserved() {
12894 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12896 {"type":"text","text":"italic link","marks":[
12897 {"type":"link","attrs":{"href":"https://example.com"}},
12898 {"type":"em"}
12899 ]}
12900 ]}]}"#;
12901 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12902 let md = adf_to_markdown(&doc).unwrap();
12903 let round_tripped = markdown_to_adf(&md).unwrap();
12904 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12905 let mark_types: Vec<&str> = node
12906 .marks
12907 .as_ref()
12908 .unwrap()
12909 .iter()
12910 .map(|m| m.mark_type.as_str())
12911 .collect();
12912 assert_eq!(
12913 mark_types,
12914 vec!["link", "em"],
12915 "mark order should be preserved, got: {mark_types:?}"
12916 );
12917 }
12918
12919 #[test]
12920 fn mark_ordering_link_strike_preserved() {
12921 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12923 {"type":"text","text":"struck link","marks":[
12924 {"type":"link","attrs":{"href":"https://example.com"}},
12925 {"type":"strike"}
12926 ]}
12927 ]}]}"#;
12928 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12929 let md = adf_to_markdown(&doc).unwrap();
12930 let round_tripped = markdown_to_adf(&md).unwrap();
12931 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12932 let mark_types: Vec<&str> = node
12933 .marks
12934 .as_ref()
12935 .unwrap()
12936 .iter()
12937 .map(|m| m.mark_type.as_str())
12938 .collect();
12939 assert_eq!(
12940 mark_types,
12941 vec!["link", "strike"],
12942 "mark order should be preserved, got: {mark_types:?}"
12943 );
12944 }
12945
12946 #[test]
12947 fn mark_ordering_strong_link_preserved() {
12948 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12950 {"type":"text","text":"bold link","marks":[
12951 {"type":"strong"},
12952 {"type":"link","attrs":{"href":"https://example.com"}}
12953 ]}
12954 ]}]}"#;
12955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956 let md = adf_to_markdown(&doc).unwrap();
12957 let round_tripped = markdown_to_adf(&md).unwrap();
12958 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12959 let mark_types: Vec<&str> = node
12960 .marks
12961 .as_ref()
12962 .unwrap()
12963 .iter()
12964 .map(|m| m.mark_type.as_str())
12965 .collect();
12966 assert_eq!(
12967 mark_types,
12968 vec!["strong", "link"],
12969 "mark order should be preserved, got: {mark_types:?}"
12970 );
12971 }
12972
12973 #[test]
12974 fn mark_ordering_em_link_preserved() {
12975 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12977 {"type":"text","text":"italic link","marks":[
12978 {"type":"em"},
12979 {"type":"link","attrs":{"href":"https://example.com"}}
12980 ]}
12981 ]}]}"#;
12982 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12983 let md = adf_to_markdown(&doc).unwrap();
12984 let round_tripped = markdown_to_adf(&md).unwrap();
12985 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12986 let mark_types: Vec<&str> = node
12987 .marks
12988 .as_ref()
12989 .unwrap()
12990 .iter()
12991 .map(|m| m.mark_type.as_str())
12992 .collect();
12993 assert_eq!(
12994 mark_types,
12995 vec!["em", "link"],
12996 "mark order should be preserved, got: {mark_types:?}"
12997 );
12998 }
12999
13000 #[test]
13001 fn mark_ordering_strike_link_preserved() {
13002 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13004 {"type":"text","text":"struck link","marks":[
13005 {"type":"strike"},
13006 {"type":"link","attrs":{"href":"https://example.com"}}
13007 ]}
13008 ]}]}"#;
13009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010 let md = adf_to_markdown(&doc).unwrap();
13011 let round_tripped = markdown_to_adf(&md).unwrap();
13012 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13013 let mark_types: Vec<&str> = node
13014 .marks
13015 .as_ref()
13016 .unwrap()
13017 .iter()
13018 .map(|m| m.mark_type.as_str())
13019 .collect();
13020 assert_eq!(
13021 mark_types,
13022 vec!["strike", "link"],
13023 "mark order should be preserved, got: {mark_types:?}"
13024 );
13025 }
13026
13027 #[test]
13028 fn mark_ordering_underline_link_preserved() {
13029 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13031 {"type":"text","text":"click here","marks":[
13032 {"type":"underline"},
13033 {"type":"link","attrs":{"href":"https://example.com"}}
13034 ]}
13035 ]}]}"#;
13036 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13037 let md = adf_to_markdown(&doc).unwrap();
13038 let round_tripped = markdown_to_adf(&md).unwrap();
13039 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13040 let mark_types: Vec<&str> = node
13041 .marks
13042 .as_ref()
13043 .unwrap()
13044 .iter()
13045 .map(|m| m.mark_type.as_str())
13046 .collect();
13047 assert_eq!(
13048 mark_types,
13049 vec!["underline", "link"],
13050 "mark order should be preserved, got: {mark_types:?}"
13051 );
13052 }
13053
13054 #[test]
13055 fn mark_ordering_textcolor_link_preserved() {
13056 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13058 {"type":"text","text":"red link","marks":[
13059 {"type":"textColor","attrs":{"color":"#ff0000"}},
13060 {"type":"link","attrs":{"href":"https://example.com"}}
13061 ]}
13062 ]}]}"##;
13063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13064 let md = adf_to_markdown(&doc).unwrap();
13065 let round_tripped = markdown_to_adf(&md).unwrap();
13066 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13067 let mark_types: Vec<&str> = node
13068 .marks
13069 .as_ref()
13070 .unwrap()
13071 .iter()
13072 .map(|m| m.mark_type.as_str())
13073 .collect();
13074 assert_eq!(
13075 mark_types,
13076 vec!["textColor", "link"],
13077 "mark order should be preserved, got: {mark_types:?}"
13078 );
13079 }
13080
13081 #[test]
13082 fn mark_ordering_link_underline_preserved() {
13083 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13085 {"type":"text","text":"click here","marks":[
13086 {"type":"link","attrs":{"href":"https://example.com"}},
13087 {"type":"underline"}
13088 ]}
13089 ]}]}"#;
13090 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13091 let md = adf_to_markdown(&doc).unwrap();
13092 assert!(
13094 md.contains("](https://example.com)"),
13095 "should have link: {md}"
13096 );
13097 assert!(md.contains("underline"), "should have underline: {md}");
13098 let round_tripped = markdown_to_adf(&md).unwrap();
13099 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13100 let mark_types: Vec<&str> = node
13101 .marks
13102 .as_ref()
13103 .unwrap()
13104 .iter()
13105 .map(|m| m.mark_type.as_str())
13106 .collect();
13107 assert_eq!(
13108 mark_types,
13109 vec!["link", "underline"],
13110 "mark order should be preserved, got: {mark_types:?}"
13111 );
13112 }
13113
13114 #[test]
13115 fn mark_ordering_underline_strong_link_preserved() {
13116 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13118 {"type":"text","text":"bold underlined link","marks":[
13119 {"type":"underline"},
13120 {"type":"strong"},
13121 {"type":"link","attrs":{"href":"https://example.com/page"}}
13122 ]}
13123 ]}]}"#;
13124 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13125 let md = adf_to_markdown(&doc).unwrap();
13126 let round_tripped = markdown_to_adf(&md).unwrap();
13127 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13128 let mark_types: Vec<&str> = node
13129 .marks
13130 .as_ref()
13131 .unwrap()
13132 .iter()
13133 .map(|m| m.mark_type.as_str())
13134 .collect();
13135 assert_eq!(
13136 mark_types,
13137 vec!["underline", "strong", "link"],
13138 "mark order should be preserved, got: {mark_types:?}"
13139 );
13140 }
13141
13142 #[test]
13143 fn mark_ordering_strong_underline_link_preserved() {
13144 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13146 {"type":"text","text":"bold underlined link","marks":[
13147 {"type":"strong"},
13148 {"type":"underline"},
13149 {"type":"link","attrs":{"href":"https://example.com/page"}}
13150 ]}
13151 ]}]}"#;
13152 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13153 let md = adf_to_markdown(&doc).unwrap();
13154 let round_tripped = markdown_to_adf(&md).unwrap();
13155 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13156 let mark_types: Vec<&str> = node
13157 .marks
13158 .as_ref()
13159 .unwrap()
13160 .iter()
13161 .map(|m| m.mark_type.as_str())
13162 .collect();
13163 assert_eq!(
13164 mark_types,
13165 vec!["strong", "underline", "link"],
13166 "mark order should be preserved, got: {mark_types:?}"
13167 );
13168 }
13169
13170 #[test]
13171 fn mark_ordering_underline_em_link_preserved() {
13172 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13174 {"type":"text","text":"italic underlined link","marks":[
13175 {"type":"underline"},
13176 {"type":"em"},
13177 {"type":"link","attrs":{"href":"https://example.com/page"}}
13178 ]}
13179 ]}]}"#;
13180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13181 let md = adf_to_markdown(&doc).unwrap();
13182 let round_tripped = markdown_to_adf(&md).unwrap();
13183 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13184 let mark_types: Vec<&str> = node
13185 .marks
13186 .as_ref()
13187 .unwrap()
13188 .iter()
13189 .map(|m| m.mark_type.as_str())
13190 .collect();
13191 assert_eq!(
13192 mark_types,
13193 vec!["underline", "em", "link"],
13194 "mark order should be preserved, got: {mark_types:?}"
13195 );
13196 }
13197
13198 #[test]
13199 fn mark_ordering_underline_strike_link_preserved() {
13200 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13202 {"type":"text","text":"struck underlined link","marks":[
13203 {"type":"underline"},
13204 {"type":"strike"},
13205 {"type":"link","attrs":{"href":"https://example.com/page"}}
13206 ]}
13207 ]}]}"#;
13208 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13209 let md = adf_to_markdown(&doc).unwrap();
13210 let round_tripped = markdown_to_adf(&md).unwrap();
13211 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13212 let mark_types: Vec<&str> = node
13213 .marks
13214 .as_ref()
13215 .unwrap()
13216 .iter()
13217 .map(|m| m.mark_type.as_str())
13218 .collect();
13219 assert_eq!(
13220 mark_types,
13221 vec!["underline", "strike", "link"],
13222 "mark order should be preserved, got: {mark_types:?}"
13223 );
13224 }
13225
13226 #[test]
13227 fn mark_ordering_underline_strong_em_link_preserved() {
13228 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13230 {"type":"text","text":"all the marks","marks":[
13231 {"type":"underline"},
13232 {"type":"strong"},
13233 {"type":"em"},
13234 {"type":"link","attrs":{"href":"https://example.com/page"}}
13235 ]}
13236 ]}]}"#;
13237 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13238 let md = adf_to_markdown(&doc).unwrap();
13239 let round_tripped = markdown_to_adf(&md).unwrap();
13240 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13241 let mark_types: Vec<&str> = node
13242 .marks
13243 .as_ref()
13244 .unwrap()
13245 .iter()
13246 .map(|m| m.mark_type.as_str())
13247 .collect();
13248 assert_eq!(
13249 mark_types,
13250 vec!["underline", "strong", "em", "link"],
13251 "mark order should be preserved, got: {mark_types:?}"
13252 );
13253 }
13254
13255 #[test]
13256 fn em_strong_round_trip() {
13257 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13259 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
13260 ]}]}"#;
13261 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13262 let md = adf_to_markdown(&doc).unwrap();
13263 assert_eq!(md.trim(), "***bold and italic***");
13264 let round_tripped = markdown_to_adf(&md).unwrap();
13265 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13266 assert_eq!(node.text.as_deref(), Some("bold and italic"));
13267 let mark_types: Vec<&str> = node
13268 .marks
13269 .as_ref()
13270 .unwrap()
13271 .iter()
13272 .map(|m| m.mark_type.as_str())
13273 .collect();
13274 assert_eq!(
13275 mark_types,
13276 vec!["strong", "em"],
13277 "both strong and em marks should be preserved, got: {mark_types:?}"
13278 );
13279 }
13280
13281 #[test]
13282 fn em_strong_round_trip_em_first() {
13283 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13285 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
13286 ]}]}"#;
13287 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13288 let md = adf_to_markdown(&doc).unwrap();
13289 let round_tripped = markdown_to_adf(&md).unwrap();
13290 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13291 assert_eq!(node.text.as_deref(), Some("italic and bold"));
13292 let mark_types: Vec<&str> = node
13293 .marks
13294 .as_ref()
13295 .unwrap()
13296 .iter()
13297 .map(|m| m.mark_type.as_str())
13298 .collect();
13299 assert_eq!(
13300 mark_types,
13301 vec!["em", "strong"],
13302 "mark order [em, strong] should be preserved, got: {mark_types:?}"
13303 );
13304 }
13305
13306 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
13309 let doc = AdfDocument {
13310 version: 1,
13311 doc_type: "doc".to_string(),
13312 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13313 "text", marks,
13314 )])],
13315 };
13316 let md = adf_to_markdown(&doc).unwrap();
13317 let round_tripped = markdown_to_adf(&md).unwrap();
13318 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13319 let mark_types: Vec<&str> = node
13320 .marks
13321 .as_ref()
13322 .expect("round-tripped node should have marks")
13323 .iter()
13324 .map(|m| m.mark_type.as_str())
13325 .collect();
13326 assert_eq!(
13327 mark_types, expected,
13328 "marks should round-trip in order via {md:?}"
13329 );
13330 }
13331
13332 #[test]
13333 fn round_trip_em_strong_mark_order() {
13334 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
13336 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
13337 }
13338
13339 #[test]
13340 fn round_trip_strong_underline_mark_order() {
13341 assert_mark_order_round_trip(
13343 vec![AdfMark::strong(), AdfMark::underline()],
13344 &["strong", "underline"],
13345 );
13346 assert_mark_order_round_trip(
13347 vec![AdfMark::underline(), AdfMark::strong()],
13348 &["underline", "strong"],
13349 );
13350 }
13351
13352 #[test]
13353 fn round_trip_em_underline_mark_order() {
13354 assert_mark_order_round_trip(
13355 vec![AdfMark::em(), AdfMark::underline()],
13356 &["em", "underline"],
13357 );
13358 assert_mark_order_round_trip(
13359 vec![AdfMark::underline(), AdfMark::em()],
13360 &["underline", "em"],
13361 );
13362 }
13363
13364 #[test]
13365 fn round_trip_strike_strong_em_permutations() {
13366 assert_mark_order_round_trip(
13370 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
13371 &["strike", "strong", "em"],
13372 );
13373 assert_mark_order_round_trip(
13374 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
13375 &["strike", "em", "strong"],
13376 );
13377 assert_mark_order_round_trip(
13378 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
13379 &["strong", "strike", "em"],
13380 );
13381 assert_mark_order_round_trip(
13382 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
13383 &["strong", "em", "strike"],
13384 );
13385 assert_mark_order_round_trip(
13386 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
13387 &["em", "strike", "strong"],
13388 );
13389 assert_mark_order_round_trip(
13390 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
13391 &["em", "strong", "strike"],
13392 );
13393 }
13394
13395 #[test]
13396 fn round_trip_underline_nested_with_strong_em() {
13397 assert_mark_order_round_trip(
13400 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
13401 &["underline", "strong", "em"],
13402 );
13403 assert_mark_order_round_trip(
13404 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
13405 &["strong", "underline", "em"],
13406 );
13407 assert_mark_order_round_trip(
13408 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
13409 &["strong", "em", "underline"],
13410 );
13411 }
13412
13413 #[test]
13414 fn round_trip_span_attr_order_preserved() {
13415 assert_mark_order_round_trip(
13419 vec![
13420 AdfMark::background_color("#ffff00"),
13421 AdfMark::text_color("#ff0000"),
13422 ],
13423 &["backgroundColor", "textColor"],
13424 );
13425 assert_mark_order_round_trip(
13426 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
13427 &["subsup", "textColor"],
13428 );
13429 assert_mark_order_round_trip(
13430 vec![
13431 AdfMark::text_color("#ff0000"),
13432 AdfMark::background_color("#ffff00"),
13433 ],
13434 &["textColor", "backgroundColor"],
13435 );
13436 }
13437
13438 #[test]
13439 fn round_trip_annotation_before_underline() {
13440 assert_mark_order_round_trip(
13444 vec![
13445 AdfMark::annotation("ann-1", "inlineComment"),
13446 AdfMark::underline(),
13447 ],
13448 &["annotation", "underline"],
13449 );
13450 assert_mark_order_round_trip(
13451 vec![
13452 AdfMark::annotation("ann-1", "inlineComment"),
13453 AdfMark::underline(),
13454 AdfMark::annotation("ann-2", "inlineComment"),
13455 ],
13456 &["annotation", "underline", "annotation"],
13457 );
13458 }
13459
13460 #[test]
13461 fn round_trip_em_content_with_underscores() {
13462 let doc = AdfDocument {
13467 version: 1,
13468 doc_type: "doc".to_string(),
13469 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13470 "foo _bar_ baz",
13471 vec![AdfMark::em(), AdfMark::strong()],
13472 )])],
13473 };
13474 let md = adf_to_markdown(&doc).unwrap();
13475 let round_tripped = markdown_to_adf(&md).unwrap();
13476 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13477 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
13478 let mark_types: Vec<&str> = node
13479 .marks
13480 .as_ref()
13481 .unwrap()
13482 .iter()
13483 .map(|m| m.mark_type.as_str())
13484 .collect();
13485 assert_eq!(mark_types, vec!["em", "strong"]);
13486 }
13487
13488 #[test]
13489 fn round_trip_link_nested_with_formatting_marks() {
13490 assert_mark_order_round_trip(
13493 vec![
13494 AdfMark::link("https://example.com"),
13495 AdfMark::strong(),
13496 AdfMark::em(),
13497 ],
13498 &["link", "strong", "em"],
13499 );
13500 assert_mark_order_round_trip(
13501 vec![
13502 AdfMark::em(),
13503 AdfMark::strong(),
13504 AdfMark::link("https://example.com"),
13505 ],
13506 &["em", "strong", "link"],
13507 );
13508 assert_mark_order_round_trip(
13509 vec![
13510 AdfMark::underline(),
13511 AdfMark::link("https://example.com"),
13512 AdfMark::strong(),
13513 ],
13514 &["underline", "link", "strong"],
13515 );
13516 }
13517
13518 fn bare_mark(mark_type: &str) -> AdfMark {
13522 AdfMark {
13523 mark_type: mark_type.to_string(),
13524 attrs: None,
13525 }
13526 }
13527
13528 #[test]
13529 fn collect_span_attr_handles_missing_attrs() {
13530 let mut attrs = Vec::new();
13535 collect_span_attr(&bare_mark("textColor"), &mut attrs);
13536 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
13537 collect_span_attr(&bare_mark("subsup"), &mut attrs);
13538 collect_span_attr(&bare_mark("link"), &mut attrs);
13539 assert!(attrs.is_empty(), "got: {attrs:?}");
13540 }
13541
13542 #[test]
13543 fn collect_bracketed_attr_handles_missing_attrs() {
13544 let mut attrs = Vec::new();
13547 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
13548 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
13549 assert!(attrs.is_empty(), "got: {attrs:?}");
13550 }
13551
13552 #[test]
13553 fn collect_bracketed_attr_handles_annotation_without_id() {
13554 let mark = AdfMark {
13558 mark_type: "annotation".to_string(),
13559 attrs: Some(serde_json::json!({})),
13560 };
13561 let mut attrs = Vec::new();
13562 collect_bracketed_attr(&mark, &mut attrs);
13563 assert!(attrs.is_empty(), "got: {attrs:?}");
13564 }
13565
13566 #[test]
13567 fn span_attr_order_rejects_unknown_types() {
13568 assert_eq!(span_attr_order("textColor"), 0);
13572 assert_eq!(span_attr_order("backgroundColor"), 1);
13573 assert_eq!(span_attr_order("subsup"), 2);
13574 assert_eq!(span_attr_order("strong"), u8::MAX);
13575 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
13576 }
13577
13578 #[test]
13579 fn bracketed_run_rejects_unknown_types() {
13580 assert!(bracketed_run_is_canonical(&[
13584 AdfMark::underline(),
13585 AdfMark::annotation("x", "inlineComment")
13586 ]));
13587 assert!(!bracketed_run_is_canonical(&[
13588 AdfMark::annotation("x", "inlineComment"),
13589 AdfMark::underline()
13590 ]));
13591 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
13592 }
13593
13594 #[test]
13595 fn render_marked_text_ignores_unknown_mark_types() {
13596 let doc = AdfDocument {
13600 version: 1,
13601 doc_type: "doc".to_string(),
13602 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13603 "hello",
13604 vec![bare_mark("futureMark"), AdfMark::strong()],
13605 )])],
13606 };
13607 let md = adf_to_markdown(&doc).unwrap();
13608 assert_eq!(md.trim(), "**hello**");
13609 let rt = markdown_to_adf(&md).unwrap();
13610 let node = &rt.content[0].content.as_ref().unwrap()[0];
13611 assert_eq!(node.text.as_deref(), Some("hello"));
13612 let mark_types: Vec<&str> = node
13613 .marks
13614 .as_ref()
13615 .unwrap()
13616 .iter()
13617 .map(|m| m.mark_type.as_str())
13618 .collect();
13619 assert_eq!(mark_types, vec!["strong"]);
13620 }
13621
13622 #[test]
13623 fn triple_asterisk_parse_to_adf() {
13624 let md = "***bold and italic***\n";
13626 let doc = markdown_to_adf(md).unwrap();
13627 let node = &doc.content[0].content.as_ref().unwrap()[0];
13628 assert_eq!(node.text.as_deref(), Some("bold and italic"));
13629 let mark_types: Vec<&str> = node
13630 .marks
13631 .as_ref()
13632 .unwrap()
13633 .iter()
13634 .map(|m| m.mark_type.as_str())
13635 .collect();
13636 assert!(
13637 mark_types.contains(&"strong") && mark_types.contains(&"em"),
13638 "***text*** should produce both strong and em marks, got: {mark_types:?}"
13639 );
13640 }
13641
13642 #[test]
13643 fn triple_asterisk_with_surrounding_text() {
13644 let md = "before ***bold italic*** after\n";
13646 let doc = markdown_to_adf(md).unwrap();
13647 let nodes = doc.content[0].content.as_ref().unwrap();
13648 assert!(
13650 nodes.len() >= 3,
13651 "expected at least 3 nodes, got {}",
13652 nodes.len()
13653 );
13654 assert_eq!(nodes[0].text.as_deref(), Some("before "));
13655 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
13656 let mark_types: Vec<&str> = nodes[1]
13657 .marks
13658 .as_ref()
13659 .unwrap()
13660 .iter()
13661 .map(|m| m.mark_type.as_str())
13662 .collect();
13663 assert!(
13664 mark_types.contains(&"strong") && mark_types.contains(&"em"),
13665 "middle node should have strong+em, got: {mark_types:?}"
13666 );
13667 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
13668 }
13669
13670 #[test]
13671 fn annotation_mark_round_trip() {
13672 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13674 {"type":"text","text":"highlighted text","marks":[
13675 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
13676 ]}
13677 ]}]}"#;
13678 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13679
13680 let md = adf_to_markdown(&doc).unwrap();
13681 assert!(
13682 md.contains("annotation-id="),
13683 "JFM should contain annotation-id, got: {md}"
13684 );
13685
13686 let round_tripped = markdown_to_adf(&md).unwrap();
13687 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13688 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
13689 let marks = text_node.marks.as_ref().expect("should have marks");
13690 let ann = marks
13691 .iter()
13692 .find(|m| m.mark_type == "annotation")
13693 .expect("should have annotation mark");
13694 let attrs = ann.attrs.as_ref().unwrap();
13695 assert_eq!(attrs["id"], "abc123");
13696 assert_eq!(attrs["annotationType"], "inlineComment");
13697 }
13698
13699 #[test]
13700 fn annotation_mark_with_bold() {
13701 let doc = AdfDocument {
13703 version: 1,
13704 doc_type: "doc".to_string(),
13705 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13706 "bold comment",
13707 vec![
13708 AdfMark::strong(),
13709 AdfMark::annotation("def456", "inlineComment"),
13710 ],
13711 )])],
13712 };
13713 let md = adf_to_markdown(&doc).unwrap();
13714 let round_tripped = markdown_to_adf(&md).unwrap();
13715 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13716 let marks = text_node.marks.as_ref().expect("should have marks");
13717 assert!(
13718 marks.iter().any(|m| m.mark_type == "strong"),
13719 "should have strong mark"
13720 );
13721 assert!(
13722 marks.iter().any(|m| m.mark_type == "annotation"),
13723 "should have annotation mark"
13724 );
13725 }
13726
13727 #[test]
13728 fn annotation_and_link_marks_both_preserved() {
13729 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13731 {"type":"text","text":"HANGUL-8","marks":[
13732 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
13733 {"type":"link","attrs":{"href":"https://acme.atlassian.net/browse/HANG-8"}}
13734 ]}
13735 ]}]}"#;
13736 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13737 let md = adf_to_markdown(&doc).unwrap();
13738 assert!(
13740 md.contains("annotation-id="),
13741 "JFM should contain annotation-id, got: {md}"
13742 );
13743 assert!(
13744 md.contains("](https://"),
13745 "JFM should contain link href, got: {md}"
13746 );
13747 let round_tripped = markdown_to_adf(&md).unwrap();
13748 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13749 let marks = text_node.marks.as_ref().expect("should have marks");
13750 assert!(
13751 marks.iter().any(|m| m.mark_type == "annotation"),
13752 "should have annotation mark, got: {:?}",
13753 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13754 );
13755 assert!(
13756 marks.iter().any(|m| m.mark_type == "link"),
13757 "should have link mark, got: {:?}",
13758 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13759 );
13760 }
13761
13762 #[test]
13763 fn annotation_and_code_marks_both_preserved() {
13764 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13766 {"type":"text","text":"some text with "},
13767 {"type":"text","text":"annotated code","marks":[
13768 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
13769 {"type":"code"}
13770 ]},
13771 {"type":"text","text":" remaining text"}
13772 ]}]}"#;
13773 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13774 let md = adf_to_markdown(&doc).unwrap();
13775 assert!(
13776 md.contains("annotation-id="),
13777 "JFM should contain annotation-id, got: {md}"
13778 );
13779 assert!(
13780 md.contains('`'),
13781 "JFM should contain backticks for code, got: {md}"
13782 );
13783
13784 let round_tripped = markdown_to_adf(&md).unwrap();
13785 let nodes = round_tripped.content[0].content.as_ref().unwrap();
13786 let code_node = nodes
13788 .iter()
13789 .find(|n| n.text.as_deref() == Some("annotated code"))
13790 .expect("should have 'annotated code' text node");
13791 let marks = code_node.marks.as_ref().expect("should have marks");
13792 assert!(
13793 marks.iter().any(|m| m.mark_type == "annotation"),
13794 "should have annotation mark, got: {:?}",
13795 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13796 );
13797 assert!(
13798 marks.iter().any(|m| m.mark_type == "code"),
13799 "should have code mark, got: {:?}",
13800 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13801 );
13802 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
13803 let attrs = ann.attrs.as_ref().unwrap();
13804 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
13805 assert_eq!(attrs["annotationType"], "inlineComment");
13806 }
13807
13808 #[test]
13809 fn annotation_and_code_and_link_marks_all_preserved() {
13810 let doc = AdfDocument {
13812 version: 1,
13813 doc_type: "doc".to_string(),
13814 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13815 "linked code",
13816 vec![
13817 AdfMark::annotation("ann-001", "inlineComment"),
13818 AdfMark::code(),
13819 AdfMark::link("https://example.com"),
13820 ],
13821 )])],
13822 };
13823 let md = adf_to_markdown(&doc).unwrap();
13824 assert!(
13825 md.contains("annotation-id="),
13826 "JFM should contain annotation-id, got: {md}"
13827 );
13828 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
13829 assert!(
13830 md.contains("](https://example.com)"),
13831 "JFM should contain link, got: {md}"
13832 );
13833
13834 let round_tripped = markdown_to_adf(&md).unwrap();
13835 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13836 let marks = text_node.marks.as_ref().expect("should have marks");
13837 assert!(
13838 marks.iter().any(|m| m.mark_type == "annotation"),
13839 "should have annotation mark, got: {:?}",
13840 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13841 );
13842 assert!(
13843 marks.iter().any(|m| m.mark_type == "code"),
13844 "should have code mark, got: {:?}",
13845 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13846 );
13847 assert!(
13848 marks.iter().any(|m| m.mark_type == "link"),
13849 "should have link mark, got: {:?}",
13850 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13851 );
13852 }
13853
13854 #[test]
13855 fn multiple_annotations_and_code_mark_preserved() {
13856 let doc = AdfDocument {
13858 version: 1,
13859 doc_type: "doc".to_string(),
13860 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13861 "doubly annotated",
13862 vec![
13863 AdfMark::annotation("ann-aaa", "inlineComment"),
13864 AdfMark::annotation("ann-bbb", "inlineComment"),
13865 AdfMark::code(),
13866 ],
13867 )])],
13868 };
13869 let md = adf_to_markdown(&doc).unwrap();
13870 assert!(
13871 md.contains("ann-aaa"),
13872 "JFM should contain first annotation id, got: {md}"
13873 );
13874 assert!(
13875 md.contains("ann-bbb"),
13876 "JFM should contain second annotation id, got: {md}"
13877 );
13878
13879 let round_tripped = markdown_to_adf(&md).unwrap();
13880 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13881 let marks = text_node.marks.as_ref().expect("should have marks");
13882 let ann_marks: Vec<_> = marks
13883 .iter()
13884 .filter(|m| m.mark_type == "annotation")
13885 .collect();
13886 assert_eq!(
13887 ann_marks.len(),
13888 2,
13889 "should have 2 annotation marks, got: {}",
13890 ann_marks.len()
13891 );
13892 assert!(
13893 marks.iter().any(|m| m.mark_type == "code"),
13894 "should have code mark"
13895 );
13896 }
13897
13898 #[test]
13899 fn underline_and_link_marks_both_preserved() {
13900 let doc = AdfDocument {
13902 version: 1,
13903 doc_type: "doc".to_string(),
13904 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13905 "click here",
13906 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
13907 )])],
13908 };
13909 let md = adf_to_markdown(&doc).unwrap();
13910 assert!(md.contains("underline"), "should have underline attr: {md}");
13911 assert!(
13912 md.contains("](https://example.com)"),
13913 "should have link: {md}"
13914 );
13915 let round_tripped = markdown_to_adf(&md).unwrap();
13916 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13917 let marks = text_node.marks.as_ref().expect("should have marks");
13918 assert!(marks.iter().any(|m| m.mark_type == "underline"));
13919 assert!(marks.iter().any(|m| m.mark_type == "link"));
13920 }
13921
13922 #[test]
13923 fn annotation_link_and_bold_all_preserved() {
13924 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13926 {"type":"text","text":"important","marks":[
13927 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
13928 {"type":"link","attrs":{"href":"https://example.com"}},
13929 {"type":"strong"}
13930 ]}
13931 ]}]}"#;
13932 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13933 let md = adf_to_markdown(&doc).unwrap();
13934 let round_tripped = markdown_to_adf(&md).unwrap();
13935 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13936 let marks = text_node.marks.as_ref().expect("should have marks");
13937 assert!(
13938 marks.iter().any(|m| m.mark_type == "annotation"),
13939 "should have annotation"
13940 );
13941 assert!(
13942 marks.iter().any(|m| m.mark_type == "link"),
13943 "should have link"
13944 );
13945 assert!(
13946 marks.iter().any(|m| m.mark_type == "strong"),
13947 "should have strong"
13948 );
13949 }
13950
13951 #[test]
13952 fn multiple_annotation_marks_round_trip() {
13953 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13955 {"type":"text","text":"some annotated text","marks":[
13956 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
13957 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
13958 ]}
13959 ]}]}"#;
13960 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13961
13962 let md = adf_to_markdown(&doc).unwrap();
13963 assert!(
13964 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
13965 "JFM should contain first annotation id, got: {md}"
13966 );
13967 assert!(
13968 md.contains("ffffffff-1111-2222-3333-444444444444"),
13969 "JFM should contain second annotation id, got: {md}"
13970 );
13971
13972 let round_tripped = markdown_to_adf(&md).unwrap();
13973 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13974 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
13975 let marks = text_node.marks.as_ref().expect("should have marks");
13976 let annotations: Vec<_> = marks
13977 .iter()
13978 .filter(|m| m.mark_type == "annotation")
13979 .collect();
13980 assert_eq!(
13981 annotations.len(),
13982 2,
13983 "should have 2 annotation marks, got: {annotations:?}"
13984 );
13985 let ids: Vec<_> = annotations
13986 .iter()
13987 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
13988 .collect();
13989 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
13990 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
13991 }
13992
13993 #[test]
13994 fn three_annotation_marks_round_trip() {
13995 let doc = AdfDocument {
13997 version: 1,
13998 doc_type: "doc".to_string(),
13999 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14000 "triple annotated",
14001 vec![
14002 AdfMark::annotation("id-1", "inlineComment"),
14003 AdfMark::annotation("id-2", "inlineComment"),
14004 AdfMark::annotation("id-3", "inlineComment"),
14005 ],
14006 )])],
14007 };
14008 let md = adf_to_markdown(&doc).unwrap();
14009 let round_tripped = markdown_to_adf(&md).unwrap();
14010 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14011 let marks = text_node.marks.as_ref().expect("should have marks");
14012 let annotations: Vec<_> = marks
14013 .iter()
14014 .filter(|m| m.mark_type == "annotation")
14015 .collect();
14016 assert_eq!(
14017 annotations.len(),
14018 3,
14019 "should have 3 annotation marks, got: {annotations:?}"
14020 );
14021 }
14022
14023 #[test]
14024 fn multiple_annotations_with_bold_round_trip() {
14025 let doc = AdfDocument {
14027 version: 1,
14028 doc_type: "doc".to_string(),
14029 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14030 "bold double annotated",
14031 vec![
14032 AdfMark::strong(),
14033 AdfMark::annotation("ann-a", "inlineComment"),
14034 AdfMark::annotation("ann-b", "inlineComment"),
14035 ],
14036 )])],
14037 };
14038 let md = adf_to_markdown(&doc).unwrap();
14039 let round_tripped = markdown_to_adf(&md).unwrap();
14040 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14041 let marks = text_node.marks.as_ref().expect("should have marks");
14042 assert!(
14043 marks.iter().any(|m| m.mark_type == "strong"),
14044 "should have strong mark"
14045 );
14046 let annotations: Vec<_> = marks
14047 .iter()
14048 .filter(|m| m.mark_type == "annotation")
14049 .collect();
14050 assert_eq!(
14051 annotations.len(),
14052 2,
14053 "should have 2 annotation marks, got: {annotations:?}"
14054 );
14055 }
14056
14057 #[test]
14058 fn multiple_annotations_with_link_round_trip() {
14059 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14061 {"type":"text","text":"linked text","marks":[
14062 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
14063 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
14064 {"type":"link","attrs":{"href":"https://example.com"}}
14065 ]}
14066 ]}]}"#;
14067 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14068 let md = adf_to_markdown(&doc).unwrap();
14069 let round_tripped = markdown_to_adf(&md).unwrap();
14070 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14071 let marks = text_node.marks.as_ref().expect("should have marks");
14072 assert!(
14073 marks.iter().any(|m| m.mark_type == "link"),
14074 "should have link mark"
14075 );
14076 let annotations: Vec<_> = marks
14077 .iter()
14078 .filter(|m| m.mark_type == "annotation")
14079 .collect();
14080 assert_eq!(
14081 annotations.len(),
14082 2,
14083 "should have 2 annotation marks, got: {annotations:?}"
14084 );
14085 }
14086
14087 #[test]
14090 fn annotation_on_emoji_round_trip() {
14091 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14093 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
14094 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
14095 ]},
14096 {"type":"text","text":" annotated text","marks":[
14097 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
14098 ]}
14099 ]}]}"#;
14100 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14101 let md = adf_to_markdown(&doc).unwrap();
14102 assert!(
14103 md.contains("annotation-id="),
14104 "JFM should contain annotation-id for emoji, got: {md}"
14105 );
14106
14107 let round_tripped = markdown_to_adf(&md).unwrap();
14108 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14109
14110 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
14112 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
14113 assert!(
14114 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
14115 "emoji should have annotation mark, got: {emoji_marks:?}"
14116 );
14117 let ann = emoji_marks
14118 .iter()
14119 .find(|m| m.mark_type == "annotation")
14120 .unwrap();
14121 assert_eq!(
14122 ann.attrs.as_ref().unwrap()["id"],
14123 "ccddee11-2233-4455-aabb-ccddee112233"
14124 );
14125
14126 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
14128 let text_marks = text_node.marks.as_ref().expect("text should have marks");
14129 assert!(
14130 text_marks.iter().any(|m| m.mark_type == "annotation"),
14131 "text should have annotation mark"
14132 );
14133 }
14134
14135 #[test]
14136 fn annotation_on_status_round_trip() {
14137 let mut status = AdfNode::status("In Progress", "blue");
14138 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
14139
14140 let doc = AdfDocument {
14141 version: 1,
14142 doc_type: "doc".to_string(),
14143 content: vec![AdfNode::paragraph(vec![status])],
14144 };
14145 let md = adf_to_markdown(&doc).unwrap();
14146 assert!(
14147 md.contains("annotation-id="),
14148 "JFM should contain annotation-id for status, got: {md}"
14149 );
14150
14151 let round_tripped = markdown_to_adf(&md).unwrap();
14152 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14153 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
14154 let marks = status_node
14155 .marks
14156 .as_ref()
14157 .expect("status should have marks");
14158 assert!(
14159 marks.iter().any(|m| m.mark_type == "annotation"),
14160 "status should have annotation mark, got: {marks:?}"
14161 );
14162 }
14163
14164 #[test]
14165 fn annotation_on_date_round_trip() {
14166 let mut date = AdfNode::date("1704067200000");
14167 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
14168
14169 let doc = AdfDocument {
14170 version: 1,
14171 doc_type: "doc".to_string(),
14172 content: vec![AdfNode::paragraph(vec![date])],
14173 };
14174 let md = adf_to_markdown(&doc).unwrap();
14175 assert!(
14176 md.contains("annotation-id="),
14177 "JFM should contain annotation-id for date, got: {md}"
14178 );
14179
14180 let round_tripped = markdown_to_adf(&md).unwrap();
14181 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14182 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
14183 let marks = date_node.marks.as_ref().expect("date should have marks");
14184 assert!(
14185 marks.iter().any(|m| m.mark_type == "annotation"),
14186 "date should have annotation mark, got: {marks:?}"
14187 );
14188 }
14189
14190 #[test]
14191 fn annotation_on_mention_round_trip() {
14192 let mut mention = AdfNode::mention("user-123", "@Alice");
14193 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
14194
14195 let doc = AdfDocument {
14196 version: 1,
14197 doc_type: "doc".to_string(),
14198 content: vec![AdfNode::paragraph(vec![mention])],
14199 };
14200 let md = adf_to_markdown(&doc).unwrap();
14201 assert!(
14202 md.contains("annotation-id="),
14203 "JFM should contain annotation-id for mention, got: {md}"
14204 );
14205
14206 let round_tripped = markdown_to_adf(&md).unwrap();
14207 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14208 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
14209 let marks = mention_node
14210 .marks
14211 .as_ref()
14212 .expect("mention should have marks");
14213 assert!(
14214 marks.iter().any(|m| m.mark_type == "annotation"),
14215 "mention should have annotation mark, got: {marks:?}"
14216 );
14217 }
14218
14219 #[test]
14220 fn annotation_on_inline_card_round_trip() {
14221 let mut card = AdfNode::inline_card("https://example.com");
14222 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
14223
14224 let doc = AdfDocument {
14225 version: 1,
14226 doc_type: "doc".to_string(),
14227 content: vec![AdfNode::paragraph(vec![card])],
14228 };
14229 let md = adf_to_markdown(&doc).unwrap();
14230 assert!(
14231 md.contains("annotation-id="),
14232 "JFM should contain annotation-id for inlineCard, got: {md}"
14233 );
14234
14235 let round_tripped = markdown_to_adf(&md).unwrap();
14236 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14237 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
14238 let marks = card_node
14239 .marks
14240 .as_ref()
14241 .expect("inlineCard should have marks");
14242 assert!(
14243 marks.iter().any(|m| m.mark_type == "annotation"),
14244 "inlineCard should have annotation mark, got: {marks:?}"
14245 );
14246 }
14247
14248 #[test]
14249 fn annotation_on_placeholder_round_trip() {
14250 let mut placeholder = AdfNode::placeholder("Enter text here");
14251 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
14252
14253 let doc = AdfDocument {
14254 version: 1,
14255 doc_type: "doc".to_string(),
14256 content: vec![AdfNode::paragraph(vec![placeholder])],
14257 };
14258 let md = adf_to_markdown(&doc).unwrap();
14259 assert!(
14260 md.contains("annotation-id="),
14261 "JFM should contain annotation-id for placeholder, got: {md}"
14262 );
14263
14264 let round_tripped = markdown_to_adf(&md).unwrap();
14265 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14266 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
14267 let marks = ph_node
14268 .marks
14269 .as_ref()
14270 .expect("placeholder should have marks");
14271 assert!(
14272 marks.iter().any(|m| m.mark_type == "annotation"),
14273 "placeholder should have annotation mark, got: {marks:?}"
14274 );
14275 }
14276
14277 #[test]
14278 fn multiple_annotations_on_emoji_round_trip() {
14279 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14281 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
14282 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
14283 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
14284 ]}
14285 ]}]}"#;
14286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14287 let md = adf_to_markdown(&doc).unwrap();
14288
14289 let round_tripped = markdown_to_adf(&md).unwrap();
14290 let nodes = round_tripped.content[0].content.as_ref().unwrap();
14291 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
14292 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
14293 let annotations: Vec<_> = marks
14294 .iter()
14295 .filter(|m| m.mark_type == "annotation")
14296 .collect();
14297 assert_eq!(
14298 annotations.len(),
14299 2,
14300 "emoji should have 2 annotation marks, got: {annotations:?}"
14301 );
14302 }
14303
14304 #[test]
14305 fn emoji_without_annotation_unchanged() {
14306 let doc = AdfDocument {
14308 version: 1,
14309 doc_type: "doc".to_string(),
14310 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
14311 };
14312 let md = adf_to_markdown(&doc).unwrap();
14313 assert!(
14315 !md.contains('['),
14316 "emoji without annotation should not be wrapped in brackets, got: {md}"
14317 );
14318 assert!(md.contains(":fire:"));
14319 }
14320
14321 #[test]
14324 fn status_directive() {
14325 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
14326 let content = doc.content[0].content.as_ref().unwrap();
14327 assert_eq!(content[1].node_type, "status");
14328 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
14329 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
14330 }
14331
14332 #[test]
14333 fn adf_status_to_markdown() {
14334 let doc = AdfDocument {
14335 version: 1,
14336 doc_type: "doc".to_string(),
14337 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
14338 };
14339 let md = adf_to_markdown(&doc).unwrap();
14340 assert!(md.contains(":status[Done]{color=green}"));
14341 }
14342
14343 #[test]
14344 fn round_trip_status() {
14345 let md = "The ticket is :status[In Progress]{color=blue}.\n";
14346 let doc = markdown_to_adf(md).unwrap();
14347 let result = adf_to_markdown(&doc).unwrap();
14348 assert!(result.contains(":status[In Progress]{color=blue}"));
14349 }
14350
14351 #[test]
14352 fn status_with_style_and_localid_roundtrips() {
14353 let adf = AdfDocument {
14354 version: 1,
14355 doc_type: "doc".to_string(),
14356 content: vec![AdfNode::paragraph(vec![{
14357 let mut node = AdfNode::status("open", "green");
14358 node.attrs.as_mut().unwrap()["style"] =
14359 serde_json::Value::String("bold".to_string());
14360 node.attrs.as_mut().unwrap()["localId"] =
14361 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
14362 node
14363 }])],
14364 };
14365
14366 let md = adf_to_markdown(&adf).unwrap();
14367 assert!(
14368 md.contains("style=bold"),
14369 "Markdown should contain style attr: {md}"
14370 );
14371 assert!(
14372 md.contains("localId=d2205ca5"),
14373 "Markdown should contain localId attr: {md}"
14374 );
14375
14376 let rt = markdown_to_adf(&md).unwrap();
14377 let status = &rt.content[0].content.as_ref().unwrap()[0];
14378 let attrs = status.attrs.as_ref().unwrap();
14379 assert_eq!(attrs["text"], "open");
14380 assert_eq!(attrs["color"], "green");
14381 assert_eq!(attrs["style"], "bold");
14382 assert_eq!(
14383 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
14384 "localId should be preserved, got: {}",
14385 attrs["localId"]
14386 );
14387 }
14388
14389 #[test]
14390 fn status_without_style_still_works() {
14391 let md = ":status[Done]{color=green}\n";
14392 let doc = markdown_to_adf(md).unwrap();
14393 let status = &doc.content[0].content.as_ref().unwrap()[0];
14394 let attrs = status.attrs.as_ref().unwrap();
14395 assert_eq!(attrs["text"], "Done");
14396 assert_eq!(attrs["color"], "green");
14397 assert!(
14399 attrs.get("style").is_none() || attrs["style"].is_null(),
14400 "style should not be set when not provided"
14401 );
14402 }
14403
14404 #[test]
14405 fn strip_local_ids_removes_localid_from_status() {
14406 let adf = AdfDocument {
14407 version: 1,
14408 doc_type: "doc".to_string(),
14409 content: vec![AdfNode::paragraph(vec![{
14410 let mut node = AdfNode::status("open", "green");
14411 node.attrs.as_mut().unwrap()["localId"] =
14412 serde_json::Value::String("real-uuid-here".to_string());
14413 node
14414 }])],
14415 };
14416 let opts = RenderOptions {
14417 strip_local_ids: true,
14418 };
14419 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
14420 assert!(
14421 !md.contains("localId"),
14422 "localId should be stripped, got: {md}"
14423 );
14424 assert!(md.contains("color=green"), "color should be preserved");
14425 }
14426
14427 #[test]
14428 fn strip_local_ids_removes_localid_from_table() {
14429 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"layout":"default","localId":"table-uuid"},"content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14430 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14431 let opts = RenderOptions {
14432 strip_local_ids: true,
14433 };
14434 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14435 assert!(
14436 !md.contains("localId"),
14437 "localId should be stripped from table, got: {md}"
14438 );
14439 assert!(md.contains("layout=default"), "layout should be preserved");
14440 }
14441
14442 #[test]
14443 fn default_options_preserve_localid() {
14444 let adf = AdfDocument {
14445 version: 1,
14446 doc_type: "doc".to_string(),
14447 content: vec![AdfNode::paragraph(vec![{
14448 let mut node = AdfNode::status("open", "green");
14449 node.attrs.as_mut().unwrap()["localId"] =
14450 serde_json::Value::String("real-uuid-here".to_string());
14451 node
14452 }])],
14453 };
14454 let md = adf_to_markdown(&adf).unwrap();
14455 assert!(
14456 md.contains("localId=real-uuid-here"),
14457 "Default should preserve localId, got: {md}"
14458 );
14459 }
14460
14461 #[test]
14462 fn mention_localid_roundtrip() {
14463 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
14464 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14465 let md = adf_to_markdown(&doc).unwrap();
14466 assert!(
14467 md.contains("localId=m-001"),
14468 "mention should have localId in md: {md}"
14469 );
14470 let rt = markdown_to_adf(&md).unwrap();
14471 let mention = &rt.content[0].content.as_ref().unwrap()[0];
14472 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
14473 }
14474
14475 #[test]
14476 fn date_localid_roundtrip() {
14477 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14478 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14479 let md = adf_to_markdown(&doc).unwrap();
14480 assert!(
14481 md.contains("localId=d-001"),
14482 "date should have localId in md: {md}"
14483 );
14484 let rt = markdown_to_adf(&md).unwrap();
14485 let date = &rt.content[0].content.as_ref().unwrap()[0];
14486 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
14487 }
14488
14489 #[test]
14490 fn emoji_localid_roundtrip() {
14491 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
14492 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14493 let md = adf_to_markdown(&doc).unwrap();
14494 assert!(
14495 md.contains("localId=e-001"),
14496 "emoji should have localId in md: {md}"
14497 );
14498 let rt = markdown_to_adf(&md).unwrap();
14499 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
14500 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
14501 }
14502
14503 #[test]
14504 fn inline_card_localid_roundtrip() {
14505 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
14506 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14507 let md = adf_to_markdown(&doc).unwrap();
14508 assert!(
14509 md.contains("localId=c-001"),
14510 "inlineCard should have localId in md: {md}"
14511 );
14512 let rt = markdown_to_adf(&md).unwrap();
14513 let card = &rt.content[0].content.as_ref().unwrap()[0];
14514 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
14515 }
14516
14517 #[test]
14518 fn strip_local_ids_removes_from_mention() {
14519 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
14520 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14521 let opts = RenderOptions {
14522 strip_local_ids: true,
14523 };
14524 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14525 assert!(
14526 !md.contains("localId"),
14527 "localId should be stripped from mention: {md}"
14528 );
14529 assert!(md.contains("id=user123"), "other attrs should be preserved");
14530 }
14531
14532 #[test]
14533 fn strip_local_ids_removes_from_date() {
14534 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14535 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14536 let opts = RenderOptions {
14537 strip_local_ids: true,
14538 };
14539 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14540 assert!(
14541 !md.contains("localId"),
14542 "localId should be stripped from date: {md}"
14543 );
14544 }
14545
14546 #[test]
14547 fn strip_local_ids_removes_from_block_attrs() {
14548 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
14549 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14550 let opts = RenderOptions {
14551 strip_local_ids: true,
14552 };
14553 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14554 assert!(
14555 !md.contains("localId"),
14556 "localId should be stripped from block attrs: {md}"
14557 );
14558 }
14559
14560 #[test]
14561 fn table_cell_localid_roundtrip() {
14562 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"localId":"tc-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14563 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14564 let md = adf_to_markdown(&doc).unwrap();
14565 assert!(
14566 md.contains("localId=tc-001"),
14567 "tableCell should have localId in md: {md}"
14568 );
14569 let rt = markdown_to_adf(&md).unwrap();
14570 let cell = &rt.content[0].content.as_ref().unwrap()[0]
14571 .content
14572 .as_ref()
14573 .unwrap()[0];
14574 assert_eq!(
14575 cell.attrs.as_ref().unwrap()["localId"],
14576 "tc-001",
14577 "tableCell localId should round-trip"
14578 );
14579 }
14580
14581 #[test]
14582 fn table_cell_border_mark_roundtrip() {
14583 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":2}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"cell with border"}]}]}]}]}]}"##;
14584 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14585 let md = adf_to_markdown(&doc).unwrap();
14586 assert!(
14587 md.contains("border-color=#ff000033"),
14588 "tableCell should have border-color in md: {md}"
14589 );
14590 assert!(
14591 md.contains("border-size=2"),
14592 "tableCell should have border-size in md: {md}"
14593 );
14594 let rt = markdown_to_adf(&md).unwrap();
14595 let cell = &rt.content[0].content.as_ref().unwrap()[0]
14596 .content
14597 .as_ref()
14598 .unwrap()[0];
14599 let marks = cell.marks.as_ref().expect("tableCell should have marks");
14600 assert_eq!(marks.len(), 1);
14601 assert_eq!(marks[0].mark_type, "border");
14602 let attrs = marks[0].attrs.as_ref().unwrap();
14603 assert_eq!(attrs["color"], "#ff000033");
14604 assert_eq!(attrs["size"], 2);
14605 }
14606
14607 #[test]
14608 fn table_header_border_mark_roundtrip() {
14609 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"marks":[{"type":"border","attrs":{"color":"#0000ff","size":3}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]}]}]}"##;
14610 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14611 let md = adf_to_markdown(&doc).unwrap();
14612 assert!(md.contains("border-color=#0000ff"), "md: {md}");
14613 assert!(md.contains("border-size=3"), "md: {md}");
14614 let rt = markdown_to_adf(&md).unwrap();
14615 let cell = &rt.content[0].content.as_ref().unwrap()[0]
14616 .content
14617 .as_ref()
14618 .unwrap()[0];
14619 assert_eq!(cell.node_type, "tableHeader");
14620 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
14621 assert_eq!(marks[0].mark_type, "border");
14622 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
14623 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14624 }
14625
14626 #[test]
14627 fn table_cell_border_mark_with_attrs_roundtrip() {
14628 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#e6fcff","colspan":2},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":1}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"styled"}]}]}]}]}]}"##;
14629 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14630 let md = adf_to_markdown(&doc).unwrap();
14631 assert!(md.contains("bg=#e6fcff"), "md: {md}");
14632 assert!(md.contains("colspan=2"), "md: {md}");
14633 assert!(md.contains("border-color=#ff000033"), "md: {md}");
14634 let rt = markdown_to_adf(&md).unwrap();
14635 let cell = &rt.content[0].content.as_ref().unwrap()[0]
14636 .content
14637 .as_ref()
14638 .unwrap()[0];
14639 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
14640 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
14641 let marks = cell.marks.as_ref().expect("should have marks");
14642 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
14643 }
14644
14645 #[test]
14646 fn table_cell_no_border_mark_unchanged() {
14647 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"plain"}]}]}]}]}]}"#;
14648 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14649 let md = adf_to_markdown(&doc).unwrap();
14650 assert!(
14651 !md.contains("border-color"),
14652 "no border attrs expected: {md}"
14653 );
14654 let rt = markdown_to_adf(&md).unwrap();
14655 let cell = &rt.content[0].content.as_ref().unwrap()[0]
14656 .content
14657 .as_ref()
14658 .unwrap()[0];
14659 assert!(cell.marks.is_none(), "no marks expected on plain cell");
14660 }
14661
14662 #[test]
14663 fn table_cell_border_size_only_defaults_color() {
14664 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
14667 let doc = markdown_to_adf(md).unwrap();
14668 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14669 .content
14670 .as_ref()
14671 .unwrap()[0];
14672 let marks = cell.marks.as_ref().expect("should have border mark");
14673 assert_eq!(marks[0].mark_type, "border");
14674 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
14675 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14676 }
14677
14678 #[test]
14679 fn table_cell_border_color_only_defaults_size() {
14680 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
14682 let doc = markdown_to_adf(md).unwrap();
14683 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14684 .content
14685 .as_ref()
14686 .unwrap()[0];
14687 let marks = cell.marks.as_ref().expect("should have border mark");
14688 assert_eq!(marks[0].mark_type, "border");
14689 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
14690 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
14691 }
14692
14693 #[test]
14694 fn media_file_border_mark_roundtrip() {
14695 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-aabbccdd1234","type":"file","collection":"contentId-123456","width":800,"height":600},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
14696 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14697 let md = adf_to_markdown(&doc).unwrap();
14698 assert!(
14699 md.contains("border-color=#091e4224"),
14700 "media should have border-color in md: {md}"
14701 );
14702 assert!(
14703 md.contains("border-size=2"),
14704 "media should have border-size in md: {md}"
14705 );
14706 let rt = markdown_to_adf(&md).unwrap();
14707 let media_single = &rt.content[0];
14708 let media = &media_single.content.as_ref().unwrap()[0];
14709 assert_eq!(media.node_type, "media");
14710 let marks = media.marks.as_ref().expect("media should have marks");
14711 assert_eq!(marks.len(), 1);
14712 assert_eq!(marks[0].mark_type, "border");
14713 let attrs = marks[0].attrs.as_ref().unwrap();
14714 assert_eq!(attrs["color"], "#091e4224");
14715 assert_eq!(attrs["size"], 2);
14716 }
14717
14718 #[test]
14719 fn media_external_border_mark_roundtrip() {
14720 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png"},"marks":[{"type":"border","attrs":{"color":"#ff0000","size":3}}]}]}]}"##;
14721 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14722 let md = adf_to_markdown(&doc).unwrap();
14723 assert!(
14724 md.contains("border-color=#ff0000"),
14725 "external media should have border-color in md: {md}"
14726 );
14727 assert!(
14728 md.contains("border-size=3"),
14729 "external media should have border-size in md: {md}"
14730 );
14731 let rt = markdown_to_adf(&md).unwrap();
14732 let media = &rt.content[0].content.as_ref().unwrap()[0];
14733 let marks = media.marks.as_ref().expect("media should have marks");
14734 assert_eq!(marks[0].mark_type, "border");
14735 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
14736 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14737 }
14738
14739 #[test]
14740 fn media_file_no_border_mark_unchanged() {
14741 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc-123","type":"file","collection":"col-1","width":100,"height":100}}]}]}"#;
14742 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14743 let md = adf_to_markdown(&doc).unwrap();
14744 assert!(
14745 !md.contains("border-color"),
14746 "no border attrs expected: {md}"
14747 );
14748 let rt = markdown_to_adf(&md).unwrap();
14749 let media = &rt.content[0].content.as_ref().unwrap()[0];
14750 assert!(media.marks.is_none(), "no marks expected on plain media");
14751 }
14752
14753 #[test]
14754 fn media_border_size_only_defaults_color() {
14755 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"size":4}}]}]}]}"#;
14756 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14757 let md = adf_to_markdown(&doc).unwrap();
14758 assert!(md.contains("border-size=4"), "md: {md}");
14759 let rt = markdown_to_adf(&md).unwrap();
14760 let media = &rt.content[0].content.as_ref().unwrap()[0];
14761 let marks = media.marks.as_ref().expect("should have border mark");
14762 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
14763 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
14764 }
14765
14766 #[test]
14767 fn media_border_color_only_defaults_size() {
14768 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"color":"#00ff00"}}]}]}]}"##;
14769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14770 let md = adf_to_markdown(&doc).unwrap();
14771 assert!(md.contains("border-color=#00ff00"), "md: {md}");
14772 let rt = markdown_to_adf(&md).unwrap();
14773 let media = &rt.content[0].content.as_ref().unwrap()[0];
14774 let marks = media.marks.as_ref().expect("should have border mark");
14775 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
14776 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
14777 }
14778
14779 #[test]
14780 fn media_border_with_other_attrs_roundtrip() {
14781 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"wide","width":600,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"xyz","type":"file","collection":"col","width":1200,"height":800},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
14782 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14783 let md = adf_to_markdown(&doc).unwrap();
14784 assert!(md.contains("layout=wide"), "md: {md}");
14785 assert!(md.contains("mediaWidth=600"), "md: {md}");
14786 assert!(md.contains("border-color=#091e4224"), "md: {md}");
14787 assert!(md.contains("border-size=2"), "md: {md}");
14788 let rt = markdown_to_adf(&md).unwrap();
14789 let ms = &rt.content[0];
14790 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
14791 let media = &ms.content.as_ref().unwrap()[0];
14792 let marks = media.marks.as_ref().expect("should have marks");
14793 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
14794 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
14795 }
14796
14797 #[test]
14798 fn table_row_localid_roundtrip() {
14799 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","attrs":{"localId":"tr-001"},"content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14800 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14801 let md = adf_to_markdown(&doc).unwrap();
14802 assert!(
14803 md.contains("localId=tr-001"),
14804 "tableRow should have localId in md: {md}"
14805 );
14806 let rt = markdown_to_adf(&md).unwrap();
14807 let row = &rt.content[0].content.as_ref().unwrap()[0];
14808 assert_eq!(
14809 row.attrs.as_ref().unwrap()["localId"],
14810 "tr-001",
14811 "tableRow localId should round-trip"
14812 );
14813 }
14814
14815 #[test]
14816 fn list_item_localid_roundtrip() {
14817 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
14819 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14820 let md = adf_to_markdown(&doc).unwrap();
14821 assert!(
14822 md.contains("localId=li-001"),
14823 "listItem should have localId in md: {md}"
14824 );
14825 let rt = markdown_to_adf(&md).unwrap();
14827 let list = &rt.content[0];
14828 assert!(
14829 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
14830 "bulletList should NOT have localId: {:?}",
14831 list.attrs
14832 );
14833 let item = &list.content.as_ref().unwrap()[0];
14834 assert_eq!(
14835 item.attrs.as_ref().unwrap()["localId"],
14836 "li-001",
14837 "listItem should have localId=li-001"
14838 );
14839 }
14840
14841 #[test]
14842 fn list_item_localid_not_promoted_to_parent() {
14843 let md = "- item {localId=li-002}\n";
14845 let doc = markdown_to_adf(md).unwrap();
14846 let list = &doc.content[0];
14847 assert!(
14848 list.attrs.is_none(),
14849 "bulletList should have no attrs: {:?}",
14850 list.attrs
14851 );
14852 let item = &list.content.as_ref().unwrap()[0];
14853 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
14854 }
14855
14856 #[test]
14857 fn ordered_list_item_localid_roundtrip() {
14858 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#;
14859 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14860 let md = adf_to_markdown(&doc).unwrap();
14861 assert!(md.contains("localId=oli-001"), "md: {md}");
14862 let rt = markdown_to_adf(&md).unwrap();
14863 let item = &rt.content[0].content.as_ref().unwrap()[0];
14864 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
14865 }
14866
14867 #[test]
14868 fn task_item_localid_roundtrip() {
14869 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
14870 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14871 let md = adf_to_markdown(&doc).unwrap();
14872 assert!(md.contains("localId=ti-001"), "md: {md}");
14873 let rt = markdown_to_adf(&md).unwrap();
14874 let item = &rt.content[0].content.as_ref().unwrap()[0];
14875 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
14876 }
14877
14878 #[test]
14881 fn task_list_short_localid_roundtrip() {
14882 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"TODO"}},{"type":"taskItem","attrs":{"localId":"99","state":"DONE"},"content":[{"type":"text","text":"done task"}]}]}]}"#;
14883 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14884 let md = adf_to_markdown(&doc).unwrap();
14885 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14887 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
14888 assert!(
14890 !md.contains("localId=}"),
14891 "empty localId should not be emitted: {md}"
14892 );
14893 let rt = markdown_to_adf(&md).unwrap();
14894 let task_list = &rt.content[0];
14895 assert_eq!(task_list.node_type, "taskList");
14896 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
14898 let items = task_list.content.as_ref().unwrap();
14899 assert_eq!(items.len(), 2);
14900 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14902 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
14903 assert!(
14904 items[0].content.is_none(),
14905 "empty taskItem should have no content: {:?}",
14906 items[0].content
14907 );
14908 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
14910 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
14911 let content = items[1].content.as_ref().unwrap();
14912 assert_eq!(content.len(), 1);
14913 assert_eq!(content[0].text.as_deref(), Some("done task"));
14914 }
14915
14916 #[test]
14920 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
14921 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Engineering Onboarding Link","marks":[{"type":"link","attrs":{"href":"https://example.com/onboarding"}}]},{"type":"hardBreak"},{"type":"text","text":"(This has links to all the various useful tools!!)"}]}]}]}]}"#;
14922 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14923 let md = adf_to_markdown(&doc).unwrap();
14924 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14926 let rt = markdown_to_adf(&md).unwrap();
14928 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
14929 let task_list = &rt.content[0];
14930 assert_eq!(task_list.node_type, "taskList");
14931 let items = task_list.content.as_ref().unwrap();
14932 assert_eq!(items.len(), 1);
14933 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14935 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
14936 let para = &items[0].content.as_ref().unwrap()[0];
14938 assert_eq!(para.node_type, "paragraph");
14939 let inlines = para.content.as_ref().unwrap();
14940 assert_eq!(inlines[0].node_type, "text");
14941 assert_eq!(
14942 inlines[0].text.as_deref(),
14943 Some("Engineering Onboarding Link")
14944 );
14945 assert_eq!(inlines[1].node_type, "hardBreak");
14946 assert_eq!(inlines[2].node_type, "text");
14947 assert_eq!(
14948 inlines[2].text.as_deref(),
14949 Some("(This has links to all the various useful tools!!)")
14950 );
14951 let rt_json = serde_json::to_string(&rt).unwrap();
14953 assert!(
14954 !rt_json.contains("{localId="),
14955 "localId attr syntax should not leak into ADF text: {rt_json}"
14956 );
14957 }
14958
14959 #[test]
14961 fn task_item_multiple_hardbreak_localids_roundtrip() {
14962 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first line"},{"type":"hardBreak"},{"type":"text","text":"second line"}]}]},{"type":"taskItem","attrs":{"localId":"67","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]}]}]}]}"#;
14963 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14964 let md = adf_to_markdown(&doc).unwrap();
14965 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14966 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
14967 let rt = markdown_to_adf(&md).unwrap();
14968 let items = rt.content[0].content.as_ref().unwrap();
14969 assert_eq!(items.len(), 2);
14970 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14971 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
14972 for item in items {
14974 let para = &item.content.as_ref().unwrap()[0];
14975 assert_eq!(para.node_type, "paragraph");
14976 let inlines = para.content.as_ref().unwrap();
14977 assert_eq!(inlines[1].node_type, "hardBreak");
14978 }
14979 }
14980
14981 #[test]
14986 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
14987 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"text","text":"second task item"}]}]}]}"#;
14988 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14989 let md = adf_to_markdown(&doc).unwrap();
14990 assert!(
14992 md.contains(" (parenthetical"),
14993 "continuation line should be 2-space indented: {md}"
14994 );
14995 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14996 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
14997 let rt = markdown_to_adf(&md).unwrap();
14998 assert_eq!(
15000 rt.content.len(),
15001 1,
15002 "should be one taskList: {:#?}",
15003 rt.content
15004 );
15005 assert_eq!(rt.content[0].node_type, "taskList");
15006 let items = rt.content[0].content.as_ref().unwrap();
15007 assert_eq!(items.len(), 2, "should have 2 taskItems");
15008 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
15009 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
15010 let first_content = items[0].content.as_ref().unwrap();
15012 assert!(
15013 first_content.iter().any(|n| n.node_type == "hardBreak"),
15014 "first item should contain hardBreak"
15015 );
15016 let second_content = items[1].content.as_ref().unwrap();
15018 assert_eq!(second_content[0].node_type, "text");
15019 assert_eq!(
15020 second_content[0].text.as_deref().unwrap(),
15021 "second task item"
15022 );
15023 }
15024
15025 #[test]
15028 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
15029 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"second task item"}]}]}]}]}"#;
15030 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15031 let md = adf_to_markdown(&doc).unwrap();
15032 let rt = markdown_to_adf(&md).unwrap();
15033 assert_eq!(
15034 rt.content.len(),
15035 1,
15036 "should be one taskList: {:#?}",
15037 rt.content
15038 );
15039 let items = rt.content[0].content.as_ref().unwrap();
15040 assert_eq!(items.len(), 2);
15041 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
15042 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
15043 }
15044
15045 #[test]
15048 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
15049 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"10","state":"TODO"},"content":[{"type":"text","text":"first"}]},{"type":"taskItem","attrs":{"localId":"20","state":"DONE"},"content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]},{"type":"taskItem","attrs":{"localId":"30","state":"TODO"},"content":[{"type":"text","text":"third"}]}]}]}"#;
15050 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15051 let md = adf_to_markdown(&doc).unwrap();
15052 let rt = markdown_to_adf(&md).unwrap();
15053 assert_eq!(rt.content.len(), 1);
15054 let items = rt.content[0].content.as_ref().unwrap();
15055 assert_eq!(items.len(), 3);
15056 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
15057 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
15058 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
15059 let mid_content = items[1].content.as_ref().unwrap();
15061 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
15062 }
15063
15064 #[test]
15067 fn task_list_empty_localid_no_spurious_paragraph() {
15068 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"tsk-1","state":"DONE"},"content":[{"type":"text","text":"completed item"}]}]}]}"#;
15069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15070 let md = adf_to_markdown(&doc).unwrap();
15071 assert!(
15072 !md.contains("{localId=}"),
15073 "empty localId should not be emitted: {md}"
15074 );
15075 let rt = markdown_to_adf(&md).unwrap();
15076 assert_eq!(
15077 rt.content.len(),
15078 1,
15079 "no spurious paragraph: {:#?}",
15080 rt.content
15081 );
15082 assert_eq!(rt.content[0].node_type, "taskList");
15083 }
15084
15085 #[test]
15087 fn task_list_localid_stripped() {
15088 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15089 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15090 let opts = RenderOptions {
15091 strip_local_ids: true,
15092 };
15093 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15094 assert!(!md.contains("localId"), "localId should be stripped: {md}");
15095 }
15096
15097 #[test]
15099 fn task_item_no_content_emits_localid() {
15100 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"00000000-0000-0000-0000-000000000000"},"content":[{"type":"taskItem","attrs":{"localId":"abc","state":"TODO"}}]}]}"#;
15101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15102 let md = adf_to_markdown(&doc).unwrap();
15103 assert!(
15104 md.contains("localId=abc"),
15105 "localId should be emitted even without content: {md}"
15106 );
15107 let rt = markdown_to_adf(&md).unwrap();
15108 let item = &rt.content[0].content.as_ref().unwrap()[0];
15109 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
15110 assert!(item.content.is_none(), "should have no content");
15111 }
15112
15113 #[test]
15115 fn task_list_localid_roundtrip() {
15116 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-xyz"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15117 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15118 let md = adf_to_markdown(&doc).unwrap();
15119 assert!(
15120 md.contains("localId=tl-xyz"),
15121 "taskList localId missing: {md}"
15122 );
15123 let rt = markdown_to_adf(&md).unwrap();
15124 assert_eq!(
15125 rt.content[0].attrs.as_ref().unwrap()["localId"],
15126 "tl-xyz",
15127 "taskList localId should survive round-trip"
15128 );
15129 }
15130
15131 #[test]
15133 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
15134 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A task with paragraph wrapper"}]}]}]}]}"#;
15135 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15136 let md = adf_to_markdown(&doc).unwrap();
15137 assert!(
15138 md.contains("paraLocalId=_"),
15139 "should emit paraLocalId=_ sentinel: {md}"
15140 );
15141 let rt = markdown_to_adf(&md).unwrap();
15142 let item = &rt.content[0].content.as_ref().unwrap()[0];
15143 let content = item.content.as_ref().unwrap();
15144 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
15145 assert_eq!(
15146 content[0].node_type, "paragraph",
15147 "child should be a paragraph: {content:#?}"
15148 );
15149 let para_content = content[0].content.as_ref().unwrap();
15150 assert_eq!(
15151 para_content[0].text.as_deref(),
15152 Some("A task with paragraph wrapper")
15153 );
15154 assert!(
15156 content[0].attrs.is_none(),
15157 "paragraph should have no attrs: {:?}",
15158 content[0].attrs
15159 );
15160 }
15161
15162 #[test]
15164 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
15165 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"task with para id"}]}]}]}]}"#;
15166 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15167 let md = adf_to_markdown(&doc).unwrap();
15168 assert!(
15169 md.contains("paraLocalId=p-001"),
15170 "should emit paraLocalId=p-001: {md}"
15171 );
15172 let rt = markdown_to_adf(&md).unwrap();
15173 let item = &rt.content[0].content.as_ref().unwrap()[0];
15174 let content = item.content.as_ref().unwrap();
15175 assert_eq!(content[0].node_type, "paragraph");
15176 assert_eq!(
15177 content[0].attrs.as_ref().unwrap()["localId"],
15178 "p-001",
15179 "paragraph localId should be preserved"
15180 );
15181 }
15182
15183 #[test]
15185 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
15186 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"text","text":"unwrapped task"}]}]}]}"#;
15187 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15188 let md = adf_to_markdown(&doc).unwrap();
15189 assert!(
15190 !md.contains("paraLocalId"),
15191 "should NOT emit paraLocalId for unwrapped inline: {md}"
15192 );
15193 let rt = markdown_to_adf(&md).unwrap();
15194 let item = &rt.content[0].content.as_ref().unwrap()[0];
15195 let content = item.content.as_ref().unwrap();
15196 assert_eq!(
15197 content[0].node_type, "text",
15198 "should remain unwrapped: {content:#?}"
15199 );
15200 }
15201
15202 #[test]
15204 fn task_item_done_paragraph_wrapper_roundtrip() {
15205 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"completed task"}]}]}]}]}"#;
15206 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15207 let md = adf_to_markdown(&doc).unwrap();
15208 assert!(md.contains("- [x]"), "should render as done: {md}");
15209 let rt = markdown_to_adf(&md).unwrap();
15210 let item = &rt.content[0].content.as_ref().unwrap()[0];
15211 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
15212 let content = item.content.as_ref().unwrap();
15213 assert_eq!(content[0].node_type, "paragraph");
15214 }
15215
15216 #[test]
15218 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
15219 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"wrapped"}]}]},{"type":"taskItem","attrs":{"localId":"ti-002","state":"DONE"},"content":[{"type":"text","text":"unwrapped"}]}]}]}"#;
15220 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15221 let md = adf_to_markdown(&doc).unwrap();
15222 let rt = markdown_to_adf(&md).unwrap();
15223 let items = rt.content[0].content.as_ref().unwrap();
15224 assert_eq!(items.len(), 2);
15225 let c1 = items[0].content.as_ref().unwrap();
15227 assert_eq!(
15228 c1[0].node_type, "paragraph",
15229 "first item should have paragraph wrapper"
15230 );
15231 let c2 = items[1].content.as_ref().unwrap();
15233 assert_eq!(
15234 c2[0].node_type, "text",
15235 "second item should remain unwrapped"
15236 );
15237 }
15238
15239 #[test]
15241 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
15242 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"bold "},{"type":"text","text":"text","marks":[{"type":"strong"}]}]}]}]}]}"#;
15243 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15244 let md = adf_to_markdown(&doc).unwrap();
15245 let rt = markdown_to_adf(&md).unwrap();
15246 let item = &rt.content[0].content.as_ref().unwrap()[0];
15247 let content = item.content.as_ref().unwrap();
15248 assert_eq!(content[0].node_type, "paragraph");
15249 let para_children = content[0].content.as_ref().unwrap();
15250 assert!(
15251 para_children.len() >= 2,
15252 "paragraph should contain multiple inline nodes"
15253 );
15254 }
15255
15256 #[test]
15258 fn task_item_paragraph_wrapper_stripped_with_option() {
15259 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15260 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15261 let opts = RenderOptions {
15262 strip_local_ids: true,
15263 };
15264 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15265 assert!(
15266 !md.contains("paraLocalId"),
15267 "paraLocalId should be stripped: {md}"
15268 );
15269 assert!(
15270 !md.contains("localId"),
15271 "all localIds should be stripped: {md}"
15272 );
15273 }
15274
15275 #[test]
15276 fn trailing_space_preserved_with_hex_localid() {
15277 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabb112233cc"},"content":[{"type":"paragraph","content":[{"type":"text","text":"trailing space "}]}]}]}]}"#;
15280 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15281 let md = adf_to_markdown(&doc).unwrap();
15282 let rt = markdown_to_adf(&md).unwrap();
15283 let item = &rt.content[0].content.as_ref().unwrap()[0];
15284 assert_eq!(
15285 item.attrs.as_ref().unwrap()["localId"],
15286 "aabb112233cc",
15287 "localId should round-trip"
15288 );
15289 let para = &item.content.as_ref().unwrap()[0];
15290 let inlines = para.content.as_ref().unwrap();
15291 let last = inlines.last().unwrap();
15292 assert!(
15293 last.text.as_deref().unwrap_or("").ends_with(' '),
15294 "trailing space should be preserved, got nodes: {:?}",
15295 inlines
15296 .iter()
15297 .map(|n| (&n.node_type, &n.text))
15298 .collect::<Vec<_>>()
15299 );
15300 }
15301
15302 #[test]
15303 fn extract_trailing_local_id_preserves_trailing_space() {
15304 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
15306 assert_eq!(before, "trailing space ");
15307 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
15308 }
15309
15310 #[test]
15311 fn extract_trailing_local_id_no_trailing_space() {
15312 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
15313 assert_eq!(before, "text");
15314 assert_eq!(lid.as_deref(), Some("abc123"));
15315 }
15316
15317 #[test]
15318 fn extract_trailing_local_id_no_attrs() {
15319 let (before, lid, pid) = extract_trailing_local_id("plain text");
15320 assert_eq!(before, "plain text");
15321 assert!(lid.is_none());
15322 assert!(pid.is_none());
15323 }
15324
15325 #[test]
15326 fn list_item_localid_stripped() {
15327 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
15328 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15329 let opts = RenderOptions {
15330 strip_local_ids: true,
15331 };
15332 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15333 assert!(!md.contains("localId"), "localId should be stripped: {md}");
15334 }
15335
15336 #[test]
15337 fn paragraph_localid_in_list_item_roundtrip() {
15338 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","attrs":{"localId":"list-001"},"content":[{"type":"listItem","attrs":{"localId":"item-001"},"content":[{"type":"paragraph","attrs":{"localId":"para-001"},"content":[{"type":"text","text":"item text"}]}]}]}]}"#;
15340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15341 let md = adf_to_markdown(&doc).unwrap();
15342 assert!(
15343 md.contains("paraLocalId=para-001"),
15344 "paragraph localId should be in md: {md}"
15345 );
15346 let rt = markdown_to_adf(&md).unwrap();
15347 let item = &rt.content[0].content.as_ref().unwrap()[0];
15348 assert_eq!(
15349 item.attrs.as_ref().unwrap()["localId"],
15350 "item-001",
15351 "listItem localId should survive"
15352 );
15353 let para = &item.content.as_ref().unwrap()[0];
15354 assert_eq!(
15355 para.attrs.as_ref().unwrap()["localId"],
15356 "para-001",
15357 "paragraph localId should survive round-trip"
15358 );
15359 }
15360
15361 #[test]
15362 fn paragraph_localid_in_ordered_list_item_roundtrip() {
15363 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","attrs":{"localId":"op-001"},"content":[{"type":"text","text":"first"}]}]}]}]}"#;
15365 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15366 let md = adf_to_markdown(&doc).unwrap();
15367 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
15368 let rt = markdown_to_adf(&md).unwrap();
15369 let item = &rt.content[0].content.as_ref().unwrap()[0];
15370 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
15371 let para = &item.content.as_ref().unwrap()[0];
15372 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
15373 }
15374
15375 #[test]
15376 fn paragraph_localid_only_in_list_item() {
15377 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","attrs":{"localId":"para-only"},"content":[{"type":"text","text":"text"}]}]}]}]}"#;
15379 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15380 let md = adf_to_markdown(&doc).unwrap();
15381 assert!(
15382 md.contains("paraLocalId=para-only"),
15383 "paragraph localId should be emitted: {md}"
15384 );
15385 let rt = markdown_to_adf(&md).unwrap();
15386 let item = &rt.content[0].content.as_ref().unwrap()[0];
15387 assert!(item.attrs.is_none(), "listItem should have no attrs");
15388 let para = &item.content.as_ref().unwrap()[0];
15389 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
15390 }
15391
15392 #[test]
15393 fn paragraph_localid_in_table_header_roundtrip() {
15394 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"aaaa-aaaa"},"content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
15396 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15397 let md = adf_to_markdown(&doc).unwrap();
15398 assert!(
15400 md.contains("localId=aaaa-aaaa"),
15401 "paragraph localId should be in md: {md}"
15402 );
15403 let rt = markdown_to_adf(&md).unwrap();
15404 let cell = &rt.content[0].content.as_ref().unwrap()[0]
15405 .content
15406 .as_ref()
15407 .unwrap()[0];
15408 let para = &cell.content.as_ref().unwrap()[0];
15409 assert_eq!(
15410 para.attrs.as_ref().unwrap()["localId"],
15411 "aaaa-aaaa",
15412 "paragraph localId should survive round-trip in tableHeader"
15413 );
15414 }
15415
15416 #[test]
15417 fn paragraph_localid_in_table_cell_roundtrip() {
15418 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"cell-para"},"content":[{"type":"text","text":"data"}]}]}]}]}]}"#;
15420 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15421 let md = adf_to_markdown(&doc).unwrap();
15422 assert!(
15423 md.contains("localId=cell-para"),
15424 "paragraph localId should be in md: {md}"
15425 );
15426 let rt = markdown_to_adf(&md).unwrap();
15427 let cell = &rt.content[0].content.as_ref().unwrap()[1]
15429 .content
15430 .as_ref()
15431 .unwrap()[0];
15432 let para = &cell.content.as_ref().unwrap()[0];
15433 assert_eq!(
15434 para.attrs.as_ref().unwrap()["localId"],
15435 "cell-para",
15436 "paragraph localId should survive round-trip in tableCell"
15437 );
15438 }
15439
15440 #[test]
15441 fn nbsp_paragraph_with_localid_roundtrip() {
15442 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
15444 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15445 let md = adf_to_markdown(&doc).unwrap();
15446 assert!(
15447 md.contains("::paragraph["),
15448 "nbsp should use directive form: {md}"
15449 );
15450 assert!(
15451 md.contains("localId=nbsp-para"),
15452 "localId should be in directive: {md}"
15453 );
15454 let rt = markdown_to_adf(&md).unwrap();
15455 let para = &rt.content[0];
15456 assert_eq!(
15457 para.attrs.as_ref().unwrap()["localId"],
15458 "nbsp-para",
15459 "localId should survive round-trip"
15460 );
15461 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
15462 assert_eq!(text, "\u{00a0}", "nbsp should survive");
15463 }
15464
15465 #[test]
15466 fn empty_paragraph_with_localid_roundtrip() {
15467 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
15469 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15470 let md = adf_to_markdown(&doc).unwrap();
15471 assert!(
15472 md.contains("::paragraph{localId=empty-para}"),
15473 "empty paragraph should include localId in directive: {md}"
15474 );
15475 let rt = markdown_to_adf(&md).unwrap();
15476 assert_eq!(
15477 rt.content[0].attrs.as_ref().unwrap()["localId"],
15478 "empty-para"
15479 );
15480 }
15481
15482 #[test]
15483 fn paragraph_localid_stripped_from_list_item() {
15484 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"item"}]}]}]}]}"#;
15486 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15487 let opts = RenderOptions {
15488 strip_local_ids: true,
15489 };
15490 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15491 assert!(!md.contains("localId"), "localId should be stripped: {md}");
15492 assert!(
15493 !md.contains("paraLocalId"),
15494 "paraLocalId should be stripped: {md}"
15495 );
15496 }
15497
15498 #[test]
15499 fn date_directive() {
15500 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
15501 let content = doc.content[0].content.as_ref().unwrap();
15502 assert_eq!(content[1].node_type, "date");
15503 assert_eq!(
15505 content[1].attrs.as_ref().unwrap()["timestamp"],
15506 "1776211200000"
15507 );
15508 }
15509
15510 #[test]
15511 fn adf_date_to_markdown() {
15512 let doc = AdfDocument {
15514 version: 1,
15515 doc_type: "doc".to_string(),
15516 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
15517 };
15518 let md = adf_to_markdown(&doc).unwrap();
15519 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
15520 }
15521
15522 #[test]
15523 fn adf_date_iso_passthrough() {
15524 let doc = AdfDocument {
15526 version: 1,
15527 doc_type: "doc".to_string(),
15528 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
15529 };
15530 let md = adf_to_markdown(&doc).unwrap();
15531 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
15532 }
15533
15534 #[test]
15535 fn round_trip_date() {
15536 let md = "Due by :date[2026-04-15].\n";
15537 let doc = markdown_to_adf(md).unwrap();
15538 let result = adf_to_markdown(&doc).unwrap();
15539 assert!(result.contains(":date[2026-04-15]"));
15540 }
15541
15542 #[test]
15543 fn round_trip_date_non_midnight_timestamp() {
15544 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
15546 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15547 let md = adf_to_markdown(&doc).unwrap();
15548 assert!(
15550 md.contains("timestamp=1700000000000"),
15551 "JFM should preserve original timestamp: {md}"
15552 );
15553 let doc2 = markdown_to_adf(&md).unwrap();
15555 let content = doc2.content[0].content.as_ref().unwrap();
15556 assert_eq!(
15557 content[0].attrs.as_ref().unwrap()["timestamp"],
15558 "1700000000000",
15559 "Round-trip must preserve original non-midnight timestamp"
15560 );
15561 }
15562
15563 #[test]
15564 fn date_epoch_ms_passthrough() {
15565 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
15567 let content = doc.content[0].content.as_ref().unwrap();
15568 assert_eq!(
15569 content[1].attrs.as_ref().unwrap()["timestamp"],
15570 "1776211200000"
15571 );
15572 }
15573
15574 #[test]
15575 fn date_timestamp_attr_preferred_over_content() {
15576 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
15578 let doc = markdown_to_adf(md).unwrap();
15579 let content = doc.content[0].content.as_ref().unwrap();
15580 assert_eq!(
15581 content[0].attrs.as_ref().unwrap()["timestamp"],
15582 "1700000000000",
15583 "timestamp attr should be used directly"
15584 );
15585 }
15586
15587 #[test]
15588 fn date_without_timestamp_attr_backward_compat() {
15589 let md = ":date[2026-04-15]\n";
15591 let doc = markdown_to_adf(md).unwrap();
15592 let content = doc.content[0].content.as_ref().unwrap();
15593 assert_eq!(
15594 content[0].attrs.as_ref().unwrap()["timestamp"],
15595 "1776211200000",
15596 "Should fall back to computing timestamp from date string"
15597 );
15598 }
15599
15600 #[test]
15601 fn date_with_local_id_and_timestamp() {
15602 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
15604 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15605 let md = adf_to_markdown(&doc).unwrap();
15606 assert!(
15607 md.contains("timestamp=1700000000000"),
15608 "Should contain timestamp: {md}"
15609 );
15610 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
15611 let doc2 = markdown_to_adf(&md).unwrap();
15613 let content = doc2.content[0].content.as_ref().unwrap();
15614 let attrs = content[0].attrs.as_ref().unwrap();
15615 assert_eq!(attrs["timestamp"], "1700000000000");
15616 assert_eq!(attrs["localId"], "d-001");
15617 }
15618
15619 #[test]
15620 fn mention_directive() {
15621 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
15622 let content = doc.content[0].content.as_ref().unwrap();
15623 assert_eq!(content[1].node_type, "mention");
15624 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
15625 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
15626 }
15627
15628 #[test]
15629 fn adf_mention_to_markdown() {
15630 let doc = AdfDocument {
15631 version: 1,
15632 doc_type: "doc".to_string(),
15633 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
15634 "abc123", "Alice",
15635 )])],
15636 };
15637 let md = adf_to_markdown(&doc).unwrap();
15638 assert!(md.contains(":mention[Alice]{id=abc123}"));
15639 }
15640
15641 #[test]
15642 fn round_trip_mention() {
15643 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
15644 let doc = markdown_to_adf(md).unwrap();
15645 let result = adf_to_markdown(&doc).unwrap();
15646 assert!(result.contains(":mention[Alice]{id=abc123}"));
15647 }
15648
15649 #[test]
15650 fn mention_with_empty_access_level_round_trips() {
15651 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15653 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
15654 ]}]}"#;
15655 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15656
15657 let md = adf_to_markdown(&doc).unwrap();
15658 let round_tripped = markdown_to_adf(&md).unwrap();
15659 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
15660 assert_eq!(
15661 mention.node_type, "mention",
15662 "mention with empty accessLevel was not parsed as mention, got: {}",
15663 mention.node_type
15664 );
15665 }
15666
15667 #[test]
15668 fn span_with_color() {
15669 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
15670 let content = doc.content[0].content.as_ref().unwrap();
15671 assert_eq!(content[1].node_type, "text");
15672 assert_eq!(content[1].text.as_deref(), Some("red text"));
15673 let marks = content[1].marks.as_ref().unwrap();
15674 assert_eq!(marks[0].mark_type, "textColor");
15675 }
15676
15677 #[test]
15678 fn adf_text_color_to_markdown() {
15679 let doc = AdfDocument {
15680 version: 1,
15681 doc_type: "doc".to_string(),
15682 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
15683 "red text",
15684 vec![AdfMark::text_color("#ff5630")],
15685 )])],
15686 };
15687 let md = adf_to_markdown(&doc).unwrap();
15688 assert!(md.contains(":span[red text]{color=#ff5630}"));
15689 }
15690
15691 #[test]
15692 fn round_trip_span_color() {
15693 let md = "This is :span[red text]{color=#ff5630}.\n";
15694 let doc = markdown_to_adf(md).unwrap();
15695 let result = adf_to_markdown(&doc).unwrap();
15696 assert!(result.contains(":span[red text]{color=#ff5630}"));
15697 }
15698
15699 #[test]
15700 fn text_color_and_link_marks_both_preserved() {
15701 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15703 {"type":"text","text":"red link","marks":[
15704 {"type":"link","attrs":{"href":"https://example.com"}},
15705 {"type":"textColor","attrs":{"color":"#ff0000"}}
15706 ]}
15707 ]}]}"##;
15708 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15709 let md = adf_to_markdown(&doc).unwrap();
15710 assert!(
15711 md.contains(":span[red link]{color=#ff0000}"),
15712 "JFM should contain span with color, got: {md}"
15713 );
15714 assert!(
15715 md.contains("](https://example.com)"),
15716 "JFM should contain link href, got: {md}"
15717 );
15718 let rt = markdown_to_adf(&md).unwrap();
15720 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15721 let marks = text_node.marks.as_ref().expect("should have marks");
15722 assert!(
15723 marks.iter().any(|m| m.mark_type == "textColor"),
15724 "should have textColor mark, got: {:?}",
15725 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
15726 );
15727 assert!(
15728 marks.iter().any(|m| m.mark_type == "link"),
15729 "should have link mark, got: {:?}",
15730 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
15731 );
15732 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
15734 assert_eq!(
15735 link_mark.attrs.as_ref().unwrap()["href"],
15736 "https://example.com"
15737 );
15738 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
15739 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
15740 }
15741
15742 #[test]
15743 fn bg_color_and_link_marks_both_preserved() {
15744 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15745 {"type":"text","text":"highlighted link","marks":[
15746 {"type":"link","attrs":{"href":"https://example.com"}},
15747 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
15748 ]}
15749 ]}]}"##;
15750 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15751 let md = adf_to_markdown(&doc).unwrap();
15752 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
15753 assert!(
15754 md.contains("](https://example.com)"),
15755 "should have link: {md}"
15756 );
15757 let rt = markdown_to_adf(&md).unwrap();
15758 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15759 let marks = text_node.marks.as_ref().expect("should have marks");
15760 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
15761 assert!(marks.iter().any(|m| m.mark_type == "link"));
15762 }
15763
15764 #[test]
15765 fn text_color_link_and_strong_rendering() {
15766 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15768 {"type":"text","text":"bold red link","marks":[
15769 {"type":"strong"},
15770 {"type":"link","attrs":{"href":"https://example.com"}},
15771 {"type":"textColor","attrs":{"color":"#ff0000"}}
15772 ]}
15773 ]}]}"##;
15774 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15775 let md = adf_to_markdown(&doc).unwrap();
15776 assert!(
15777 md.starts_with("**") && md.trim().ends_with("**"),
15778 "should have bold wrapping: {md}"
15779 );
15780 assert!(md.contains("color=#ff0000"), "should have color: {md}");
15781 assert!(
15782 md.contains("](https://example.com)"),
15783 "should have link: {md}"
15784 );
15785 }
15786
15787 #[test]
15788 fn subsup_and_link_marks_both_preserved() {
15789 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15790 {"type":"text","text":"note","marks":[
15791 {"type":"link","attrs":{"href":"https://example.com"}},
15792 {"type":"subsup","attrs":{"type":"sup"}}
15793 ]}
15794 ]}]}"#;
15795 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15796 let md = adf_to_markdown(&doc).unwrap();
15797 assert!(md.contains("sup"), "should have sup: {md}");
15798 assert!(
15799 md.contains("](https://example.com)"),
15800 "should have link: {md}"
15801 );
15802 let rt = markdown_to_adf(&md).unwrap();
15803 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15804 let marks = text_node.marks.as_ref().expect("should have marks");
15805 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
15806 assert!(marks.iter().any(|m| m.mark_type == "link"));
15807 }
15808
15809 #[test]
15810 fn text_color_without_link_unchanged() {
15811 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15813 {"type":"text","text":"just red","marks":[
15814 {"type":"textColor","attrs":{"color":"#ff0000"}}
15815 ]}
15816 ]}]}"##;
15817 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15818 let md = adf_to_markdown(&doc).unwrap();
15819 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
15820 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
15821 }
15822
15823 #[test]
15824 fn inline_extension_directive() {
15825 let doc =
15826 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
15827 let content = doc.content[0].content.as_ref().unwrap();
15828 assert_eq!(content[1].node_type, "inlineExtension");
15829 assert_eq!(
15830 content[1].attrs.as_ref().unwrap()["extensionType"],
15831 "com.app"
15832 );
15833 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
15834 }
15835
15836 #[test]
15837 fn adf_inline_extension_to_markdown() {
15838 let doc = AdfDocument {
15839 version: 1,
15840 doc_type: "doc".to_string(),
15841 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
15842 "com.app",
15843 "widget",
15844 Some("fallback"),
15845 )])],
15846 };
15847 let md = adf_to_markdown(&doc).unwrap();
15848 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
15849 }
15850
15851 #[test]
15854 fn parse_ordered_list_marker_valid() {
15855 let result = parse_ordered_list_marker("1. Hello");
15856 assert_eq!(result, Some((1, "Hello")));
15857 }
15858
15859 #[test]
15860 fn parse_ordered_list_marker_high_number() {
15861 let result = parse_ordered_list_marker("42. Item");
15862 assert_eq!(result, Some((42, "Item")));
15863 }
15864
15865 #[test]
15866 fn parse_ordered_list_marker_not_a_list() {
15867 assert!(parse_ordered_list_marker("not a list").is_none());
15868 assert!(parse_ordered_list_marker("1.no space").is_none());
15869 }
15870
15871 #[test]
15872 fn is_list_start_various() {
15873 assert!(is_list_start("- item"));
15874 assert!(is_list_start("* item"));
15875 assert!(is_list_start("+ item"));
15876 assert!(is_list_start("1. item"));
15877 assert!(!is_list_start("not a list"));
15878 }
15879
15880 #[test]
15881 fn is_horizontal_rule_various() {
15882 assert!(is_horizontal_rule("---"));
15883 assert!(is_horizontal_rule("***"));
15884 assert!(is_horizontal_rule("___"));
15885 assert!(is_horizontal_rule("------"));
15886 assert!(!is_horizontal_rule("--"));
15887 assert!(!is_horizontal_rule("abc"));
15888 }
15889
15890 #[test]
15891 fn is_table_separator_valid() {
15892 assert!(is_table_separator("| --- | --- |"));
15893 assert!(is_table_separator("|:---:|:---|"));
15894 assert!(!is_table_separator("no pipes here"));
15895 }
15896
15897 #[test]
15898 fn parse_table_row_cells() {
15899 let cells = parse_table_row("| A | B | C |");
15900 assert_eq!(cells, vec!["A", "B", "C"]);
15901 }
15902
15903 #[test]
15904 fn parse_table_row_escaped_pipe_in_cell() {
15905 let cells = parse_table_row(r"| a\|b | c |");
15907 assert_eq!(cells, vec!["a|b", "c"]);
15908 }
15909
15910 #[test]
15911 fn parse_table_row_escaped_pipe_in_code_span() {
15912 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
15914 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
15915 }
15916
15917 #[test]
15918 fn parse_table_row_preserves_other_backslashes() {
15919 let cells = parse_table_row(r"| a\\b | c\*d |");
15921 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
15922 }
15923
15924 #[test]
15925 fn parse_image_syntax_valid() {
15926 let result = parse_image_syntax("");
15927 assert_eq!(result, Some(("alt", "url")));
15928 }
15929
15930 #[test]
15931 fn parse_image_syntax_not_image() {
15932 assert!(parse_image_syntax("not an image").is_none());
15933 }
15934
15935 #[test]
15938 fn find_closing_paren_simple() {
15939 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
15940 }
15941
15942 #[test]
15943 fn find_closing_paren_nested() {
15944 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
15945 }
15946
15947 #[test]
15948 fn find_closing_paren_unmatched() {
15949 assert_eq!(find_closing_paren("(no close", 0), None);
15950 }
15951
15952 #[test]
15953 fn find_closing_paren_offset() {
15954 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
15956 }
15957
15958 #[test]
15961 fn try_parse_link_url_with_parens() {
15962 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
15963 let result = try_parse_link(input, 0);
15964 assert_eq!(
15965 result,
15966 Some((
15967 input.len(),
15968 "here",
15969 "https://example.com/faq#access-(permissions)-rest"
15970 ))
15971 );
15972 }
15973
15974 #[test]
15975 fn try_parse_link_url_no_parens() {
15976 let input = "[text](https://example.com)";
15977 let result = try_parse_link(input, 0);
15978 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
15979 }
15980
15981 #[test]
15982 fn try_parse_link_url_with_multiple_nested_parens() {
15983 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
15984 let result = try_parse_link(input, 0);
15985 assert_eq!(
15986 result,
15987 Some((
15988 input.len(),
15989 "x",
15990 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
15991 ))
15992 );
15993 }
15994
15995 #[test]
15996 fn parse_image_syntax_url_with_parens() {
15997 let result = parse_image_syntax(")");
15998 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
15999 }
16000
16001 #[test]
16002 fn parse_image_syntax_url_no_parens() {
16003 let result = parse_image_syntax("");
16004 assert_eq!(result, Some(("alt", "https://example.com")));
16005 }
16006
16007 #[test]
16008 fn link_with_parens_round_trip() {
16009 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
16010 let mut text_node = AdfNode::text("here");
16011 text_node.marks = Some(vec![AdfMark::link(href)]);
16012 let adf_input = AdfDocument {
16013 version: 1,
16014 doc_type: "doc".to_string(),
16015 content: vec![AdfNode::paragraph(vec![text_node])],
16016 };
16017
16018 let jfm = adf_to_markdown(&adf_input).unwrap();
16019 let adf_output = markdown_to_adf(&jfm).unwrap();
16020
16021 let para = &adf_output.content[0];
16023 let text_node = ¶.content.as_ref().unwrap()[0];
16024 let mark = &text_node.marks.as_ref().unwrap()[0];
16025 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
16026
16027 assert_eq!(result_href, href);
16028 }
16029
16030 #[test]
16031 fn flush_plain_empty_range() {
16032 let mut nodes = Vec::new();
16033 flush_plain("hello", 3, 3, &mut nodes);
16034 assert!(nodes.is_empty());
16035 }
16036
16037 #[test]
16038 fn add_mark_to_unmarked_node() {
16039 let mut node = AdfNode::text("test");
16040 add_mark(&mut node, AdfMark::strong());
16041 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
16042 }
16043
16044 #[test]
16045 fn add_mark_to_marked_node() {
16046 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
16047 add_mark(&mut node, AdfMark::em());
16048 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
16049 }
16050
16051 #[test]
16054 fn directive_table_basic() {
16055 let md = "::::table\n:::tr\n:::th\nHeader 1\n:::\n:::th\nHeader 2\n:::\n:::\n:::tr\n:::td\nCell 1\n:::\n:::td\nCell 2\n:::\n:::\n::::\n";
16056 let doc = markdown_to_adf(md).unwrap();
16057 assert_eq!(doc.content[0].node_type, "table");
16058 let rows = doc.content[0].content.as_ref().unwrap();
16059 assert_eq!(rows.len(), 2);
16060 assert_eq!(
16061 rows[0].content.as_ref().unwrap()[0].node_type,
16062 "tableHeader"
16063 );
16064 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
16065 }
16066
16067 #[test]
16068 fn directive_table_with_block_content() {
16069 let md = "::::table\n:::tr\n:::td\nCell with list:\n\n- Item 1\n- Item 2\n:::\n:::td\nSimple cell\n:::\n:::\n::::\n";
16070 let doc = markdown_to_adf(md).unwrap();
16071 let rows = doc.content[0].content.as_ref().unwrap();
16072 let cell = &rows[0].content.as_ref().unwrap()[0];
16073 let content = cell.content.as_ref().unwrap();
16075 assert!(content.len() >= 2);
16076 assert_eq!(content[1].node_type, "bulletList");
16077 }
16078
16079 #[test]
16080 fn directive_table_with_cell_attrs() {
16081 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
16082 let doc = markdown_to_adf(md).unwrap();
16083 let cell = &doc.content[0].content.as_ref().unwrap()[0]
16084 .content
16085 .as_ref()
16086 .unwrap()[0];
16087 let attrs = cell.attrs.as_ref().unwrap();
16088 assert_eq!(attrs["colspan"], 2);
16089 assert_eq!(attrs["background"], "#DEEBFF");
16090 }
16091
16092 #[test]
16093 fn directive_table_with_css_var_background() {
16094 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
16095 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
16096 let doc = markdown_to_adf(&md).unwrap();
16097 let row = &doc.content[0].content.as_ref().unwrap()[0];
16098 let cells = row.content.as_ref().unwrap();
16099 assert_eq!(cells.len(), 1, "row must have at least one cell");
16100 let attrs = cells[0].attrs.as_ref().unwrap();
16101 assert_eq!(attrs["background"], bg);
16102 }
16103
16104 #[test]
16105 fn css_var_background_round_trips() {
16106 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
16107 let adf = AdfDocument {
16108 version: 1,
16109 doc_type: "doc".to_string(),
16110 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16111 AdfNode::table_header_with_attrs(
16112 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
16113 serde_json::json!({"background": bg}),
16114 ),
16115 ])])],
16116 };
16117 let md = adf_to_markdown(&adf).unwrap();
16118 assert!(
16119 md.contains(&format!("bg=\"{bg}\"")),
16120 "bg value must be quoted in markdown: {md}"
16121 );
16122
16123 let round_tripped = markdown_to_adf(&md).unwrap();
16124 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
16125 let cells = row.content.as_ref().unwrap();
16126 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
16127 let rt_attrs = cells[0].attrs.as_ref().unwrap();
16128 assert_eq!(rt_attrs["background"], bg);
16129 }
16130
16131 #[test]
16132 fn directive_table_with_table_attrs() {
16133 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
16134 let doc = markdown_to_adf(md).unwrap();
16135 let attrs = doc.content[0].attrs.as_ref().unwrap();
16136 assert_eq!(attrs["layout"], "wide");
16137 assert_eq!(attrs["isNumberColumnEnabled"], true);
16138 }
16139
16140 #[test]
16141 fn adf_table_with_block_content_renders_directive_form() {
16142 let doc = AdfDocument {
16144 version: 1,
16145 doc_type: "doc".to_string(),
16146 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16147 AdfNode::table_cell(vec![
16148 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
16149 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
16150 AdfNode::text("Item 1"),
16151 ])])]),
16152 ]),
16153 ])])],
16154 };
16155 let md = adf_to_markdown(&doc).unwrap();
16156 assert!(md.contains("::::table"));
16157 assert!(md.contains(":::td"));
16158 assert!(md.contains("- Item 1"));
16159 }
16160
16161 #[test]
16162 fn adf_table_inline_only_renders_pipe_form() {
16163 let doc = AdfDocument {
16165 version: 1,
16166 doc_type: "doc".to_string(),
16167 content: vec![AdfNode::table(vec![
16168 AdfNode::table_row(vec![
16169 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
16170 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
16171 ]),
16172 AdfNode::table_row(vec![
16173 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
16174 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
16175 ]),
16176 ])],
16177 };
16178 let md = adf_to_markdown(&doc).unwrap();
16179 assert!(md.contains("| H1 | H2 |"));
16180 assert!(!md.contains("::::table"));
16181 }
16182
16183 #[test]
16184 fn adf_table_header_outside_first_row_renders_directive() {
16185 let doc = AdfDocument {
16186 version: 1,
16187 doc_type: "doc".to_string(),
16188 content: vec![AdfNode::table(vec![
16189 AdfNode::table_row(vec![
16190 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
16191 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
16192 ]),
16193 AdfNode::table_row(vec![
16194 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
16195 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
16196 ]),
16197 ])],
16198 };
16199 let md = adf_to_markdown(&doc).unwrap();
16200 assert!(md.contains("::::table"));
16201 assert!(md.contains(":::th"));
16202 }
16203
16204 #[test]
16205 fn adf_table_cell_attrs_rendered() {
16206 let doc = AdfDocument {
16207 version: 1,
16208 doc_type: "doc".to_string(),
16209 content: vec![AdfNode::table(vec![
16210 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
16211 AdfNode::text("H"),
16212 ])])]),
16213 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
16214 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
16215 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
16216 )]),
16217 ])],
16218 };
16219 let md = adf_to_markdown(&doc).unwrap();
16220 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
16221 }
16222
16223 #[test]
16226 fn pipe_table_cell_attrs() {
16227 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
16228 let doc = markdown_to_adf(md).unwrap();
16229 let rows = doc.content[0].content.as_ref().unwrap();
16230 let cell = &rows[1].content.as_ref().unwrap()[0];
16231 let attrs = cell.attrs.as_ref().unwrap();
16232 assert_eq!(attrs["background"], "#DEEBFF");
16233 }
16234
16235 #[test]
16236 fn pipe_table_cell_colspan() {
16237 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
16238 let doc = markdown_to_adf(md).unwrap();
16239 let rows = doc.content[0].content.as_ref().unwrap();
16240 let cell = &rows[1].content.as_ref().unwrap()[0];
16241 let attrs = cell.attrs.as_ref().unwrap();
16242 assert_eq!(attrs["colspan"], 2);
16243 }
16244
16245 #[test]
16246 fn trailing_space_after_mention_in_table_cell_preserved() {
16247 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[
16249 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
16250 {"type":"text","text":" "}
16251 ]}]}]}]}]}"#;
16252 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16253 let md = adf_to_markdown(&doc).unwrap();
16254 let round_tripped = markdown_to_adf(&md).unwrap();
16255 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16256 .content
16257 .as_ref()
16258 .unwrap()[0];
16259 let para = &cell.content.as_ref().unwrap()[0];
16260 let inlines = para.content.as_ref().unwrap();
16261 assert!(
16262 inlines.len() >= 2,
16263 "expected mention + text(' ') nodes, got {} nodes: {:?}",
16264 inlines.len(),
16265 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
16266 );
16267 assert_eq!(inlines[0].node_type, "mention");
16268 assert_eq!(inlines[1].node_type, "text");
16269 assert_eq!(inlines[1].text.as_deref(), Some(" "));
16270 }
16271
16272 #[test]
16275 fn pipe_table_column_alignment() {
16276 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
16277 let doc = markdown_to_adf(md).unwrap();
16278 let rows = doc.content[0].content.as_ref().unwrap();
16279 let h_cells = rows[0].content.as_ref().unwrap();
16281 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
16283 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
16285 .marks
16286 .as_ref()
16287 .unwrap();
16288 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
16289 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
16291 .marks
16292 .as_ref()
16293 .unwrap();
16294 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
16295 }
16296
16297 #[test]
16298 fn adf_table_alignment_roundtrip() {
16299 let doc = AdfDocument {
16300 version: 1,
16301 doc_type: "doc".to_string(),
16302 content: vec![AdfNode::table(vec![
16303 AdfNode::table_row(vec![
16304 AdfNode::table_header(vec![{
16305 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
16306 p.marks = Some(vec![AdfMark::alignment("center")]);
16307 p
16308 }]),
16309 AdfNode::table_header(vec![{
16310 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
16311 p.marks = Some(vec![AdfMark::alignment("end")]);
16312 p
16313 }]),
16314 ]),
16315 AdfNode::table_row(vec![
16316 AdfNode::table_cell(vec![{
16317 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
16318 p.marks = Some(vec![AdfMark::alignment("center")]);
16319 p
16320 }]),
16321 AdfNode::table_cell(vec![{
16322 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
16323 p.marks = Some(vec![AdfMark::alignment("end")]);
16324 p
16325 }]),
16326 ]),
16327 ])],
16328 };
16329 let md = adf_to_markdown(&doc).unwrap();
16330 assert!(md.contains(":---:"));
16331 assert!(md.contains("---:"));
16332 }
16333
16334 #[test]
16337 fn panel_custom_attrs_round_trip() {
16338 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
16339 let doc = markdown_to_adf(md).unwrap();
16340 let panel = &doc.content[0];
16341 let attrs = panel.attrs.as_ref().unwrap();
16342 assert_eq!(attrs["panelType"], "custom");
16343 assert_eq!(attrs["panelIcon"], ":star:");
16344 assert_eq!(attrs["panelColor"], "#DEEBFF");
16345
16346 let result = adf_to_markdown(&doc).unwrap();
16347 assert!(result.contains("type=custom"));
16348 assert!(result.contains("icon="));
16349 assert!(result.contains("color="));
16350 }
16351
16352 #[test]
16355 fn block_card_with_layout() {
16356 let md = "::card[https://example.com]{layout=wide}\n";
16357 let doc = markdown_to_adf(md).unwrap();
16358 let attrs = doc.content[0].attrs.as_ref().unwrap();
16359 assert_eq!(attrs["layout"], "wide");
16360
16361 let result = adf_to_markdown(&doc).unwrap();
16362 assert!(result.contains("::card[https://example.com]{layout=wide}"));
16363 }
16364
16365 #[test]
16368 fn extension_with_params() {
16369 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
16370 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
16371 let attrs = doc.content[0].attrs.as_ref().unwrap();
16372 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
16373 }
16374
16375 #[test]
16376 fn leaf_extension_layout_preserved_in_roundtrip() {
16377 let adf_json = r#"{"version":1,"type":"doc","content":[
16379 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
16380 ]}"#;
16381 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16382 let md = adf_to_markdown(&doc).unwrap();
16383 assert!(
16384 md.contains("layout=default"),
16385 "JFM should contain layout=default, got: {md}"
16386 );
16387 let round_tripped = markdown_to_adf(&md).unwrap();
16388 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16389 assert_eq!(attrs["layout"], "default", "layout should be preserved");
16390 assert_eq!(attrs["extensionKey"], "toc");
16391 }
16392
16393 #[test]
16394 fn bodied_extension_layout_preserved_in_roundtrip() {
16395 let adf_json = r#"{"version":1,"type":"doc","content":[
16397 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
16398 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
16399 ]}"#;
16400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16401 let md = adf_to_markdown(&doc).unwrap();
16402 assert!(
16403 md.contains("layout=wide"),
16404 "JFM should contain layout=wide, got: {md}"
16405 );
16406 let round_tripped = markdown_to_adf(&md).unwrap();
16407 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16408 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
16409 }
16410
16411 #[test]
16412 fn bodied_extension_parameters_preserved_in_roundtrip() {
16413 let adf_json = r#"{"version":1,"type":"doc","content":[
16415 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"details","layout":"default","localId":"aabbccdd-1234","parameters":{"macroMetadata":{"macroId":{"value":"bbccddee-2345"},"schemaVersion":{"value":"1"},"title":"Page Properties"},"macroParams":{}}},
16416 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
16417 ]}"#;
16418 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16419 let md = adf_to_markdown(&doc).unwrap();
16420 assert!(
16421 md.contains("params="),
16422 "JFM should contain params attribute, got: {md}"
16423 );
16424 let round_tripped = markdown_to_adf(&md).unwrap();
16425 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16426 assert_eq!(
16427 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
16428 "parameters should be preserved in round-trip"
16429 );
16430 assert_eq!(attrs["extensionKey"], "details");
16431 assert_eq!(attrs["layout"], "default");
16432 assert_eq!(attrs["localId"], "aabbccdd-1234");
16433 }
16434
16435 #[test]
16436 fn bodied_extension_malformed_params_ignored() {
16437 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
16439 let doc = markdown_to_adf(md).unwrap();
16440 let attrs = doc.content[0].attrs.as_ref().unwrap();
16441 assert_eq!(attrs["extensionKey"], "details");
16442 assert!(attrs.get("parameters").is_none());
16444 }
16445
16446 #[test]
16447 fn leaf_extension_localid_preserved_in_roundtrip() {
16448 let adf_json = r#"{"version":1,"type":"doc","content":[
16450 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
16451 ]}"#;
16452 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16453 let md = adf_to_markdown(&doc).unwrap();
16454 let round_tripped = markdown_to_adf(&md).unwrap();
16455 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16456 assert_eq!(attrs["layout"], "default");
16457 assert_eq!(attrs["localId"], "abc-123");
16458 }
16459
16460 #[test]
16463 fn mention_with_user_type() {
16464 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
16465 let doc = markdown_to_adf(md).unwrap();
16466 let mention = &doc.content[0].content.as_ref().unwrap()[1];
16467 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
16468
16469 let result = adf_to_markdown(&doc).unwrap();
16470 assert!(result.contains("userType=DEFAULT"));
16471 }
16472
16473 #[test]
16476 fn directive_table_colwidth() {
16477 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
16478 let doc = markdown_to_adf(md).unwrap();
16479 let cell = &doc.content[0].content.as_ref().unwrap()[0]
16480 .content
16481 .as_ref()
16482 .unwrap()[0];
16483 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16484 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
16485 }
16486
16487 #[test]
16488 fn directive_table_colwidth_float_roundtrip() {
16489 let adf_doc = serde_json::json!({
16492 "type": "doc",
16493 "version": 1,
16494 "content": [{
16495 "type": "table",
16496 "content": [{
16497 "type": "tableRow",
16498 "content": [
16499 {
16500 "type": "tableHeader",
16501 "attrs": { "colwidth": [157.0] },
16502 "content": [{ "type": "paragraph" }]
16503 },
16504 {
16505 "type": "tableHeader",
16506 "attrs": { "colwidth": [863.0] },
16507 "content": [{ "type": "paragraph" }]
16508 }
16509 ]
16510 }]
16511 }]
16512 });
16513 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16514 let md = adf_to_markdown(&doc).unwrap();
16515 assert!(
16516 md.contains("colwidth=157.0"),
16517 "expected colwidth=157.0 in markdown, got: {md}"
16518 );
16519 assert!(
16520 md.contains("colwidth=863.0"),
16521 "expected colwidth=863.0 in markdown, got: {md}"
16522 );
16523 let doc2 = markdown_to_adf(&md).unwrap();
16525 let row = &doc2.content[0].content.as_ref().unwrap()[0];
16526 let header1 = &row.content.as_ref().unwrap()[0];
16527 let header2 = &row.content.as_ref().unwrap()[1];
16528 assert_eq!(
16529 header1.attrs.as_ref().unwrap()["colwidth"]
16530 .as_array()
16531 .unwrap(),
16532 &[serde_json::json!(157.0)]
16533 );
16534 assert_eq!(
16535 header2.attrs.as_ref().unwrap()["colwidth"]
16536 .as_array()
16537 .unwrap(),
16538 &[serde_json::json!(863.0)]
16539 );
16540 }
16541
16542 #[test]
16543 fn colwidth_float_preserved_in_roundtrip() {
16544 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colwidth":[254.0,416.0]},"content":[{"type":"paragraph","content":[]}]}]}]}]}"#;
16546 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16547 let md = adf_to_markdown(&doc).unwrap();
16548 let round_tripped = markdown_to_adf(&md).unwrap();
16549 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16550 .content
16551 .as_ref()
16552 .unwrap()[0];
16553 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16554 assert_eq!(
16555 colwidth,
16556 &[serde_json::json!(254.0), serde_json::json!(416.0)],
16557 "colwidth should preserve float values"
16558 );
16559 }
16560
16561 #[test]
16562 fn colwidth_integer_preserved_in_roundtrip() {
16563 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[150],"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16566 let md = adf_to_markdown(&doc).unwrap();
16567 assert!(
16568 md.contains("colwidth=150"),
16569 "expected colwidth=150 (no decimal) in markdown, got: {md}"
16570 );
16571 assert!(
16572 !md.contains("colwidth=150.0"),
16573 "colwidth should not have .0 suffix for integers, got: {md}"
16574 );
16575 let round_tripped = markdown_to_adf(&md).unwrap();
16577 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16578 .content
16579 .as_ref()
16580 .unwrap()[0];
16581 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16582 assert_eq!(
16583 colwidth,
16584 &[serde_json::json!(150)],
16585 "colwidth should preserve integer values"
16586 );
16587 let json_output = serde_json::to_string(&round_tripped).unwrap();
16589 assert!(
16590 json_output.contains(r#""colwidth":[150]"#),
16591 "JSON should contain integer colwidth, got: {json_output}"
16592 );
16593 }
16594
16595 #[test]
16596 fn colwidth_mixed_int_and_float_roundtrip() {
16597 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
16600 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
16601
16602 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
16604 let int_md = adf_to_markdown(&int_doc).unwrap();
16605 assert!(
16606 int_md.contains("colwidth=100,200"),
16607 "integer colwidth in md: {int_md}"
16608 );
16609 let int_rt = markdown_to_adf(&int_md).unwrap();
16610 let int_serial = serde_json::to_string(&int_rt).unwrap();
16611 assert!(
16612 int_serial.contains(r#""colwidth":[100,200]"#),
16613 "integer colwidth in JSON: {int_serial}"
16614 );
16615
16616 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
16618 let float_md = adf_to_markdown(&float_doc).unwrap();
16619 assert!(
16620 float_md.contains("colwidth=100.0,200.0"),
16621 "float colwidth in md: {float_md}"
16622 );
16623 let float_rt = markdown_to_adf(&float_md).unwrap();
16624 let float_serial = serde_json::to_string(&float_rt).unwrap();
16625 assert!(
16626 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
16627 "float colwidth in JSON: {float_serial}"
16628 );
16629 }
16630
16631 #[test]
16632 fn colwidth_fractional_float_preserved() {
16633 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.5]},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16635 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16636 let md = adf_to_markdown(&doc).unwrap();
16637 assert!(
16638 md.contains("colwidth=100.5"),
16639 "expected colwidth=100.5 in markdown, got: {md}"
16640 );
16641 }
16642
16643 #[test]
16644 fn colwidth_non_numeric_values_skipped() {
16645 let adf_doc = serde_json::json!({
16647 "type": "doc",
16648 "version": 1,
16649 "content": [{
16650 "type": "table",
16651 "content": [{
16652 "type": "tableRow",
16653 "content": [{
16654 "type": "tableCell",
16655 "attrs": { "colwidth": ["invalid"] },
16656 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
16657 }]
16658 }]
16659 }]
16660 });
16661 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
16662 let md = adf_to_markdown(&doc).unwrap();
16663 assert!(
16665 !md.contains("colwidth"),
16666 "non-numeric colwidth should be filtered out, got: {md}"
16667 );
16668 }
16669
16670 #[test]
16671 fn default_rowspan_colspan_preserved_in_roundtrip() {
16672 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"rowspan":1,"colspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16674 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16675 let md = adf_to_markdown(&doc).unwrap();
16676 let round_tripped = markdown_to_adf(&md).unwrap();
16677 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16678 .content
16679 .as_ref()
16680 .unwrap()[0];
16681 let attrs = cell.attrs.as_ref().unwrap();
16682 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
16683 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
16684 }
16685
16686 #[test]
16689 fn table_localid_preserved_in_roundtrip() {
16690 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default","localId":"7afd4550-e66c-4b12-875f-a91c6c7b62c7"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16692 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16693 let md = adf_to_markdown(&doc).unwrap();
16694 assert!(
16695 md.contains("localId="),
16696 "JFM should contain localId, got: {md}"
16697 );
16698 let round_tripped = markdown_to_adf(&md).unwrap();
16699 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16700 assert_eq!(
16701 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
16702 "localId should be preserved"
16703 );
16704 }
16705
16706 #[test]
16707 fn paragraph_localid_preserved_in_roundtrip() {
16708 let adf_json = r#"{"version":1,"type":"doc","content":[
16710 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
16711 ]}"#;
16712 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16713 let md = adf_to_markdown(&doc).unwrap();
16714 assert!(
16715 md.contains("localId=abc-123"),
16716 "JFM should contain localId, got: {md}"
16717 );
16718 let round_tripped = markdown_to_adf(&md).unwrap();
16719 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16720 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
16721 }
16722
16723 #[test]
16724 fn heading_localid_preserved_in_roundtrip() {
16725 let adf_json = r#"{"version":1,"type":"doc","content":[
16726 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
16727 ]}"#;
16728 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16729 let md = adf_to_markdown(&doc).unwrap();
16730 let round_tripped = markdown_to_adf(&md).unwrap();
16731 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16732 assert_eq!(attrs["localId"], "h-456");
16733 }
16734
16735 #[test]
16736 fn localid_with_alignment_preserved() {
16737 let adf_json = r#"{"version":1,"type":"doc","content":[
16739 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
16740 "content":[{"type":"text","text":"centered"}]}
16741 ]}"#;
16742 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16743 let md = adf_to_markdown(&doc).unwrap();
16744 assert!(md.contains("localId=p-789"), "should have localId: {md}");
16745 assert!(md.contains("align=center"), "should have align: {md}");
16746 let round_tripped = markdown_to_adf(&md).unwrap();
16747 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16748 assert_eq!(attrs["localId"], "p-789");
16749 let marks = round_tripped.content[0].marks.as_ref().unwrap();
16750 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
16751 }
16752
16753 #[test]
16754 fn table_layout_default_preserved_in_roundtrip() {
16755 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16757 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16758 let md = adf_to_markdown(&doc).unwrap();
16759 let round_tripped = markdown_to_adf(&md).unwrap();
16760 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16761 assert_eq!(
16762 attrs["layout"], "default",
16763 "layout='default' should be preserved"
16764 );
16765 }
16766
16767 #[test]
16768 fn table_is_number_column_enabled_false_preserved() {
16769 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16771 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16772 let md = adf_to_markdown(&doc).unwrap();
16773 let round_tripped = markdown_to_adf(&md).unwrap();
16774 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16775 assert_eq!(
16776 attrs["isNumberColumnEnabled"], false,
16777 "isNumberColumnEnabled=false should be preserved"
16778 );
16779 }
16780
16781 #[test]
16782 fn table_is_number_column_enabled_true_preserved() {
16783 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16785 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16786 let md = adf_to_markdown(&doc).unwrap();
16787 let round_tripped = markdown_to_adf(&md).unwrap();
16788 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16789 assert_eq!(
16790 attrs["isNumberColumnEnabled"], true,
16791 "isNumberColumnEnabled=true should be preserved"
16792 );
16793 }
16794
16795 #[test]
16796 fn directive_table_is_number_column_enabled_false_preserved() {
16797 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
16800 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
16801 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
16802 ]}]}]}]}"#;
16803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16804 let md = adf_to_markdown(&doc).unwrap();
16805 assert!(md.contains("::::table"), "should use directive table form");
16806 assert!(
16807 md.contains("numbered=false"),
16808 "should contain numbered=false, got: {md}"
16809 );
16810 let round_tripped = markdown_to_adf(&md).unwrap();
16811 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16812 assert_eq!(attrs["isNumberColumnEnabled"], false);
16813 assert_eq!(attrs["layout"], "default");
16814 }
16815
16816 #[test]
16817 fn directive_table_is_number_column_enabled_true_preserved() {
16818 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
16820 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
16821 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
16822 ]}]}]}]}"#;
16823 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16824 let md = adf_to_markdown(&doc).unwrap();
16825 assert!(md.contains("::::table"), "should use directive table form");
16826 assert!(
16827 md.contains("numbered}") || md.contains("numbered "),
16828 "should contain numbered flag, got: {md}"
16829 );
16830 let round_tripped = markdown_to_adf(&md).unwrap();
16831 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16832 assert_eq!(attrs["isNumberColumnEnabled"], true);
16833 }
16834
16835 #[test]
16836 fn trailing_space_in_bullet_list_item_preserved() {
16837 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16839 {"type":"listItem","content":[{"type":"paragraph","content":[
16840 {"type":"text","text":"Before link "},
16841 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
16842 {"type":"text","text":" "}
16843 ]}]}
16844 ]}]}"#;
16845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846 let md = adf_to_markdown(&doc).unwrap();
16847 let round_tripped = markdown_to_adf(&md).unwrap();
16848 let list = &round_tripped.content[0];
16849 let item = &list.content.as_ref().unwrap()[0];
16850 let para = &item.content.as_ref().unwrap()[0];
16851 let inlines = para.content.as_ref().unwrap();
16852 let last = inlines.last().unwrap();
16853 assert_eq!(
16854 last.text.as_deref(),
16855 Some(" "),
16856 "trailing space text node should be preserved, got nodes: {:?}",
16857 inlines
16858 .iter()
16859 .map(|n| (&n.node_type, &n.text))
16860 .collect::<Vec<_>>()
16861 );
16862 }
16863
16864 #[test]
16865 fn trailing_space_after_mention_in_bullet_list_preserved() {
16866 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16868 {"type":"listItem","content":[{"type":"paragraph","content":[
16869 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
16870 {"type":"text","text":" "}
16871 ]}]}
16872 ]}]}"#;
16873 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16874 let md = adf_to_markdown(&doc).unwrap();
16875 let round_tripped = markdown_to_adf(&md).unwrap();
16876 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
16877 .content
16878 .as_ref()
16879 .unwrap()[0];
16880 let inlines = para.content.as_ref().unwrap();
16881 assert!(
16882 inlines.len() >= 2,
16883 "should have mention + trailing space, got {} nodes",
16884 inlines.len()
16885 );
16886 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
16887 }
16888
16889 #[test]
16890 fn trailing_space_in_ordered_list_item_preserved() {
16891 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16893 {"type":"listItem","content":[{"type":"paragraph","content":[
16894 {"type":"text","text":"item "},
16895 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
16896 {"type":"text","text":" "}
16897 ]}]}
16898 ]}]}"#;
16899 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16900 let md = adf_to_markdown(&doc).unwrap();
16901 let round_tripped = markdown_to_adf(&md).unwrap();
16902 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
16903 .content
16904 .as_ref()
16905 .unwrap()[0];
16906 let inlines = para.content.as_ref().unwrap();
16907 let last = inlines.last().unwrap();
16908 assert_eq!(
16909 last.text.as_deref(),
16910 Some(" "),
16911 "trailing space should be preserved in ordered list item"
16912 );
16913 }
16914
16915 #[test]
16916 fn trailing_space_in_heading_text_preserved() {
16917 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
16919 {"type":"text","text":"Firefighting Engineers "}
16920 ]}]}"#;
16921 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16922 let md = adf_to_markdown(&doc).unwrap();
16923 let round_tripped = markdown_to_adf(&md).unwrap();
16924 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16925 assert_eq!(
16926 inlines[0].text.as_deref(),
16927 Some("Firefighting Engineers "),
16928 "trailing space in heading should be preserved"
16929 );
16930 }
16931
16932 #[test]
16933 fn trailing_space_in_heading_before_bold_preserved() {
16934 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
16936 {"type":"text","text":"Classic "},
16937 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
16938 ]}]}"#;
16939 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16940 let md = adf_to_markdown(&doc).unwrap();
16941 let round_tripped = markdown_to_adf(&md).unwrap();
16942 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16943 assert_eq!(
16944 inlines[0].text.as_deref(),
16945 Some("Classic "),
16946 "trailing space in heading text before bold should be preserved"
16947 );
16948 }
16949
16950 #[test]
16951 fn leading_space_in_heading_text_preserved() {
16952 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
16954 {"type":"text","text":" #general-channel"}
16955 ]}]}"#;
16956 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16957 let md = adf_to_markdown(&doc).unwrap();
16958 let round_tripped = markdown_to_adf(&md).unwrap();
16959 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16960 assert_eq!(
16961 inlines[0].text.as_deref(),
16962 Some(" #general-channel"),
16963 "leading spaces in heading text should be preserved"
16964 );
16965 }
16966
16967 #[test]
16968 fn leading_space_in_heading_before_bold_preserved() {
16969 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
16971 {"type":"text","text":" indented"},
16972 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
16973 ]}]}"#;
16974 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16975 let md = adf_to_markdown(&doc).unwrap();
16976 let round_tripped = markdown_to_adf(&md).unwrap();
16977 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16978 assert_eq!(
16979 inlines[0].text.as_deref(),
16980 Some(" indented"),
16981 "leading spaces in heading text before bold should be preserved"
16982 );
16983 }
16984
16985 #[test]
16986 fn heading_multiple_leading_spaces_markdown_parse() {
16987 let md = "### \t #general-channel";
16989 let doc = markdown_to_adf(md).unwrap();
16990 let inlines = doc.content[0].content.as_ref().unwrap();
16991 assert_eq!(
16992 inlines[0].text.as_deref(),
16993 Some("\t #general-channel"),
16994 "leading whitespace in heading text should be preserved during JFM parsing"
16995 );
16996 }
16997
16998 #[test]
16999 fn trailing_space_in_paragraph_text_preserved() {
17000 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17002 {"type":"text","text":"word followed by space "},
17003 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
17004 ]}]}"#;
17005 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17006 let md = adf_to_markdown(&doc).unwrap();
17007 let round_tripped = markdown_to_adf(&md).unwrap();
17008 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17009 assert_eq!(
17010 inlines[0].text.as_deref(),
17011 Some("word followed by space "),
17012 "trailing space in paragraph text should be preserved"
17013 );
17014 }
17015
17016 #[test]
17017 fn nested_bullet_list_roundtrip() {
17018 let adf_doc = serde_json::json!({
17020 "type": "doc",
17021 "version": 1,
17022 "content": [{
17023 "type": "bulletList",
17024 "content": [{
17025 "type": "listItem",
17026 "content": [
17027 {
17028 "type": "paragraph",
17029 "content": [{"type": "text", "text": "parent item"}]
17030 },
17031 {
17032 "type": "bulletList",
17033 "content": [
17034 {
17035 "type": "listItem",
17036 "content": [{
17037 "type": "paragraph",
17038 "content": [{"type": "text", "text": "sub item 1"}]
17039 }]
17040 },
17041 {
17042 "type": "listItem",
17043 "content": [{
17044 "type": "paragraph",
17045 "content": [{"type": "text", "text": "sub item 2"}]
17046 }]
17047 }
17048 ]
17049 }
17050 ]
17051 }]
17052 }]
17053 });
17054 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
17055 let md = adf_to_markdown(&doc).unwrap();
17056 assert!(
17057 md.contains("- parent item\n"),
17058 "expected top-level item in markdown, got: {md}"
17059 );
17060 assert!(
17061 md.contains(" - sub item 1\n"),
17062 "expected indented sub item 1 in markdown, got: {md}"
17063 );
17064 assert!(
17065 md.contains(" - sub item 2\n"),
17066 "expected indented sub item 2 in markdown, got: {md}"
17067 );
17068
17069 let doc2 = markdown_to_adf(&md).unwrap();
17071 let list = &doc2.content[0];
17072 assert_eq!(list.node_type, "bulletList");
17073 let item = &list.content.as_ref().unwrap()[0];
17074 assert_eq!(item.node_type, "listItem");
17075 let item_content = item.content.as_ref().unwrap();
17076 assert_eq!(
17077 item_content.len(),
17078 2,
17079 "listItem should have paragraph + nested list"
17080 );
17081 assert_eq!(item_content[0].node_type, "paragraph");
17082 assert_eq!(item_content[1].node_type, "bulletList");
17083 let sub_items = item_content[1].content.as_ref().unwrap();
17084 assert_eq!(sub_items.len(), 2);
17085 }
17086
17087 #[test]
17088 fn nested_bullet_in_table_cell_roundtrip() {
17089 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
17090 let doc = markdown_to_adf(md).unwrap();
17091 let table = &doc.content[0];
17092 let row = &table.content.as_ref().unwrap()[0];
17093 let cell = &row.content.as_ref().unwrap()[0];
17094 let list = &cell.content.as_ref().unwrap()[0];
17095 assert_eq!(list.node_type, "bulletList");
17096 let item = &list.content.as_ref().unwrap()[0];
17097 let item_content = item.content.as_ref().unwrap();
17098 assert_eq!(
17099 item_content.len(),
17100 2,
17101 "listItem should have paragraph + nested list"
17102 );
17103 assert_eq!(item_content[1].node_type, "bulletList");
17104
17105 let md2 = adf_to_markdown(&doc).unwrap();
17107 assert!(
17108 md2.contains(" - child"),
17109 "expected indented child in round-tripped markdown, got: {md2}"
17110 );
17111 }
17112
17113 #[test]
17114 fn nested_ordered_list_roundtrip() {
17115 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
17117 {"type":"listItem","content":[
17118 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
17119 {"type":"orderedList","attrs":{"order":1},"content":[
17120 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
17121 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
17122 ]}
17123 ]},
17124 {"type":"listItem","content":[
17125 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
17126 ]}
17127 ]}]}"#;
17128 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17129 let md = adf_to_markdown(&doc).unwrap();
17130 let round_tripped = markdown_to_adf(&md).unwrap();
17131
17132 let outer = &round_tripped.content[0];
17134 assert_eq!(outer.node_type, "orderedList");
17135 assert_eq!(
17136 outer.attrs.as_ref().unwrap()["order"],
17137 1,
17138 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
17139 );
17140 let outer_items = outer.content.as_ref().unwrap();
17141 assert_eq!(
17142 outer_items.len(),
17143 2,
17144 "outer list should have 2 items, got {}",
17145 outer_items.len()
17146 );
17147
17148 let first_item = &outer_items[0];
17150 let first_content = first_item.content.as_ref().unwrap();
17151 assert_eq!(
17152 first_content.len(),
17153 2,
17154 "first listItem should have paragraph + nested list, got {}",
17155 first_content.len()
17156 );
17157 assert_eq!(first_content[0].node_type, "paragraph");
17158 assert_eq!(first_content[1].node_type, "orderedList");
17159 let nested_items = first_content[1].content.as_ref().unwrap();
17160 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
17161 }
17162
17163 #[test]
17164 fn nested_ordered_list_markdown_parsing() {
17165 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
17167 let doc = markdown_to_adf(md).unwrap();
17168 let outer = &doc.content[0];
17169 assert_eq!(outer.node_type, "orderedList");
17170 let outer_items = outer.content.as_ref().unwrap();
17171 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
17172
17173 let first_content = outer_items[0].content.as_ref().unwrap();
17174 assert_eq!(
17175 first_content.len(),
17176 2,
17177 "first item should have paragraph + nested list"
17178 );
17179 assert_eq!(first_content[1].node_type, "orderedList");
17180 }
17181
17182 #[test]
17183 fn bullet_list_nested_inside_ordered_list() {
17184 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
17186 let doc = markdown_to_adf(md).unwrap();
17187 let outer = &doc.content[0];
17188 assert_eq!(outer.node_type, "orderedList");
17189 let outer_items = outer.content.as_ref().unwrap();
17190 assert_eq!(outer_items.len(), 2);
17191
17192 let first_content = outer_items[0].content.as_ref().unwrap();
17193 assert_eq!(
17194 first_content.len(),
17195 2,
17196 "first item should have paragraph + nested list"
17197 );
17198 assert_eq!(first_content[1].node_type, "bulletList");
17199 let sub_items = first_content[1].content.as_ref().unwrap();
17200 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
17201 }
17202
17203 #[test]
17204 fn ordered_list_order_attr_one_is_elided() {
17205 let md = "1. A\n2. B\n";
17209 let doc = markdown_to_adf(md).unwrap();
17210 assert!(
17211 doc.content[0].attrs.is_none(),
17212 "attrs should be elided when order=1"
17213 );
17214
17215 let md2 = adf_to_markdown(&doc).unwrap();
17217 let doc2 = markdown_to_adf(&md2).unwrap();
17218 assert!(
17219 doc2.content[0].attrs.is_none(),
17220 "attrs should remain elided after round-trip"
17221 );
17222 }
17223
17224 #[test]
17225 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
17226 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Second item"}]}]}]}]}"#;
17229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17230 let md = adf_to_markdown(&doc).unwrap();
17231 let rt = markdown_to_adf(&md).unwrap();
17232 assert!(
17233 rt.content[0].attrs.is_none(),
17234 "round-tripped orderedList should not have attrs, got: {:?}",
17235 rt.content[0].attrs
17236 );
17237
17238 let rt_json = serde_json::to_string(&rt).unwrap();
17240 assert!(
17241 !rt_json.contains("\"order\""),
17242 "round-tripped JSON should not contain \"order\", got: {rt_json}"
17243 );
17244 }
17245
17246 fn assert_roundtrip_byte_identical(adf_json: &str) {
17252 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17253 let md = adf_to_markdown(&doc).unwrap();
17254 let rt = markdown_to_adf(&md).unwrap();
17255
17256 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17257 let canonical_rt: serde_json::Value =
17258 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
17259 assert_eq!(
17260 canonical_src, canonical_rt,
17261 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
17262 );
17263 }
17264
17265 #[test]
17266 fn issue_547_single_item_no_attrs_roundtrip() {
17267 assert_roundtrip_byte_identical(
17268 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
17269 );
17270 }
17271
17272 #[test]
17273 fn issue_547_many_items_no_attrs_roundtrip() {
17274 assert_roundtrip_byte_identical(
17275 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"A"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"B"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"C"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"D"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"E"}]}]}]}]}"#,
17276 );
17277 }
17278
17279 #[test]
17280 fn issue_547_non_default_order_preserved() {
17281 assert_roundtrip_byte_identical(
17284 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
17285 );
17286 }
17287
17288 #[test]
17289 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
17290 assert_roundtrip_byte_identical(
17292 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
17293 );
17294 }
17295
17296 #[test]
17297 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
17298 assert_roundtrip_byte_identical(
17299 r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bullet"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
17300 );
17301 }
17302
17303 #[test]
17304 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
17305 assert_roundtrip_byte_identical(
17306 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
17307 );
17308 }
17309
17310 #[test]
17311 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
17312 assert_roundtrip_byte_identical(
17313 r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]},{"type":"paragraph","content":[{"type":"text","text":"outro"}]}]}"#,
17314 );
17315 }
17316
17317 #[test]
17318 fn issue_547_ordered_list_with_marked_text_roundtrip() {
17319 assert_roundtrip_byte_identical(
17320 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
17321 );
17322 }
17323
17324 #[test]
17325 fn issue_547_ordered_list_with_link_roundtrip() {
17326 assert_roundtrip_byte_identical(
17327 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"site","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}]}]}]}"#,
17328 );
17329 }
17330
17331 #[test]
17332 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
17333 assert_roundtrip_byte_identical(
17334 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"},{"type":"hardBreak"},{"type":"text","text":"b"}]}]}]}]}"#,
17335 );
17336 }
17337
17338 #[test]
17339 fn issue_547_triple_nested_ordered_roundtrip() {
17340 assert_roundtrip_byte_identical(
17341 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L1"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L2"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L3"}]}]}]}]}]}]}]}]}"#,
17342 );
17343 }
17344
17345 #[test]
17346 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
17347 assert_roundtrip_byte_identical(
17348 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]},{"type":"rule"}]}"#,
17349 );
17350 }
17351
17352 #[test]
17353 fn issue_547_ordered_list_listitem_localid_roundtrip() {
17354 assert_roundtrip_byte_identical(
17356 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
17357 );
17358 }
17359
17360 #[test]
17361 fn issue_547_explicit_order_one_preserved_roundtrip() {
17362 assert_roundtrip_byte_identical(
17367 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
17368 );
17369 }
17370
17371 #[test]
17372 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
17373 assert_roundtrip_byte_identical(
17376 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
17377 );
17378 }
17379
17380 #[test]
17381 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
17382 assert_roundtrip_byte_identical(
17385 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}]}"#,
17386 );
17387 }
17388
17389 #[test]
17390 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
17391 assert_roundtrip_byte_identical(
17395 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"li-1"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
17396 );
17397 }
17398
17399 #[test]
17400 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
17401 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17405 let explicit_one = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17406 let order_five = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17407
17408 let md_no =
17409 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
17410 let md_one =
17411 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
17412 let md_five =
17413 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
17414
17415 assert!(
17416 !md_no.contains("{order="),
17417 "no-attrs source must not emit order signal, got: {md_no:?}"
17418 );
17419 assert!(
17420 md_one.contains("{order=1}"),
17421 "explicit order=1 must emit trailing signal, got: {md_one:?}"
17422 );
17423 assert!(
17424 !md_five.contains("{order="),
17425 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
17426 );
17427 }
17428
17429 #[test]
17432 fn file_media_roundtrip() {
17433 let adf_doc = serde_json::json!({
17435 "type": "doc",
17436 "version": 1,
17437 "content": [{
17438 "type": "mediaSingle",
17439 "attrs": {"layout": "center"},
17440 "content": [{
17441 "type": "media",
17442 "attrs": {
17443 "type": "file",
17444 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
17445 "collection": "contentId-8220672100",
17446 "height": 56,
17447 "width": 312,
17448 "alt": "Screenshot.png"
17449 }
17450 }]
17451 }]
17452 });
17453 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17454 let md = adf_to_markdown(&doc).unwrap();
17455 assert!(
17456 md.contains("type=file"),
17457 "expected type=file in markdown, got: {md}"
17458 );
17459 assert!(
17460 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
17461 "expected id in markdown, got: {md}"
17462 );
17463 assert!(
17464 md.contains("collection=contentId-8220672100"),
17465 "expected collection in markdown, got: {md}"
17466 );
17467 let doc2 = markdown_to_adf(&md).unwrap();
17469 let ms = &doc2.content[0];
17470 assert_eq!(ms.node_type, "mediaSingle");
17471 let media = &ms.content.as_ref().unwrap()[0];
17472 assert_eq!(media.node_type, "media");
17473 let attrs = media.attrs.as_ref().unwrap();
17474 assert_eq!(attrs["type"], "file");
17475 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
17476 assert_eq!(attrs["collection"], "contentId-8220672100");
17477 assert_eq!(attrs["height"], 56);
17478 assert_eq!(attrs["width"], 312);
17479 assert_eq!(attrs["alt"], "Screenshot.png");
17480 }
17481
17482 #[test]
17486 fn file_media_roundtrip_issue_550_reproducer() {
17487 let adf_json = r#"{
17488 "version": 1,
17489 "type": "doc",
17490 "content": [
17491 {
17492 "type": "mediaSingle",
17493 "attrs": {"layout": "center"},
17494 "content": [
17495 {
17496 "type": "media",
17497 "attrs": {
17498 "type": "file",
17499 "id": "abc-123-def-456",
17500 "collection": "my-collection",
17501 "width": 941,
17502 "height": 655
17503 }
17504 }
17505 ]
17506 }
17507 ]
17508 }"#;
17509 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17510 let md = adf_to_markdown(&doc).unwrap();
17511 let rt = markdown_to_adf(&md).unwrap();
17512 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17513 let actual = serde_json::to_value(&rt).unwrap();
17514 assert_eq!(
17515 actual, expected,
17516 "roundtrip should preserve file media attrs; md was:\n{md}"
17517 );
17518 }
17519
17520 #[test]
17526 fn file_media_roundtrip_id_with_spaces() {
17527 let adf_json = r#"{
17528 "version": 1,
17529 "type": "doc",
17530 "content": [
17531 {
17532 "type": "mediaSingle",
17533 "attrs": {"layout": "center"},
17534 "content": [
17535 {
17536 "type": "media",
17537 "attrs": {
17538 "type": "file",
17539 "id": "abc 123 def 456",
17540 "collection": "my-collection",
17541 "width": 800,
17542 "height": 600
17543 }
17544 }
17545 ]
17546 }
17547 ]
17548 }"#;
17549 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17550 let md = adf_to_markdown(&doc).unwrap();
17551 assert!(
17552 md.contains(r#"id="abc 123 def 456""#),
17553 "id with spaces should be quoted in JFM, got:\n{md}"
17554 );
17555 let rt = markdown_to_adf(&md).unwrap();
17556 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17557 let actual = serde_json::to_value(&rt).unwrap();
17558 assert_eq!(
17559 actual, expected,
17560 "space-containing id must round-trip; md was:\n{md}"
17561 );
17562 }
17563
17564 #[test]
17566 fn file_media_roundtrip_collection_with_spaces() {
17567 let adf_json = r#"{
17568 "version": 1,
17569 "type": "doc",
17570 "content": [
17571 {
17572 "type": "mediaSingle",
17573 "attrs": {"layout": "center"},
17574 "content": [
17575 {
17576 "type": "media",
17577 "attrs": {
17578 "type": "file",
17579 "id": "abc-123",
17580 "collection": "my collection with spaces"
17581 }
17582 }
17583 ]
17584 }
17585 ]
17586 }"#;
17587 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17588 let md = adf_to_markdown(&doc).unwrap();
17589 let rt = markdown_to_adf(&md).unwrap();
17590 let media = &rt.content[0].content.as_ref().unwrap()[0];
17591 assert_eq!(
17592 media.attrs.as_ref().unwrap()["collection"],
17593 "my collection with spaces"
17594 );
17595 }
17596
17597 #[test]
17599 fn file_media_roundtrip_occurrence_key_with_spaces() {
17600 let adf_json = r#"{
17601 "version": 1,
17602 "type": "doc",
17603 "content": [
17604 {
17605 "type": "mediaSingle",
17606 "attrs": {"layout": "center"},
17607 "content": [
17608 {
17609 "type": "media",
17610 "attrs": {
17611 "type": "file",
17612 "id": "x",
17613 "collection": "y",
17614 "occurrenceKey": "key with spaces"
17615 }
17616 }
17617 ]
17618 }
17619 ]
17620 }"#;
17621 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17622 let md = adf_to_markdown(&doc).unwrap();
17623 let rt = markdown_to_adf(&md).unwrap();
17624 let media = &rt.content[0].content.as_ref().unwrap()[0];
17625 assert_eq!(
17626 media.attrs.as_ref().unwrap()["occurrenceKey"],
17627 "key with spaces"
17628 );
17629 }
17630
17631 #[test]
17633 fn file_media_roundtrip_id_with_quote_char() {
17634 let adf_json = r#"{
17635 "version": 1,
17636 "type": "doc",
17637 "content": [
17638 {
17639 "type": "mediaSingle",
17640 "attrs": {"layout": "center"},
17641 "content": [
17642 {
17643 "type": "media",
17644 "attrs": {
17645 "type": "file",
17646 "id": "a\"b\"c",
17647 "collection": "col"
17648 }
17649 }
17650 ]
17651 }
17652 ]
17653 }"#;
17654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17655 let md = adf_to_markdown(&doc).unwrap();
17656 let rt = markdown_to_adf(&md).unwrap();
17657 let media = &rt.content[0].content.as_ref().unwrap()[0];
17658 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
17659 }
17660
17661 #[test]
17664 fn media_inline_roundtrip_id_with_spaces() {
17665 let adf_json = r#"{
17666 "version": 1,
17667 "type": "doc",
17668 "content": [
17669 {
17670 "type": "paragraph",
17671 "content": [
17672 {"type": "text", "text": "before "},
17673 {
17674 "type": "mediaInline",
17675 "attrs": {
17676 "type": "file",
17677 "id": "a b c",
17678 "collection": "my col"
17679 }
17680 },
17681 {"type": "text", "text": " after"}
17682 ]
17683 }
17684 ]
17685 }"#;
17686 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17687 let md = adf_to_markdown(&doc).unwrap();
17688 let rt = markdown_to_adf(&md).unwrap();
17689 let inline = &rt.content[0].content.as_ref().unwrap()[1];
17690 assert_eq!(inline.node_type, "mediaInline");
17691 let attrs = inline.attrs.as_ref().unwrap();
17692 assert_eq!(attrs["id"], "a b c");
17693 assert_eq!(attrs["collection"], "my col");
17694 }
17695
17696 #[test]
17699 fn file_media_roundtrip_preserves_occurrence_key() {
17700 let adf_json = r#"{
17701 "version": 1,
17702 "type": "doc",
17703 "content": [
17704 {
17705 "type": "mediaSingle",
17706 "attrs": {"layout": "center"},
17707 "content": [
17708 {
17709 "type": "media",
17710 "attrs": {
17711 "type": "file",
17712 "id": "abc-123",
17713 "collection": "my-collection",
17714 "occurrenceKey": "unique-key-xyz",
17715 "width": 200,
17716 "height": 100
17717 }
17718 }
17719 ]
17720 }
17721 ]
17722 }"#;
17723 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17724 let md = adf_to_markdown(&doc).unwrap();
17725 assert!(
17726 md.contains("occurrenceKey=unique-key-xyz"),
17727 "expected occurrenceKey in markdown, got: {md}"
17728 );
17729 let rt = markdown_to_adf(&md).unwrap();
17730 let media = &rt.content[0].content.as_ref().unwrap()[0];
17731 let attrs = media.attrs.as_ref().unwrap();
17732 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
17733 assert_eq!(attrs["type"], "file");
17734 assert_eq!(attrs["id"], "abc-123");
17735 assert_eq!(attrs["collection"], "my-collection");
17736 }
17737
17738 #[test]
17741 fn media_single_caption_adf_to_markdown() {
17742 let adf_doc = serde_json::json!({
17743 "type": "doc",
17744 "version": 1,
17745 "content": [{
17746 "type": "mediaSingle",
17747 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
17748 "content": [
17749 {
17750 "type": "media",
17751 "attrs": {
17752 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
17753 "type": "file",
17754 "collection": "contentId-123456",
17755 "width": 800,
17756 "height": 600
17757 }
17758 },
17759 {
17760 "type": "caption",
17761 "content": [{"type": "text", "text": "An image caption here"}]
17762 }
17763 ]
17764 }]
17765 });
17766 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17767 let md = adf_to_markdown(&doc).unwrap();
17768 assert!(
17769 md.contains(":::caption"),
17770 "expected :::caption in markdown, got: {md}"
17771 );
17772 assert!(
17773 md.contains("An image caption here"),
17774 "expected caption text in markdown, got: {md}"
17775 );
17776 }
17777
17778 #[test]
17779 fn media_single_caption_markdown_to_adf() {
17780 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
17781 let doc = markdown_to_adf(md).unwrap();
17782 let ms = &doc.content[0];
17783 assert_eq!(ms.node_type, "mediaSingle");
17784 let content = ms.content.as_ref().unwrap();
17785 assert_eq!(content.len(), 2, "expected media + caption children");
17786 assert_eq!(content[0].node_type, "media");
17787 assert_eq!(content[1].node_type, "caption");
17788 let caption_content = content[1].content.as_ref().unwrap();
17789 assert_eq!(
17790 caption_content[0].text.as_deref(),
17791 Some("An image caption here")
17792 );
17793 }
17794
17795 #[test]
17796 fn media_single_caption_round_trip() {
17797 let adf_doc = serde_json::json!({
17799 "type": "doc",
17800 "version": 1,
17801 "content": [{
17802 "type": "mediaSingle",
17803 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
17804 "content": [
17805 {
17806 "type": "media",
17807 "attrs": {
17808 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
17809 "type": "file",
17810 "collection": "contentId-123456",
17811 "width": 800,
17812 "height": 600
17813 }
17814 },
17815 {
17816 "type": "caption",
17817 "content": [{"type": "text", "text": "An image caption here"}]
17818 }
17819 ]
17820 }]
17821 });
17822 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17823 let md = adf_to_markdown(&doc).unwrap();
17824 let doc2 = markdown_to_adf(&md).unwrap();
17825 let ms = &doc2.content[0];
17826 assert_eq!(ms.node_type, "mediaSingle");
17827 let content = ms.content.as_ref().unwrap();
17828 assert_eq!(
17829 content.len(),
17830 2,
17831 "expected media + caption after round-trip"
17832 );
17833 assert_eq!(content[1].node_type, "caption");
17834 let caption_content = content[1].content.as_ref().unwrap();
17835 assert_eq!(
17836 caption_content[0].text.as_deref(),
17837 Some("An image caption here")
17838 );
17839 }
17840
17841 #[test]
17842 fn media_single_caption_with_inline_marks() {
17843 let adf_doc = serde_json::json!({
17844 "type": "doc",
17845 "version": 1,
17846 "content": [{
17847 "type": "mediaSingle",
17848 "attrs": {"layout": "center"},
17849 "content": [
17850 {
17851 "type": "media",
17852 "attrs": {"type": "external", "url": "https://example.com/img.png"}
17853 },
17854 {
17855 "type": "caption",
17856 "content": [
17857 {"type": "text", "text": "A "},
17858 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
17859 {"type": "text", "text": " caption"}
17860 ]
17861 }
17862 ]
17863 }]
17864 });
17865 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17866 let md = adf_to_markdown(&doc).unwrap();
17867 assert!(
17868 md.contains("**bold**"),
17869 "expected bold in caption, got: {md}"
17870 );
17871
17872 let doc2 = markdown_to_adf(&md).unwrap();
17873 let content = doc2.content[0].content.as_ref().unwrap();
17874 assert_eq!(content.len(), 2, "expected media + caption");
17875 assert_eq!(content[1].node_type, "caption");
17876 let caption_inlines = content[1].content.as_ref().unwrap();
17877 let bold_node = caption_inlines
17878 .iter()
17879 .find(|n| n.text.as_deref() == Some("bold"))
17880 .unwrap();
17881 let marks = bold_node.marks.as_ref().unwrap();
17882 assert_eq!(marks[0].mark_type, "strong");
17883 }
17884
17885 #[test]
17886 fn media_single_no_caption_unaffected() {
17887 let adf_doc = serde_json::json!({
17889 "type": "doc",
17890 "version": 1,
17891 "content": [{
17892 "type": "mediaSingle",
17893 "attrs": {"layout": "center"},
17894 "content": [{
17895 "type": "media",
17896 "attrs": {"type": "external", "url": "https://example.com/img.png"}
17897 }]
17898 }]
17899 });
17900 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17901 let md = adf_to_markdown(&doc).unwrap();
17902 assert!(
17903 !md.contains(":::caption"),
17904 "should not emit caption when none present"
17905 );
17906 let doc2 = markdown_to_adf(&md).unwrap();
17907 let content = doc2.content[0].content.as_ref().unwrap();
17908 assert_eq!(content.len(), 1, "should only have media child");
17909 assert_eq!(content[0].node_type, "media");
17910 }
17911
17912 #[test]
17913 fn media_single_empty_caption_round_trip() {
17914 let adf_doc = serde_json::json!({
17916 "type": "doc",
17917 "version": 1,
17918 "content": [{
17919 "type": "mediaSingle",
17920 "attrs": {"layout": "center"},
17921 "content": [
17922 {
17923 "type": "media",
17924 "attrs": {"type": "external", "url": "https://example.com/img.png"}
17925 },
17926 {
17927 "type": "caption"
17928 }
17929 ]
17930 }]
17931 });
17932 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17933 let md = adf_to_markdown(&doc).unwrap();
17934 assert!(
17935 md.contains(":::caption"),
17936 "expected :::caption even for empty caption, got: {md}"
17937 );
17938 assert!(
17939 md.contains(":::\n"),
17940 "expected closing ::: fence, got: {md}"
17941 );
17942 }
17943
17944 #[test]
17945 fn media_single_external_caption_round_trip() {
17946 let md = "\n:::caption\nImage description\n:::\n";
17948 let doc = markdown_to_adf(md).unwrap();
17949 let ms = &doc.content[0];
17950 assert_eq!(ms.node_type, "mediaSingle");
17951 let content = ms.content.as_ref().unwrap();
17952 assert_eq!(content.len(), 2);
17953 assert_eq!(content[0].node_type, "media");
17954 assert_eq!(content[1].node_type, "caption");
17955
17956 let md2 = adf_to_markdown(&doc).unwrap();
17957 let doc2 = markdown_to_adf(&md2).unwrap();
17958 let content2 = doc2.content[0].content.as_ref().unwrap();
17959 assert_eq!(content2.len(), 2);
17960 assert_eq!(content2[1].node_type, "caption");
17961 let caption_text = content2[1].content.as_ref().unwrap();
17962 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
17963 }
17964
17965 #[test]
17968 fn media_single_caption_localid_roundtrip() {
17969 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"a caption with hex localId"}]}]}]}"#;
17970 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17971 let md = adf_to_markdown(&doc).unwrap();
17972 assert!(
17973 md.contains("localId=9da8c2104471"),
17974 "caption localId should appear in markdown: {md}"
17975 );
17976 let rt = markdown_to_adf(&md).unwrap();
17977 let content = rt.content[0].content.as_ref().unwrap();
17978 let caption = &content[1];
17979 assert_eq!(caption.node_type, "caption");
17980 assert_eq!(
17981 caption.attrs.as_ref().unwrap()["localId"],
17982 "9da8c2104471",
17983 "caption localId should round-trip"
17984 );
17985 }
17986
17987 #[test]
17988 fn media_single_caption_without_localid() {
17989 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
17990 let doc = markdown_to_adf(md).unwrap();
17991 let caption = &doc.content[0].content.as_ref().unwrap()[1];
17992 assert_eq!(caption.node_type, "caption");
17993 assert!(
17994 caption.attrs.is_none(),
17995 "caption without localId should not gain attrs"
17996 );
17997 let md2 = adf_to_markdown(&doc).unwrap();
17998 assert!(
17999 !md2.contains("localId"),
18000 "no localId should appear in output: {md2}"
18001 );
18002 }
18003
18004 #[test]
18005 fn media_single_caption_localid_stripped_when_option_set() {
18006 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"stripped caption"}]}]}]}"#;
18007 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18008 let opts = RenderOptions {
18009 strip_local_ids: true,
18010 ..Default::default()
18011 };
18012 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18013 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18014 }
18015
18016 #[test]
18017 fn table_width_roundtrip() {
18018 let adf_doc = serde_json::json!({
18020 "type": "doc",
18021 "version": 1,
18022 "content": [{
18023 "type": "table",
18024 "attrs": {"layout": "default", "width": 760.0},
18025 "content": [{
18026 "type": "tableRow",
18027 "content": [{
18028 "type": "tableHeader",
18029 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
18030 }]
18031 }]
18032 }]
18033 });
18034 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18035 let md = adf_to_markdown(&doc).unwrap();
18036 assert!(
18037 md.contains("width=760.0"),
18038 "expected width=760.0 in markdown (float preserved), got: {md}"
18039 );
18040 let doc2 = markdown_to_adf(&md).unwrap();
18042 let table = &doc2.content[0];
18043 assert_eq!(table.node_type, "table");
18044 let table_attrs = table.attrs.as_ref().unwrap();
18045 assert_eq!(table_attrs["width"], 760.0);
18046 assert!(
18047 table_attrs["width"].is_f64(),
18048 "expected float width to be preserved as f64, got: {:?}",
18049 table_attrs["width"]
18050 );
18051 }
18052
18053 #[test]
18054 fn table_integer_width_roundtrip_preserves_integer() {
18055 let adf_doc = serde_json::json!({
18058 "type": "doc",
18059 "version": 1,
18060 "content": [{
18061 "type": "table",
18062 "attrs": {
18063 "isNumberColumnEnabled": false,
18064 "layout": "center",
18065 "localId": "abc-123",
18066 "width": 1420
18067 },
18068 "content": [{
18069 "type": "tableRow",
18070 "content": [{
18071 "type": "tableCell",
18072 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
18073 }]
18074 }]
18075 }]
18076 });
18077 let doc: crate::atlassian::adf::AdfDocument =
18078 serde_json::from_value(adf_doc.clone()).unwrap();
18079 let md = adf_to_markdown(&doc).unwrap();
18080 assert!(
18081 md.contains("width=1420"),
18082 "expected width=1420 in markdown, got: {md}"
18083 );
18084 assert!(
18085 !md.contains("width=1420.0"),
18086 "integer width should not be rendered with decimal: {md}"
18087 );
18088
18089 let doc2 = markdown_to_adf(&md).unwrap();
18090 let table = &doc2.content[0];
18091 assert_eq!(table.node_type, "table");
18092 let table_attrs = table.attrs.as_ref().unwrap();
18093 assert_eq!(table_attrs["width"], 1420);
18094 assert!(
18095 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
18096 "width should remain an integer, got: {:?}",
18097 table_attrs["width"]
18098 );
18099 assert!(
18100 !table_attrs["width"].is_f64(),
18101 "width should not be a float, got: {:?}",
18102 table_attrs["width"]
18103 );
18104
18105 let roundtripped = serde_json::to_value(&doc2).unwrap();
18107 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
18108 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
18109 assert_eq!(
18110 orig_width, rt_width,
18111 "width value must roundtrip byte-for-byte"
18112 );
18113 }
18114
18115 #[test]
18116 fn table_fractional_width_roundtrip() {
18117 let adf_doc = serde_json::json!({
18119 "type": "doc",
18120 "version": 1,
18121 "content": [{
18122 "type": "table",
18123 "attrs": {"layout": "default", "width": 760.5},
18124 "content": [{
18125 "type": "tableRow",
18126 "content": [{
18127 "type": "tableHeader",
18128 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
18129 }]
18130 }]
18131 }]
18132 });
18133 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18134 let md = adf_to_markdown(&doc).unwrap();
18135 assert!(
18136 md.contains("width=760.5"),
18137 "expected width=760.5 in markdown, got: {md}"
18138 );
18139 let doc2 = markdown_to_adf(&md).unwrap();
18140 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
18141 assert_eq!(table_attrs["width"], 760.5);
18142 assert!(table_attrs["width"].is_f64());
18143 }
18144
18145 #[test]
18146 fn pipe_table_integer_width_roundtrip() {
18147 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
18149 let doc = markdown_to_adf(md).unwrap();
18150 let table = &doc.content[0];
18151 assert_eq!(table.node_type, "table");
18152 let attrs = table.attrs.as_ref().unwrap();
18153 assert_eq!(attrs["width"], 1420);
18154 assert!(
18155 attrs["width"].is_u64() || attrs["width"].is_i64(),
18156 "pipe-table width must stay integer, got: {:?}",
18157 attrs["width"]
18158 );
18159 }
18160
18161 #[test]
18162 fn file_media_width_type_roundtrip() {
18163 let adf_doc = serde_json::json!({
18165 "type": "doc",
18166 "version": 1,
18167 "content": [{
18168 "type": "mediaSingle",
18169 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
18170 "content": [{
18171 "type": "media",
18172 "attrs": {
18173 "type": "file",
18174 "id": "abc123",
18175 "collection": "contentId-999",
18176 "height": 56,
18177 "width": 312
18178 }
18179 }]
18180 }]
18181 });
18182 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18183 let md = adf_to_markdown(&doc).unwrap();
18184 assert!(
18185 md.contains("widthType=pixel"),
18186 "expected widthType=pixel in markdown, got: {md}"
18187 );
18188 let doc2 = markdown_to_adf(&md).unwrap();
18189 let ms = &doc2.content[0];
18190 let ms_attrs = ms.attrs.as_ref().unwrap();
18191 assert_eq!(ms_attrs["widthType"], "pixel");
18192 assert_eq!(ms_attrs["width"], 312);
18193 }
18194
18195 #[test]
18196 fn file_media_pixel_width_above_100_passes_validation() {
18197 let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
18206 let adf = markdown_to_adf(md).unwrap();
18207
18208 let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
18209 assert_eq!(ms_attrs["width"], 900);
18210 assert_eq!(ms_attrs["widthType"], "pixel");
18211
18212 crate::atlassian::adf_validated::validate(&adf).unwrap();
18213 }
18214
18215 #[test]
18216 fn file_media_mode_roundtrip() {
18217 let adf_doc = serde_json::json!({
18219 "type": "doc",
18220 "version": 1,
18221 "content": [{
18222 "type": "mediaSingle",
18223 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
18224 "content": [{
18225 "type": "media",
18226 "attrs": {
18227 "type": "file",
18228 "id": "abc123",
18229 "collection": "test",
18230 "width": 1200,
18231 "height": 600
18232 }
18233 }]
18234 }]
18235 });
18236 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18237 let md = adf_to_markdown(&doc).unwrap();
18238 assert!(
18239 md.contains("mode=wide"),
18240 "expected mode=wide in markdown, got: {md}"
18241 );
18242 let doc2 = markdown_to_adf(&md).unwrap();
18243 let ms = &doc2.content[0];
18244 let ms_attrs = ms.attrs.as_ref().unwrap();
18245 assert_eq!(ms_attrs["mode"], "wide");
18246 assert_eq!(ms_attrs["layout"], "wide");
18247 assert_eq!(ms_attrs["width"], 1200);
18248 }
18249
18250 #[test]
18251 fn external_media_mode_roundtrip() {
18252 let adf_doc = serde_json::json!({
18254 "type": "doc",
18255 "version": 1,
18256 "content": [{
18257 "type": "mediaSingle",
18258 "attrs": {"layout": "wide", "mode": "wide"},
18259 "content": [{
18260 "type": "media",
18261 "attrs": {
18262 "type": "external",
18263 "url": "https://example.com/image.png"
18264 }
18265 }]
18266 }]
18267 });
18268 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18269 let md = adf_to_markdown(&doc).unwrap();
18270 assert!(
18271 md.contains("mode=wide"),
18272 "expected mode=wide in markdown, got: {md}"
18273 );
18274 let doc2 = markdown_to_adf(&md).unwrap();
18275 let ms = &doc2.content[0];
18276 let ms_attrs = ms.attrs.as_ref().unwrap();
18277 assert_eq!(ms_attrs["mode"], "wide");
18278 assert_eq!(ms_attrs["layout"], "wide");
18279 }
18280
18281 #[test]
18282 fn media_mode_only_roundtrip() {
18283 let adf_doc = serde_json::json!({
18285 "type": "doc",
18286 "version": 1,
18287 "content": [{
18288 "type": "mediaSingle",
18289 "attrs": {"layout": "center", "mode": "default"},
18290 "content": [{
18291 "type": "media",
18292 "attrs": {
18293 "type": "external",
18294 "url": "https://example.com/image.png"
18295 }
18296 }]
18297 }]
18298 });
18299 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18300 let md = adf_to_markdown(&doc).unwrap();
18301 assert!(
18302 md.contains("mode=default"),
18303 "expected mode=default in markdown, got: {md}"
18304 );
18305 let doc2 = markdown_to_adf(&md).unwrap();
18306 let ms = &doc2.content[0];
18307 let ms_attrs = ms.attrs.as_ref().unwrap();
18308 assert_eq!(ms_attrs["mode"], "default");
18309 }
18310
18311 #[test]
18312 fn file_media_hex_localid_roundtrip() {
18313 let adf_doc = serde_json::json!({
18315 "type": "doc",
18316 "version": 1,
18317 "content": [{
18318 "type": "mediaSingle",
18319 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
18320 "content": [{
18321 "type": "media",
18322 "attrs": {
18323 "type": "file",
18324 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
18325 "collection": "contentId-123",
18326 "height": 484,
18327 "width": 915,
18328 "alt": "image.png",
18329 "localId": "0e79f58ac382"
18330 }
18331 }]
18332 }]
18333 });
18334 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18335 let md = adf_to_markdown(&doc).unwrap();
18336 assert!(
18337 md.contains("localId=0e79f58ac382"),
18338 "expected localId=0e79f58ac382 in markdown, got: {md}"
18339 );
18340 let doc2 = markdown_to_adf(&md).unwrap();
18341 let ms = &doc2.content[0];
18342 let media = &ms.content.as_ref().unwrap()[0];
18343 let attrs = media.attrs.as_ref().unwrap();
18344 assert_eq!(attrs["localId"], "0e79f58ac382");
18345 }
18346
18347 #[test]
18348 fn file_media_uuid_localid_roundtrip() {
18349 let adf_doc = serde_json::json!({
18351 "type": "doc",
18352 "version": 1,
18353 "content": [{
18354 "type": "mediaSingle",
18355 "attrs": {"layout": "center"},
18356 "content": [{
18357 "type": "media",
18358 "attrs": {
18359 "type": "file",
18360 "id": "abc-123",
18361 "collection": "contentId-456",
18362 "height": 100,
18363 "width": 200,
18364 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
18365 }
18366 }]
18367 }]
18368 });
18369 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18370 let md = adf_to_markdown(&doc).unwrap();
18371 assert!(
18372 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
18373 "expected UUID localId in markdown, got: {md}"
18374 );
18375 let doc2 = markdown_to_adf(&md).unwrap();
18376 let media = &doc2.content[0].content.as_ref().unwrap()[0];
18377 let attrs = media.attrs.as_ref().unwrap();
18378 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
18379 }
18380
18381 #[test]
18382 fn file_media_null_uuid_localid_stripped() {
18383 let adf_doc = serde_json::json!({
18385 "type": "doc",
18386 "version": 1,
18387 "content": [{
18388 "type": "mediaSingle",
18389 "attrs": {"layout": "center"},
18390 "content": [{
18391 "type": "media",
18392 "attrs": {
18393 "type": "file",
18394 "id": "abc-123",
18395 "collection": "contentId-456",
18396 "height": 100,
18397 "width": 200,
18398 "localId": "00000000-0000-0000-0000-000000000000"
18399 }
18400 }]
18401 }]
18402 });
18403 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18404 let md = adf_to_markdown(&doc).unwrap();
18405 assert!(
18406 !md.contains("localId="),
18407 "null UUID localId should be stripped, got: {md}"
18408 );
18409 }
18410
18411 #[test]
18412 fn file_media_localid_stripped_when_option_set() {
18413 let adf_doc = serde_json::json!({
18415 "type": "doc",
18416 "version": 1,
18417 "content": [{
18418 "type": "mediaSingle",
18419 "attrs": {"layout": "center"},
18420 "content": [{
18421 "type": "media",
18422 "attrs": {
18423 "type": "file",
18424 "id": "abc-123",
18425 "collection": "contentId-456",
18426 "height": 100,
18427 "width": 200,
18428 "localId": "0e79f58ac382"
18429 }
18430 }]
18431 }]
18432 });
18433 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18434 let opts = RenderOptions {
18435 strip_local_ids: true,
18436 ..Default::default()
18437 };
18438 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18439 assert!(
18440 !md.contains("localId="),
18441 "localId should be stripped with strip_local_ids, got: {md}"
18442 );
18443 }
18444
18445 #[test]
18446 fn external_media_localid_roundtrip() {
18447 let adf_doc = serde_json::json!({
18449 "type": "doc",
18450 "version": 1,
18451 "content": [{
18452 "type": "mediaSingle",
18453 "attrs": {"layout": "center"},
18454 "content": [{
18455 "type": "media",
18456 "attrs": {
18457 "type": "external",
18458 "url": "https://example.com/image.png",
18459 "alt": "test",
18460 "localId": "deadbeef1234"
18461 }
18462 }]
18463 }]
18464 });
18465 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18466 let md = adf_to_markdown(&doc).unwrap();
18467 assert!(
18468 md.contains("localId=deadbeef1234"),
18469 "expected localId in markdown for external media, got: {md}"
18470 );
18471 let doc2 = markdown_to_adf(&md).unwrap();
18472 let media = &doc2.content[0].content.as_ref().unwrap()[0];
18473 let attrs = media.attrs.as_ref().unwrap();
18474 assert_eq!(attrs["localId"], "deadbeef1234");
18475 }
18476
18477 #[test]
18478 fn bracket_in_text_not_parsed_as_link() {
18479 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
18481 let doc = markdown_to_adf(md).unwrap();
18482 let para = &doc.content[0];
18483 assert_eq!(para.node_type, "paragraph");
18484 let content = para.content.as_ref().unwrap();
18485 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
18487 let has_task_bracket = text_nodes
18488 .iter()
18489 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
18490 assert!(
18491 has_task_bracket,
18492 "expected [Task] in plain text, nodes: {content:?}"
18493 );
18494 let link_nodes: Vec<_> = content
18496 .iter()
18497 .filter(|n| {
18498 n.marks
18499 .as_ref()
18500 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
18501 })
18502 .collect();
18503 assert!(!link_nodes.is_empty(), "expected a link node");
18504 assert_eq!(
18505 link_nodes[0].text.as_deref(),
18506 Some("Link"),
18507 "link text should be 'Link'"
18508 );
18509 }
18510
18511 #[test]
18512 fn empty_paragraph_roundtrip() {
18513 let mut adf_in = AdfDocument::new();
18515 adf_in.content = vec![
18516 AdfNode::paragraph(vec![AdfNode::text("before")]),
18517 AdfNode::paragraph(vec![]),
18518 AdfNode::paragraph(vec![AdfNode::text("after")]),
18519 ];
18520 let md = adf_to_markdown(&adf_in).unwrap();
18521 let adf_out = markdown_to_adf(&md).unwrap();
18522 assert_eq!(
18523 adf_out.content.len(),
18524 3,
18525 "should have 3 blocks, markdown:\n{md}"
18526 );
18527 assert_eq!(adf_out.content[0].node_type, "paragraph");
18528 assert_eq!(adf_out.content[1].node_type, "paragraph");
18529 assert!(
18530 adf_out.content[1].content.is_none(),
18531 "middle paragraph should be empty"
18532 );
18533 assert_eq!(adf_out.content[2].node_type, "paragraph");
18534 }
18535
18536 #[test]
18537 fn nbsp_paragraph_roundtrip() {
18538 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
18540 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18541 let md = adf_to_markdown(&doc).unwrap();
18542 assert!(
18543 md.contains("::paragraph["),
18544 "NBSP paragraph should use directive form: {md}"
18545 );
18546 let rt = markdown_to_adf(&md).unwrap();
18547 assert_eq!(rt.content.len(), 1, "should have 1 block");
18548 assert_eq!(rt.content[0].node_type, "paragraph");
18549 let text = rt.content[0].content.as_ref().unwrap()[0]
18550 .text
18551 .as_deref()
18552 .unwrap_or("");
18553 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
18554 }
18555
18556 #[test]
18557 fn nbsp_in_nested_expand_roundtrip() {
18558 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
18560 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18561 let md = adf_to_markdown(&doc).unwrap();
18562 let rt = markdown_to_adf(&md).unwrap();
18563 let ne = &rt.content[0];
18564 assert_eq!(ne.node_type, "nestedExpand");
18565 let inner = ne.content.as_ref().unwrap();
18566 assert_eq!(inner.len(), 1, "should have 1 inner block");
18567 assert_eq!(inner[0].node_type, "paragraph");
18568 let content = inner[0].content.as_ref().unwrap();
18569 assert!(!content.is_empty(), "paragraph should not be empty");
18570 let text = content[0].text.as_deref().unwrap_or("");
18571 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
18572 }
18573
18574 #[test]
18575 fn nbsp_followed_by_content() {
18576 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"S\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]},{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}]}";
18578 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18579 let md = adf_to_markdown(&doc).unwrap();
18580 let rt = markdown_to_adf(&md).unwrap();
18581 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
18582 let after_para = rt.content.iter().find(|n| {
18584 n.node_type == "paragraph"
18585 && n.content
18586 .as_ref()
18587 .and_then(|c| c.first())
18588 .and_then(|n| n.text.as_deref())
18589 .is_some_and(|t| t.contains("after"))
18590 });
18591 assert!(after_para.is_some(), "should have paragraph with 'after'");
18592 }
18593
18594 #[test]
18595 fn nbsp_paragraph_with_marks_survives() {
18596 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
18599 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18600 let md = adf_to_markdown(&doc).unwrap();
18601 assert!(md.contains("**"), "should have bold markers: {md}");
18602 let rt = markdown_to_adf(&md).unwrap();
18603 let content = rt.content[0].content.as_ref().unwrap();
18604 assert!(!content.is_empty(), "should preserve content");
18605 }
18606
18607 #[test]
18608 fn regular_paragraph_unchanged() {
18609 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
18611 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18612 let md = adf_to_markdown(&doc).unwrap();
18613 assert!(
18614 !md.contains("::paragraph"),
18615 "regular paragraphs should not use directive form: {md}"
18616 );
18617 assert!(md.contains("hello"));
18618 }
18619
18620 #[test]
18621 fn paragraph_directive_with_content_parsed() {
18622 let md = "::paragraph[\u{00a0}]\n";
18624 let doc = markdown_to_adf(md).unwrap();
18625 assert_eq!(doc.content.len(), 1);
18626 assert_eq!(doc.content[0].node_type, "paragraph");
18627 let content = doc.content[0].content.as_ref().unwrap();
18628 assert!(!content.is_empty(), "should have inline content");
18629 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
18630 }
18631
18632 #[test]
18633 fn nbsp_paragraph_in_list_item_with_nested_list() {
18634 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item one"}]}]}]}]}]}]}"#;
18636 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18637 let md = adf_to_markdown(&doc).unwrap();
18638 let rt = markdown_to_adf(&md).unwrap();
18639 let list = &rt.content[0];
18640 assert_eq!(list.node_type, "bulletList");
18641 let item = &list.content.as_ref().unwrap()[0];
18642 let item_content = item.content.as_ref().unwrap();
18643 assert_eq!(
18644 item_content.len(),
18645 2,
18646 "listItem should have paragraph + nested list, got: {item_content:?}"
18647 );
18648 let para = &item_content[0];
18649 assert_eq!(para.node_type, "paragraph");
18650 let para_content = para
18651 .content
18652 .as_ref()
18653 .expect("paragraph should have content");
18654 assert!(
18655 !para_content.is_empty(),
18656 "NBSP paragraph content should not be empty"
18657 );
18658 assert_eq!(
18659 para_content[0].text.as_deref().unwrap(),
18660 "\u{00a0}",
18661 "NBSP should survive round-trip inside listItem"
18662 );
18663 }
18664
18665 #[test]
18666 fn nbsp_paragraph_in_list_item_with_local_ids() {
18667 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-002"},"content":[{"type":"paragraph","attrs":{"localId":"p-002"},"content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
18669 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18670 let md = adf_to_markdown(&doc).unwrap();
18671 let rt = markdown_to_adf(&md).unwrap();
18672 let list = &rt.content[0];
18673 let item = &list.content.as_ref().unwrap()[0];
18674 assert_eq!(
18676 item.attrs.as_ref().unwrap()["localId"],
18677 "li-001",
18678 "listItem localId should survive"
18679 );
18680 let item_content = item.content.as_ref().unwrap();
18681 assert_eq!(item_content.len(), 2);
18682 let para = &item_content[0];
18684 assert_eq!(
18685 para.attrs.as_ref().unwrap()["localId"],
18686 "p-001",
18687 "paragraph localId should survive"
18688 );
18689 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18690 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
18691 }
18692
18693 #[test]
18694 fn nbsp_paragraph_in_list_item_without_nested_list() {
18695 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]}]}]}]}"#;
18697 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18698 let md = adf_to_markdown(&doc).unwrap();
18699 let rt = markdown_to_adf(&md).unwrap();
18700 let list = &rt.content[0];
18701 let item = &list.content.as_ref().unwrap()[0];
18702 let para = &item.content.as_ref().unwrap()[0];
18703 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18704 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
18705 }
18706
18707 #[test]
18708 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
18709 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
18711 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18712 let md = adf_to_markdown(&doc).unwrap();
18713 let rt = markdown_to_adf(&md).unwrap();
18714 let list = &rt.content[0];
18715 let item = &list.content.as_ref().unwrap()[0];
18716 let item_content = item.content.as_ref().unwrap();
18717 assert_eq!(item_content.len(), 2);
18718 let para = &item_content[0];
18719 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18720 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
18721 }
18722
18723 #[test]
18724 fn list_item_leading_space_preserved() {
18725 let md = "- hello world\n- - text";
18727 let doc = markdown_to_adf(md).unwrap();
18728 let list = &doc.content[0];
18729 assert_eq!(list.node_type, "bulletList");
18730 let items = list.content.as_ref().unwrap();
18731 let first_para = &items[0].content.as_ref().unwrap()[0];
18733 let first_text = &first_para.content.as_ref().unwrap()[0];
18734 assert_eq!(first_text.text.as_deref(), Some("hello world"));
18735 }
18736
18737 #[test]
18738 fn list_item_leading_space_not_stripped() {
18739 let md = "- leading space text";
18742 let doc = markdown_to_adf(md).unwrap();
18743 let list = &doc.content[0];
18744 let items = list.content.as_ref().unwrap();
18745 let para = &items[0].content.as_ref().unwrap()[0];
18746 let text_node = ¶.content.as_ref().unwrap()[0];
18747 assert_eq!(
18749 text_node.text.as_deref(),
18750 Some(" leading space text"),
18751 "leading space should be preserved"
18752 );
18753 }
18754
18755 #[test]
18760 fn hardbreak_in_cell_uses_directive_table() {
18761 let adf = AdfDocument {
18764 version: 1,
18765 doc_type: "doc".to_string(),
18766 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18767 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18768 AdfNode::text("before"),
18769 AdfNode::hard_break(),
18770 AdfNode::text("after"),
18771 ])]),
18772 ])])],
18773 };
18774 let md = adf_to_markdown(&adf).unwrap();
18775 assert!(
18777 md.contains(":::td") || md.contains("::::table"),
18778 "Table with hardBreak should use directive form, got:\n{md}"
18779 );
18780 assert!(
18781 !md.contains("| before"),
18782 "Should NOT use pipe syntax with hardBreak"
18783 );
18784 }
18785
18786 #[test]
18787 fn hardbreak_in_cell_roundtrips() {
18788 let adf = AdfDocument {
18790 version: 1,
18791 doc_type: "doc".to_string(),
18792 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18793 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18794 AdfNode::text("line one"),
18795 AdfNode::hard_break(),
18796 AdfNode::text("line two"),
18797 ])]),
18798 ])])],
18799 };
18800 let md = adf_to_markdown(&adf).unwrap();
18801 let roundtripped = markdown_to_adf(&md).unwrap();
18802
18803 assert_eq!(roundtripped.content.len(), 1);
18805 assert_eq!(roundtripped.content[0].node_type, "table");
18806 let rows = roundtripped.content[0].content.as_ref().unwrap();
18807 assert_eq!(
18808 rows.len(),
18809 1,
18810 "Should have exactly 1 row, got {}",
18811 rows.len()
18812 );
18813 }
18814
18815 #[test]
18816 fn hardbreak_in_paragraph_roundtrips() {
18817 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18819 {"type":"text","text":"line one"},
18820 {"type":"hardBreak"},
18821 {"type":"text","text":"line two"}
18822 ]}]}"#;
18823 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18824 let md = adf_to_markdown(&doc).unwrap();
18825 let round_tripped = markdown_to_adf(&md).unwrap();
18826 let inlines = round_tripped.content[0].content.as_ref().unwrap();
18827 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18828 assert_eq!(
18829 types,
18830 vec!["text", "hardBreak", "text"],
18831 "hardBreak should be preserved, got: {types:?}"
18832 );
18833 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
18834 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
18835 }
18836
18837 #[test]
18838 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
18839 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18841 {"type":"text","text":"before"},
18842 {"type":"hardBreak"},
18843 {"type":"hardBreak"},
18844 {"type":"text","text":"after"}
18845 ]}]}"#;
18846 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18847 let md = adf_to_markdown(&doc).unwrap();
18848 let round_tripped = markdown_to_adf(&md).unwrap();
18849 assert_eq!(
18850 round_tripped.content.len(),
18851 1,
18852 "Should remain a single paragraph, got {} blocks",
18853 round_tripped.content.len()
18854 );
18855 let inlines = round_tripped.content[0].content.as_ref().unwrap();
18856 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18857 assert_eq!(
18858 types,
18859 vec!["text", "hardBreak", "hardBreak", "text"],
18860 "Both hardBreaks should be preserved, got: {types:?}"
18861 );
18862 assert_eq!(inlines[0].text.as_deref(), Some("before"));
18863 assert_eq!(inlines[3].text.as_deref(), Some("after"));
18864 }
18865
18866 #[test]
18867 fn hardbreak_only_paragraph_roundtrips() {
18868 let adf_json = r#"{"version":1,"type":"doc","content":[
18870 {"type":"paragraph","content":[{"type":"hardBreak"}]}
18871 ]}"#;
18872 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18873 let md = adf_to_markdown(&doc).unwrap();
18874 let round_tripped = markdown_to_adf(&md).unwrap();
18875 assert_eq!(
18876 round_tripped.content.len(),
18877 1,
18878 "Paragraph should not be dropped, got {} blocks",
18879 round_tripped.content.len()
18880 );
18881 let inlines = round_tripped.content[0].content.as_ref().unwrap();
18882 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18883 assert_eq!(
18884 types,
18885 vec!["hardBreak"],
18886 "hardBreak-only paragraph should preserve its content, got: {types:?}"
18887 );
18888 }
18889
18890 #[test]
18891 fn issue_410_full_reproducer_roundtrips() {
18892 let adf_json = r#"{"version":1,"type":"doc","content":[
18894 {"type":"paragraph","content":[
18895 {"type":"text","text":"before"},
18896 {"type":"hardBreak"},
18897 {"type":"hardBreak"},
18898 {"type":"text","text":"after"}
18899 ]},
18900 {"type":"paragraph","content":[
18901 {"type":"hardBreak"}
18902 ]}
18903 ]}"#;
18904 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18905 let md = adf_to_markdown(&doc).unwrap();
18906 let round_tripped = markdown_to_adf(&md).unwrap();
18907 assert_eq!(
18908 round_tripped.content.len(),
18909 2,
18910 "Should have exactly 2 paragraphs, got {}",
18911 round_tripped.content.len()
18912 );
18913 let p1 = round_tripped.content[0].content.as_ref().unwrap();
18915 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
18916 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
18917 let p2 = round_tripped.content[1].content.as_ref().unwrap();
18919 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
18920 assert_eq!(types2, vec!["hardBreak"]);
18921 }
18922
18923 #[test]
18924 fn trailing_space_hardbreak_still_parsed() {
18925 let md = "line one \nline two\n";
18927 let doc = markdown_to_adf(md).unwrap();
18928 let inlines = doc.content[0].content.as_ref().unwrap();
18929 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18930 assert_eq!(
18931 types,
18932 vec!["text", "hardBreak", "text"],
18933 "Trailing-space hardBreak should still parse, got: {types:?}"
18934 );
18935 }
18936
18937 #[test]
18938 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
18939 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18941 {"type":"text","text":"text"},
18942 {"type":"hardBreak"}
18943 ]}]}"#;
18944 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18945 let md = adf_to_markdown(&doc).unwrap();
18946 let round_tripped = markdown_to_adf(&md).unwrap();
18947 let inlines = round_tripped.content[0].content.as_ref().unwrap();
18948 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18949 assert_eq!(
18950 types,
18951 vec!["text", "hardBreak"],
18952 "Trailing hardBreak should be preserved, got: {types:?}"
18953 );
18954 }
18955
18956 #[test]
18957 #[test]
18958 fn table_with_header_row_uses_pipe_syntax() {
18959 let adf = AdfDocument {
18961 version: 1,
18962 doc_type: "doc".to_string(),
18963 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18964 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
18965 ])])],
18966 };
18967 let md = adf_to_markdown(&adf).unwrap();
18968 assert!(
18969 md.contains("| header cell |"),
18970 "Table with header row should use pipe syntax, got:\n{md}"
18971 );
18972 }
18973
18974 #[test]
18975 fn table_without_header_row_uses_directive_syntax() {
18976 let adf = AdfDocument {
18979 version: 1,
18980 doc_type: "doc".to_string(),
18981 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18982 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
18983 ])])],
18984 };
18985 let md = adf_to_markdown(&adf).unwrap();
18986 assert!(
18987 md.contains("::::table"),
18988 "Table without header row should use directive syntax, got:\n{md}"
18989 );
18990 }
18991
18992 #[test]
18993 fn tablecell_first_row_preserved_on_roundtrip() {
18994 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
18996 {"type":"tableRow","content":[
18997 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
18998 ]},
18999 {"type":"tableRow","content":[
19000 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
19001 ]}
19002 ]}]}"#;
19003 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19004 let md = adf_to_markdown(&doc).unwrap();
19005 let round_tripped = markdown_to_adf(&md).unwrap();
19006 let rows = round_tripped.content[0].content.as_ref().unwrap();
19007 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
19008 assert_eq!(
19009 row0_cell.node_type, "tableCell",
19010 "first row cell should remain tableCell, got: {}",
19011 row0_cell.node_type
19012 );
19013 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
19014 assert_eq!(row1_cell.node_type, "tableCell");
19015 }
19016
19017 #[test]
19018 fn mixed_header_and_cell_first_row_uses_pipe() {
19019 let adf = AdfDocument {
19021 version: 1,
19022 doc_type: "doc".to_string(),
19023 content: vec![AdfNode::table(vec![
19024 AdfNode::table_row(vec![
19025 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
19026 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
19027 ]),
19028 AdfNode::table_row(vec![
19029 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
19030 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
19031 ]),
19032 ])],
19033 };
19034 let md = adf_to_markdown(&adf).unwrap();
19035 assert!(
19036 md.contains("| H1 |"),
19037 "Table with header first row should use pipe syntax, got:\n{md}"
19038 );
19039 assert!(!md.contains("::::table"), "should not use directive syntax");
19040 }
19041
19042 #[test]
19045 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
19046 let adf = AdfDocument {
19049 version: 1,
19050 doc_type: "doc".to_string(),
19051 content: vec![AdfNode::table(vec![
19052 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
19053 AdfNode::text("Header"),
19054 ])])]),
19055 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19056 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
19057 ])])]),
19058 ])],
19059 };
19060 let md = adf_to_markdown(&adf).unwrap();
19061 assert!(
19062 md.contains(r"`a\|b`"),
19063 "Pipe inside code span must be escaped, got:\n{md}"
19064 );
19065 }
19066
19067 #[test]
19068 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
19069 let adf = AdfDocument {
19070 version: 1,
19071 doc_type: "doc".to_string(),
19072 content: vec![AdfNode::table(vec![
19073 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
19074 AdfNode::text("Header"),
19075 ])])]),
19076 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19077 AdfNode::text("x|y"),
19078 ])])]),
19079 ])],
19080 };
19081 let md = adf_to_markdown(&adf).unwrap();
19082 assert!(
19083 md.contains(r"x\|y"),
19084 "Pipe inside plain-text cell must be escaped, got:\n{md}"
19085 );
19086 }
19087
19088 #[test]
19089 fn code_span_with_pipe_in_table_cell_roundtrips() {
19090 let adf_json = r#"{
19092 "version": 1,
19093 "type": "doc",
19094 "content": [{
19095 "type": "table",
19096 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
19097 "content": [
19098 {"type": "tableRow", "content": [
19099 {"type": "tableHeader", "attrs": {}, "content": [
19100 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
19101 ]},
19102 {"type": "tableHeader", "attrs": {}, "content": [
19103 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
19104 ]}
19105 ]},
19106 {"type": "tableRow", "content": [
19107 {"type": "tableCell", "attrs": {}, "content": [
19108 {"type": "paragraph", "content": [
19109 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
19110 ]}
19111 ]},
19112 {"type": "tableCell", "attrs": {}, "content": [
19113 {"type": "paragraph", "content": [
19114 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
19115 ]}
19116 ]}
19117 ]}
19118 ]
19119 }]
19120 }"#;
19121 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19122 let md = adf_to_markdown(&doc).unwrap();
19123 let round_tripped = markdown_to_adf(&md).unwrap();
19124
19125 let rows = round_tripped.content[0].content.as_ref().unwrap();
19126 assert_eq!(
19127 rows.len(),
19128 2,
19129 "Table should have 2 rows, got: {}",
19130 rows.len()
19131 );
19132
19133 let body_row = rows[1].content.as_ref().unwrap();
19134 assert_eq!(
19135 body_row.len(),
19136 2,
19137 "Body row should have 2 cells (not split by the pipe), got: {}",
19138 body_row.len()
19139 );
19140
19141 let second_cell = &body_row[1];
19142 let para = second_cell.content.as_ref().unwrap().first().unwrap();
19143 let inlines = para.content.as_ref().unwrap();
19144 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
19145 assert_eq!(
19146 inlines[0].text.as_deref(),
19147 Some("parser.decode[T|json]"),
19148 "Code-span text must be preserved with literal pipe"
19149 );
19150 let marks = inlines[0]
19151 .marks
19152 .as_ref()
19153 .expect("code mark must be preserved");
19154 assert!(
19155 marks.iter().any(|m| m.mark_type == "code"),
19156 "text node should carry the code mark"
19157 );
19158 }
19159
19160 #[test]
19161 fn plain_text_pipe_in_table_cell_roundtrips() {
19162 let adf = AdfDocument {
19164 version: 1,
19165 doc_type: "doc".to_string(),
19166 content: vec![AdfNode::table(vec![
19167 AdfNode::table_row(vec![
19168 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
19169 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
19170 ]),
19171 AdfNode::table_row(vec![
19172 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
19173 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
19174 ]),
19175 ])],
19176 };
19177 let md = adf_to_markdown(&adf).unwrap();
19178 let round_tripped = markdown_to_adf(&md).unwrap();
19179 let rows = round_tripped.content[0].content.as_ref().unwrap();
19180 let body_row = rows[1].content.as_ref().unwrap();
19181 assert_eq!(
19182 body_row.len(),
19183 2,
19184 "Body row should keep 2 cells, got: {}",
19185 body_row.len()
19186 );
19187 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
19188 .content
19189 .as_ref()
19190 .unwrap()[0]
19191 .text
19192 .as_deref();
19193 assert_eq!(first_cell_text, Some("a|b"));
19194 }
19195
19196 #[test]
19197 fn cell_contains_hard_break_true() {
19198 let para = AdfNode::paragraph(vec![
19199 AdfNode::text("a"),
19200 AdfNode::hard_break(),
19201 AdfNode::text("b"),
19202 ]);
19203 assert!(cell_contains_hard_break(¶));
19204 }
19205
19206 #[test]
19207 fn cell_contains_hard_break_false() {
19208 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
19209 assert!(!cell_contains_hard_break(¶));
19210 }
19211
19212 #[test]
19213 fn cell_contains_hard_break_empty() {
19214 let para = AdfNode::paragraph(vec![]);
19215 assert!(!cell_contains_hard_break(¶));
19216 }
19217
19218 #[test]
19221 fn multi_paragraph_panel_roundtrips() {
19222 let adf = AdfDocument {
19223 version: 1,
19224 doc_type: "doc".to_string(),
19225 content: vec![AdfNode {
19226 node_type: "panel".to_string(),
19227 attrs: Some(serde_json::json!({"panelType": "info"})),
19228 content: Some(vec![
19229 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
19230 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
19231 ]),
19232 text: None,
19233 marks: None,
19234 local_id: None,
19235 parameters: None,
19236 }],
19237 };
19238
19239 let md = adf_to_markdown(&adf).unwrap();
19240 assert!(
19242 md.contains("First paragraph.\n\nSecond paragraph."),
19243 "Panel should have blank line between paragraphs, got:\n{md}"
19244 );
19245
19246 let roundtripped = markdown_to_adf(&md).unwrap();
19248 assert_eq!(roundtripped.content.len(), 1);
19249 assert_eq!(roundtripped.content[0].node_type, "panel");
19250 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
19251 assert_eq!(
19252 panel_content.len(),
19253 2,
19254 "Panel should have 2 paragraphs after round-trip, got {}",
19255 panel_content.len()
19256 );
19257 }
19258
19259 #[test]
19260 fn multi_paragraph_expand_roundtrips() {
19261 let adf = AdfDocument {
19262 version: 1,
19263 doc_type: "doc".to_string(),
19264 content: vec![AdfNode {
19265 node_type: "expand".to_string(),
19266 attrs: Some(serde_json::json!({"title": "Details"})),
19267 content: Some(vec![
19268 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
19269 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
19270 ]),
19271 text: None,
19272 marks: None,
19273 local_id: None,
19274 parameters: None,
19275 }],
19276 };
19277
19278 let md = adf_to_markdown(&adf).unwrap();
19279 let roundtripped = markdown_to_adf(&md).unwrap();
19280 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
19281 assert_eq!(
19282 expand_content.len(),
19283 2,
19284 "Expand should have 2 paragraphs after round-trip, got {}",
19285 expand_content.len()
19286 );
19287 }
19288
19289 #[test]
19290 fn consecutive_nested_expands_in_table_cell_roundtrip() {
19291 let cell_content = vec![
19292 AdfNode {
19293 node_type: "nestedExpand".to_string(),
19294 attrs: Some(serde_json::json!({"title": "First"})),
19295 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
19296 text: None,
19297 marks: None,
19298 local_id: None,
19299 parameters: None,
19300 },
19301 AdfNode {
19302 node_type: "nestedExpand".to_string(),
19303 attrs: Some(serde_json::json!({"title": "Second"})),
19304 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
19305 text: None,
19306 marks: None,
19307 local_id: None,
19308 parameters: None,
19309 },
19310 ];
19311 let adf = AdfDocument {
19312 version: 1,
19313 doc_type: "doc".to_string(),
19314 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19315 AdfNode::table_cell(cell_content),
19316 ])])],
19317 };
19318
19319 let md = adf_to_markdown(&adf).unwrap();
19320 assert!(
19321 md.contains(":::\n\n:::nested-expand"),
19322 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
19323 );
19324
19325 let rt = markdown_to_adf(&md).unwrap();
19326 let cell = &rt.content[0].content.as_ref().unwrap()[0]
19327 .content
19328 .as_ref()
19329 .unwrap()[0];
19330 let cell_nodes = cell.content.as_ref().unwrap();
19331 let expand_count = cell_nodes
19332 .iter()
19333 .filter(|n| n.node_type == "nestedExpand")
19334 .count();
19335 assert_eq!(
19336 expand_count, 2,
19337 "Both nested-expands should survive round-trip, got {expand_count}"
19338 );
19339 }
19340
19341 #[test]
19342 fn multi_paragraph_in_table_cell_roundtrip() {
19343 let adf = AdfDocument {
19345 version: 1,
19346 doc_type: "doc".to_string(),
19347 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19348 AdfNode::table_cell(vec![
19349 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
19350 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
19351 ]),
19352 ])])],
19353 };
19354
19355 let md = adf_to_markdown(&adf).unwrap();
19356 assert!(
19357 md.contains("Para one.\n\nPara two."),
19358 "Should have blank line between paragraphs in cell, got:\n{md}"
19359 );
19360
19361 let rt = markdown_to_adf(&md).unwrap();
19362 let cell = &rt.content[0].content.as_ref().unwrap()[0]
19363 .content
19364 .as_ref()
19365 .unwrap()[0];
19366 let para_count = cell
19367 .content
19368 .as_ref()
19369 .unwrap()
19370 .iter()
19371 .filter(|n| n.node_type == "paragraph")
19372 .count();
19373 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
19374 }
19375
19376 #[test]
19377 fn panel_inside_table_cell_roundtrip() {
19378 let adf = AdfDocument {
19380 version: 1,
19381 doc_type: "doc".to_string(),
19382 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19383 AdfNode::table_cell(vec![
19384 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
19385 AdfNode {
19386 node_type: "panel".to_string(),
19387 attrs: Some(serde_json::json!({"panelType": "info"})),
19388 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
19389 "Panel content",
19390 )])]),
19391 text: None,
19392 marks: None,
19393 local_id: None,
19394 parameters: None,
19395 },
19396 ]),
19397 ])])],
19398 };
19399
19400 let md = adf_to_markdown(&adf).unwrap();
19401 assert!(
19402 md.contains(":::panel"),
19403 "Should contain panel directive, got:\n{md}"
19404 );
19405
19406 let rt = markdown_to_adf(&md).unwrap();
19407 let cell = &rt.content[0].content.as_ref().unwrap()[0]
19408 .content
19409 .as_ref()
19410 .unwrap()[0];
19411 let has_panel = cell
19412 .content
19413 .as_ref()
19414 .unwrap()
19415 .iter()
19416 .any(|n| n.node_type == "panel");
19417 assert!(has_panel, "Panel should survive round-trip in table cell");
19418 }
19419
19420 #[test]
19421 fn three_consecutive_expands_in_table_cell() {
19422 let make_expand = |title: &str| AdfNode {
19423 node_type: "nestedExpand".to_string(),
19424 attrs: Some(serde_json::json!({"title": title})),
19425 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
19426 text: None,
19427 marks: None,
19428 local_id: None,
19429 parameters: None,
19430 };
19431 let adf = AdfDocument {
19432 version: 1,
19433 doc_type: "doc".to_string(),
19434 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19435 AdfNode::table_cell(vec![
19436 make_expand("First"),
19437 make_expand("Second"),
19438 make_expand("Third"),
19439 ]),
19440 ])])],
19441 };
19442
19443 let md = adf_to_markdown(&adf).unwrap();
19444 let rt = markdown_to_adf(&md).unwrap();
19445 let cell = &rt.content[0].content.as_ref().unwrap()[0]
19446 .content
19447 .as_ref()
19448 .unwrap()[0];
19449 let expand_count = cell
19450 .content
19451 .as_ref()
19452 .unwrap()
19453 .iter()
19454 .filter(|n| n.node_type == "nestedExpand")
19455 .count();
19456 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
19457 }
19458
19459 #[test]
19462 fn nested_expand_inside_panel() {
19463 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
19468 let adf = markdown_to_adf(md).unwrap();
19469
19470 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
19471 assert!(err.violations.iter().any(|v| matches!(
19472 v,
19473 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
19474 parent_type, child_type, ..
19475 } if parent_type == "panel" && child_type == "expand"
19476 )));
19477 }
19478
19479 #[test]
19480 fn nested_expand_inside_table_cell() {
19481 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
19485 let adf = markdown_to_adf(md).unwrap();
19486
19487 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
19488 assert!(err.violations.iter().any(|v| matches!(
19489 v,
19490 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
19491 parent_type, child_type, ..
19492 } if parent_type == "tableCell" && child_type == "expand"
19493 )));
19494 }
19495
19496 #[test]
19497 fn nested_expand_inside_layout_column() {
19498 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
19503 let adf = markdown_to_adf(md).unwrap();
19504
19505 assert_eq!(adf.content.len(), 1);
19506 assert_eq!(adf.content[0].node_type, "layoutSection");
19507
19508 let columns = adf.content[0].content.as_ref().unwrap();
19509 assert_eq!(columns.len(), 2);
19510 let col_content = columns[0].content.as_ref().unwrap();
19511 assert!(
19512 col_content.iter().any(|n| n.node_type == "expand"),
19513 "Column should contain an expand node, got: {:?}",
19514 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
19515 );
19516
19517 crate::atlassian::adf_validated::validate(&adf).unwrap();
19519 }
19520
19521 #[test]
19522 fn expand_localid_in_directive_attrs() {
19523 let adf_json = r#"{"version":1,"type":"doc","content":[
19525 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
19526 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19527 ]}
19528 ]}"#;
19529 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19530 let md = adf_to_markdown(&doc).unwrap();
19531 assert!(
19532 md.contains("localId=exp-001"),
19533 "should contain localId: {md}"
19534 );
19535 assert!(
19536 md.contains(":::expand{"),
19537 "should have expand directive with attrs: {md}"
19538 );
19539 assert!(
19540 !md.contains(":::\n{localId="),
19541 "localId should NOT be trailing: {md}"
19542 );
19543 }
19544
19545 #[test]
19546 fn expand_localid_roundtrip() {
19547 let adf_json = r#"{"version":1,"type":"doc","content":[
19548 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
19549 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19550 ]}
19551 ]}"#;
19552 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19553 let md = adf_to_markdown(&doc).unwrap();
19554 let rt = markdown_to_adf(&md).unwrap();
19555 let expand = &rt.content[0];
19556 assert_eq!(expand.node_type, "expand");
19557 assert_eq!(
19558 expand.local_id.as_deref(),
19559 Some("exp-001"),
19560 "expand localId should survive round-trip"
19561 );
19562 assert_eq!(
19563 expand.attrs.as_ref().unwrap()["title"],
19564 "Details",
19565 "expand title should survive round-trip"
19566 );
19567 }
19568
19569 #[test]
19570 fn nested_expand_localid_roundtrip() {
19571 let adf_json = r#"{"version":1,"type":"doc","content":[
19572 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
19573 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
19574 ]}
19575 ]}"#;
19576 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19577 let md = adf_to_markdown(&doc).unwrap();
19578 assert!(
19579 md.contains(":::nested-expand{"),
19580 "should have directive: {md}"
19581 );
19582 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
19583 let rt = markdown_to_adf(&md).unwrap();
19584 let ne = &rt.content[0];
19585 assert_eq!(ne.node_type, "nestedExpand");
19586 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
19587 }
19588
19589 #[test]
19590 fn nested_expand_localid_followed_by_content() {
19591 let adf_json = "{\
19593 \"version\":1,\"type\":\"doc\",\"content\":[\
19594 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
19595 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
19596 ]},\
19597 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
19598 ]}";
19599 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19600 let md = adf_to_markdown(&doc).unwrap();
19601 let rt = markdown_to_adf(&md).unwrap();
19602 let ne = &rt.content[0];
19604 assert_eq!(ne.node_type, "nestedExpand");
19605 assert_eq!(
19606 ne.local_id.as_deref(),
19607 Some("exp-001"),
19608 "nestedExpand should preserve localId"
19609 );
19610 let para = &rt.content[1];
19612 assert_eq!(para.node_type, "paragraph");
19613 let text = para.content.as_ref().unwrap()[0]
19614 .text
19615 .as_deref()
19616 .unwrap_or("");
19617 assert!(
19618 !text.contains("localId"),
19619 "following paragraph should not contain localId: {text}"
19620 );
19621 assert!(
19622 text.contains("after"),
19623 "following paragraph should contain 'after': {text}"
19624 );
19625 }
19626
19627 #[test]
19628 fn expand_localid_without_title() {
19629 let adf_json = r#"{"version":1,"type":"doc","content":[
19630 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
19631 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
19632 ]}
19633 ]}"#;
19634 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19635 let md = adf_to_markdown(&doc).unwrap();
19636 assert!(
19637 md.contains(":::expand{localId=exp-002}"),
19638 "should have localId without title: {md}"
19639 );
19640 let rt = markdown_to_adf(&md).unwrap();
19641 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
19642 }
19643
19644 #[test]
19645 fn expand_localid_stripped() {
19646 let adf_json = r#"{"version":1,"type":"doc","content":[
19647 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
19648 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19649 ]}
19650 ]}"#;
19651 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19652 let opts = RenderOptions {
19653 strip_local_ids: true,
19654 };
19655 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
19656 assert!(!md.contains("localId"), "localId should be stripped: {md}");
19657 assert!(
19658 md.contains(":::expand{title=\"X\"}"),
19659 "title should remain: {md}"
19660 );
19661 }
19662
19663 #[test]
19666 fn expand_top_level_localid_roundtrip() {
19667 let adf_json = r#"{"version":1,"type":"doc","content":[
19669 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
19670 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
19671 ]}
19672 ]}"#;
19673 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19674 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
19675 let md = adf_to_markdown(&doc).unwrap();
19676 assert!(
19677 md.contains("localId=abc-123"),
19678 "JFM should contain localId: {md}"
19679 );
19680 let rt = markdown_to_adf(&md).unwrap();
19681 let expand = &rt.content[0];
19682 assert_eq!(expand.node_type, "expand");
19683 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
19684 assert_eq!(
19685 expand.attrs.as_ref().unwrap()["title"],
19686 "My Section",
19687 "title should survive round-trip"
19688 );
19689 }
19690
19691 #[test]
19692 fn expand_parameters_roundtrip() {
19693 let adf_json = r#"{"version":1,"type":"doc","content":[
19695 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
19696 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19697 ]}
19698 ]}"#;
19699 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19700 assert!(doc.content[0].parameters.is_some());
19701 let md = adf_to_markdown(&doc).unwrap();
19702 assert!(md.contains("params="), "JFM should contain params: {md}");
19703 let rt = markdown_to_adf(&md).unwrap();
19704 let expand = &rt.content[0];
19705 let params = expand
19706 .parameters
19707 .as_ref()
19708 .expect("parameters should survive round-trip");
19709 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
19710 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
19711 }
19712
19713 #[test]
19714 fn expand_localid_and_parameters_roundtrip() {
19715 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","parameters":{"macroMetadata":{"macroId":{"value":"macro-001"},"schemaVersion":{"value":"1"},"title":"Page Properties"}},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}"#;
19717 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19718 let md = adf_to_markdown(&doc).unwrap();
19719 let rt = markdown_to_adf(&md).unwrap();
19720 let expand = &rt.content[0];
19721 assert_eq!(expand.node_type, "expand");
19722 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
19723 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
19724 let params = expand
19725 .parameters
19726 .as_ref()
19727 .expect("parameters should survive");
19728 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
19729 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
19730 }
19731
19732 #[test]
19733 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
19734 let adf_json = r#"{"version":1,"type":"doc","content":[
19735 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
19736 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
19737 ]}
19738 ]}"#;
19739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19740 let md = adf_to_markdown(&doc).unwrap();
19741 assert!(
19742 md.contains(":::nested-expand{"),
19743 "should use nested-expand: {md}"
19744 );
19745 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
19746 assert!(md.contains("params="), "should have params: {md}");
19747 let rt = markdown_to_adf(&md).unwrap();
19748 let ne = &rt.content[0];
19749 assert_eq!(ne.node_type, "nestedExpand");
19750 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
19751 assert_eq!(
19752 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
19753 "nm-001"
19754 );
19755 }
19756
19757 #[test]
19758 fn expand_top_level_localid_stripped() {
19759 let adf_json = r#"{"version":1,"type":"doc","content":[
19761 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
19762 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19763 ]}
19764 ]}"#;
19765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19766 let opts = RenderOptions {
19767 strip_local_ids: true,
19768 };
19769 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
19770 assert!(!md.contains("localId"), "localId should be stripped: {md}");
19771 assert!(
19772 md.contains(":::expand{title=\"X\"}"),
19773 "title should remain: {md}"
19774 );
19775 }
19776
19777 #[test]
19778 fn expand_parameters_without_localid() {
19779 let adf_json = r#"{"version":1,"type":"doc","content":[
19781 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
19782 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
19783 ]}
19784 ]}"#;
19785 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19786 let md = adf_to_markdown(&doc).unwrap();
19787 assert!(!md.contains("localId"), "no localId: {md}");
19788 assert!(md.contains("params="), "has params: {md}");
19789 let rt = markdown_to_adf(&md).unwrap();
19790 assert!(rt.content[0].local_id.is_none());
19791 assert_eq!(
19792 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
19793 "solo"
19794 );
19795 }
19796
19797 #[test]
19798 fn expand_localid_without_parameters() {
19799 let adf_json = r#"{"version":1,"type":"doc","content":[
19801 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
19802 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
19803 ]}
19804 ]}"#;
19805 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19806 let md = adf_to_markdown(&doc).unwrap();
19807 assert!(md.contains("localId=lid-only"), "has localId: {md}");
19808 assert!(!md.contains("params="), "no params: {md}");
19809 let rt = markdown_to_adf(&md).unwrap();
19810 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
19811 assert!(rt.content[0].parameters.is_none());
19812 }
19813
19814 #[test]
19815 fn nested_panel_inside_panel() {
19816 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
19817 let adf = markdown_to_adf(md).unwrap();
19818
19819 assert_eq!(adf.content.len(), 1);
19821 assert_eq!(adf.content[0].node_type, "panel");
19822
19823 let panel_content = adf.content[0].content.as_ref().unwrap();
19825 assert!(
19826 panel_content.iter().any(|n| n.node_type == "panel"),
19827 "Outer panel should contain an inner panel, got: {:?}",
19828 panel_content
19829 .iter()
19830 .map(|n| &n.node_type)
19831 .collect::<Vec<_>>()
19832 );
19833 }
19834
19835 #[test]
19836 fn content_after_directive_table_is_preserved() {
19837 let md = "\
19839## Before table
19840
19841::::table{layout=default}
19842:::tr
19843:::th{}
19844Cell
19845:::
19846:::
19847::::
19848
19849## After table
19850
19851Paragraph after.";
19852 let adf = markdown_to_adf(md).unwrap();
19853 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19854 assert_eq!(
19855 types,
19856 vec!["heading", "table", "heading", "paragraph"],
19857 "Content after table was dropped: got {types:?}"
19858 );
19859 }
19860
19861 #[test]
19862 fn paragraph_after_directive_table_is_preserved() {
19863 let md = "\
19865::::table{layout=default}
19866:::tr
19867:::th{}
19868Header
19869:::
19870:::
19871::::
19872
19873Just a paragraph.";
19874 let adf = markdown_to_adf(md).unwrap();
19875 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19876 assert_eq!(
19877 types,
19878 vec!["table", "paragraph"],
19879 "Paragraph after table was dropped: got {types:?}"
19880 );
19881 }
19882
19883 #[test]
19884 fn extension_after_directive_table_is_preserved() {
19885 let md = "\
19887::::table{layout=default}
19888:::tr
19889:::th{}
19890Header
19891:::
19892:::
19893::::
19894
19895::extension{type=com.atlassian.confluence.macro.core key=toc}";
19896 let adf = markdown_to_adf(md).unwrap();
19897 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19898 assert_eq!(
19899 types,
19900 vec!["table", "extension"],
19901 "Extension after table was dropped: got {types:?}"
19902 );
19903 }
19904
19905 #[test]
19906 fn multiple_blocks_after_directive_table() {
19907 let md = "\
19909## Heading 1
19910
19911::::table{layout=default}
19912:::tr
19913:::td{}
19914A
19915:::
19916:::td{}
19917B
19918:::
19919:::
19920::::
19921
19922## Heading 2
19923
19924Some text.
19925
19926---
19927
19928::::table{layout=default}
19929:::tr
19930:::th{}
19931C
19932:::
19933:::
19934::::
19935
19936## Heading 3";
19937 let adf = markdown_to_adf(md).unwrap();
19938 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19939 assert_eq!(
19940 types,
19941 vec![
19942 "heading",
19943 "table",
19944 "heading",
19945 "paragraph",
19946 "rule",
19947 "table",
19948 "heading"
19949 ],
19950 "Content after tables was dropped: got {types:?}"
19951 );
19952 }
19953
19954 #[test]
19957 fn adf_table_caption_to_markdown() {
19958 let doc = AdfDocument {
19959 version: 1,
19960 doc_type: "doc".to_string(),
19961 content: vec![AdfNode::table(vec![
19962 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19963 AdfNode::text("cell"),
19964 ])])]),
19965 AdfNode::caption(vec![AdfNode::text("Table caption")]),
19966 ])],
19967 };
19968 let md = adf_to_markdown(&doc).unwrap();
19969 assert!(
19970 md.contains("::::table"),
19971 "table with caption must use directive form"
19972 );
19973 assert!(
19974 md.contains(":::caption"),
19975 "caption directive missing, got: {md}"
19976 );
19977 assert!(
19978 md.contains("Table caption"),
19979 "caption text missing, got: {md}"
19980 );
19981 }
19982
19983 #[test]
19984 fn directive_table_caption_parses() {
19985 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
19986 let doc = markdown_to_adf(md).unwrap();
19987 let table = &doc.content[0];
19988 assert_eq!(table.node_type, "table");
19989 let children = table.content.as_ref().unwrap();
19990 assert_eq!(children.len(), 2, "expected row + caption");
19991 assert_eq!(children[0].node_type, "tableRow");
19992 assert_eq!(children[1].node_type, "caption");
19993 let caption_content = children[1].content.as_ref().unwrap();
19994 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
19995 }
19996
19997 #[test]
19998 fn table_caption_round_trip_from_adf_json() {
19999 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20000 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20001 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
20002 ]}]}"#;
20003 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20004 let md = adf_to_markdown(&doc).unwrap();
20005 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
20006 let round_tripped = markdown_to_adf(&md).unwrap();
20007 let children = round_tripped.content[0].content.as_ref().unwrap();
20008 let caption = children.iter().find(|n| n.node_type == "caption");
20009 assert!(caption.is_some(), "caption lost on round-trip");
20010 let caption_text = caption.unwrap().content.as_ref().unwrap();
20011 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
20012 }
20013
20014 #[test]
20015 fn table_caption_with_inline_marks_round_trips() {
20016 let doc = AdfDocument {
20017 version: 1,
20018 doc_type: "doc".to_string(),
20019 content: vec![AdfNode::table(vec![
20020 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
20021 AdfNode::text("data"),
20022 ])])]),
20023 AdfNode::caption(vec![
20024 AdfNode::text("Caption with "),
20025 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
20026 ]),
20027 ])],
20028 };
20029 let md = adf_to_markdown(&doc).unwrap();
20030 assert!(md.contains("**bold**"), "bold mark missing in caption");
20031 let round_tripped = markdown_to_adf(&md).unwrap();
20032 let caption = round_tripped.content[0]
20033 .content
20034 .as_ref()
20035 .unwrap()
20036 .iter()
20037 .find(|n| n.node_type == "caption")
20038 .expect("caption node missing after round-trip");
20039 let inlines = caption.content.as_ref().unwrap();
20040 let bold_node = inlines.iter().find(|n| {
20041 n.marks
20042 .as_ref()
20043 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
20044 });
20045 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
20046 }
20047
20048 #[test]
20051 fn table_caption_localid_roundtrip() {
20052 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20053 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20054 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
20055 ]}]}"#;
20056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20057 let md = adf_to_markdown(&doc).unwrap();
20058 assert!(
20059 md.contains("localId=abcdef123456"),
20060 "table caption localId should appear in markdown: {md}"
20061 );
20062 let rt = markdown_to_adf(&md).unwrap();
20063 let caption = rt.content[0]
20064 .content
20065 .as_ref()
20066 .unwrap()
20067 .iter()
20068 .find(|n| n.node_type == "caption")
20069 .expect("caption should survive round-trip");
20070 assert_eq!(
20071 caption.attrs.as_ref().unwrap()["localId"],
20072 "abcdef123456",
20073 "table caption localId should round-trip"
20074 );
20075 }
20076
20077 #[test]
20078 fn table_caption_without_localid_unchanged() {
20079 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
20080 let doc = markdown_to_adf(md).unwrap();
20081 let caption = doc.content[0]
20082 .content
20083 .as_ref()
20084 .unwrap()
20085 .iter()
20086 .find(|n| n.node_type == "caption")
20087 .unwrap();
20088 assert!(
20089 caption.attrs.is_none(),
20090 "table caption without localId should not gain attrs"
20091 );
20092 let md2 = adf_to_markdown(&doc).unwrap();
20093 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
20094 }
20095
20096 #[test]
20097 fn table_caption_localid_stripped_when_option_set() {
20098 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20099 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20100 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
20101 ]}]}"#;
20102 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20103 let opts = RenderOptions {
20104 strip_local_ids: true,
20105 ..Default::default()
20106 };
20107 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
20108 assert!(
20109 !md.contains("localId"),
20110 "table caption localId should be stripped: {md}"
20111 );
20112 }
20113
20114 #[test]
20115 #[test]
20116 fn tablecell_empty_attrs_preserved_on_roundtrip() {
20117 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20119 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20120 let md = adf_to_markdown(&doc).unwrap();
20121 let round_tripped = markdown_to_adf(&md).unwrap();
20122 let rows = round_tripped.content[0].content.as_ref().unwrap();
20123 let cell = &rows[0].content.as_ref().unwrap()[0];
20124 assert!(
20125 cell.attrs.is_some(),
20126 "tableCell attrs should be preserved, got None"
20127 );
20128 assert_eq!(
20129 cell.attrs.as_ref().unwrap(),
20130 &serde_json::json!({}),
20131 "tableCell attrs should be an empty object"
20132 );
20133 }
20134
20135 #[test]
20136 fn tablecell_empty_attrs_serialized_in_json() {
20137 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20139 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20140 let md = adf_to_markdown(&doc).unwrap();
20141 let round_tripped = markdown_to_adf(&md).unwrap();
20142 let json = serde_json::to_string(&round_tripped).unwrap();
20143 assert!(
20144 json.contains(r#""attrs":{}"#),
20145 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
20146 );
20147 }
20148
20149 #[test]
20150 fn tablecell_empty_attrs_renders_braces_in_markdown() {
20151 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]},{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]},{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"world"}]}]}]}]}]}"#;
20153 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20154 let md = adf_to_markdown(&doc).unwrap();
20155 assert!(
20157 md.contains("{} hello"),
20158 "cell with empty attrs should render '{{}} hello', got: {md}"
20159 );
20160 assert!(
20161 !md.contains("{} world"),
20162 "cell without attrs should not render '{{}}', got: {md}"
20163 );
20164 }
20165
20166 #[test]
20167 fn tablecell_no_attrs_unchanged_on_roundtrip() {
20168 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20170 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20171 let md = adf_to_markdown(&doc).unwrap();
20172 let round_tripped = markdown_to_adf(&md).unwrap();
20173 let rows = round_tripped.content[0].content.as_ref().unwrap();
20174 let cell = &rows[0].content.as_ref().unwrap()[0];
20175 assert!(
20176 cell.attrs.is_none(),
20177 "tableCell without attrs should stay None, got: {:?}",
20178 cell.attrs
20179 );
20180 }
20181
20182 #[test]
20183 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
20184 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#DEEBFF","colspan":2},"content":[{"type":"paragraph","content":[{"type":"text","text":"highlighted"}]}]}]}]}]}"##;
20186 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20187 let md = adf_to_markdown(&doc).unwrap();
20188 let round_tripped = markdown_to_adf(&md).unwrap();
20189 let rows = round_tripped.content[0].content.as_ref().unwrap();
20190 let cell = &rows[1].content.as_ref().unwrap()[0];
20191 let attrs = cell.attrs.as_ref().unwrap();
20192 assert_eq!(attrs["background"], "#DEEBFF");
20193 assert_eq!(attrs["colspan"], 2);
20194 }
20195
20196 #[test]
20197 fn pipe_table_not_used_when_caption_present() {
20198 let doc = AdfDocument {
20199 version: 1,
20200 doc_type: "doc".to_string(),
20201 content: vec![AdfNode::table(vec![
20202 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
20203 AdfNode::text("H"),
20204 ])])]),
20205 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
20206 AdfNode::text("D"),
20207 ])])]),
20208 AdfNode::caption(vec![AdfNode::text("cap")]),
20209 ])],
20210 };
20211 let md = adf_to_markdown(&doc).unwrap();
20212 assert!(
20213 md.contains("::::table"),
20214 "pipe syntax should not be used when caption is present"
20215 );
20216 }
20217
20218 #[test]
20221 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
20222 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20225 {"type":"listItem","content":[{"type":"paragraph","content":[
20226 {"type":"text","text":"1. First item"},
20227 {"type":"hardBreak"},
20228 {"type":"text","text":"2. Honouring existing commitments"}
20229 ]}]}
20230 ]}]}"#;
20231 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20232 let md = adf_to_markdown(&doc).unwrap();
20233
20234 assert!(
20236 md.contains(" 2. Honouring"),
20237 "Continuation line should be indented, got:\n{md}"
20238 );
20239
20240 let rt = markdown_to_adf(&md).unwrap();
20242 let list = &rt.content[0];
20243 assert_eq!(list.node_type, "bulletList");
20244 let items = list.content.as_ref().unwrap();
20245 assert_eq!(
20246 items.len(),
20247 1,
20248 "Should be one list item, got {}",
20249 items.len()
20250 );
20251
20252 let para = &items[0].content.as_ref().unwrap()[0];
20253 let inlines = para.content.as_ref().unwrap();
20254 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20255 assert_eq!(
20256 types,
20257 vec!["text", "hardBreak", "text"],
20258 "Expected text+hardBreak+text, got {types:?}"
20259 );
20260 assert_eq!(
20261 inlines[2].text.as_deref().unwrap(),
20262 "2. Honouring existing commitments"
20263 );
20264 }
20265
20266 #[test]
20267 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
20268 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20270 {"type":"listItem","content":[{"type":"paragraph","content":[
20271 {"type":"text","text":"Introduction "},
20272 {"type":"hardBreak"},
20273 {"type":"text","text":"3. Third point"}
20274 ]}]}
20275 ]}]}"#;
20276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20277 let md = adf_to_markdown(&doc).unwrap();
20278 let rt = markdown_to_adf(&md).unwrap();
20279
20280 let list = &rt.content[0];
20281 assert_eq!(list.node_type, "orderedList");
20282 let items = list.content.as_ref().unwrap();
20283 assert_eq!(items.len(), 1);
20284
20285 let para = &items[0].content.as_ref().unwrap()[0];
20286 let inlines = para.content.as_ref().unwrap();
20287 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20288 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20289 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
20290 }
20291
20292 #[test]
20293 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
20294 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20296 {"type":"listItem","content":[{"type":"paragraph","content":[
20297 {"type":"text","text":"Header "},
20298 {"type":"hardBreak"},
20299 {"type":"text","text":"- not a sub-item"}
20300 ]}]}
20301 ]}]}"#;
20302 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20303 let md = adf_to_markdown(&doc).unwrap();
20304 let rt = markdown_to_adf(&md).unwrap();
20305
20306 let list = &rt.content[0];
20307 assert_eq!(list.node_type, "bulletList");
20308 let items = list.content.as_ref().unwrap();
20309 assert_eq!(
20310 items.len(),
20311 1,
20312 "Should be one list item, not {}",
20313 items.len()
20314 );
20315
20316 let para = &items[0].content.as_ref().unwrap()[0];
20317 let inlines = para.content.as_ref().unwrap();
20318 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20319 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20320 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
20321 }
20322
20323 #[test]
20324 fn hardbreak_continuation_followed_by_sub_list() {
20325 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20327 {"type":"listItem","content":[
20328 {"type":"paragraph","content":[
20329 {"type":"text","text":"Main item "},
20330 {"type":"hardBreak"},
20331 {"type":"text","text":"continued here"}
20332 ]},
20333 {"type":"bulletList","content":[
20334 {"type":"listItem","content":[{"type":"paragraph","content":[
20335 {"type":"text","text":"sub-item"}
20336 ]}]}
20337 ]}
20338 ]}
20339 ]}]}"#;
20340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20341 let md = adf_to_markdown(&doc).unwrap();
20342 let rt = markdown_to_adf(&md).unwrap();
20343
20344 let list = &rt.content[0];
20345 let items = list.content.as_ref().unwrap();
20346 assert_eq!(items.len(), 1);
20347
20348 let item_content = items[0].content.as_ref().unwrap();
20349 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
20350 assert_eq!(item_content[0].node_type, "paragraph");
20351 assert_eq!(item_content[1].node_type, "bulletList");
20352
20353 let inlines = item_content[0].content.as_ref().unwrap();
20355 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20356 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20357 }
20358
20359 #[test]
20360 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
20361 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20363 {"type":"listItem","content":[{"type":"paragraph","content":[
20364 {"type":"text","text":"Preamble "},
20365 {"type":"hardBreak"},
20366 {"type":"text","text":"1. Alpha "},
20367 {"type":"hardBreak"},
20368 {"type":"text","text":"2. Bravo"}
20369 ]}]}
20370 ]}]}"#;
20371 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20372 let md = adf_to_markdown(&doc).unwrap();
20373 let rt = markdown_to_adf(&md).unwrap();
20374
20375 let items = rt.content[0].content.as_ref().unwrap();
20376 assert_eq!(items.len(), 1);
20377
20378 let inlines = items[0].content.as_ref().unwrap()[0]
20379 .content
20380 .as_ref()
20381 .unwrap();
20382 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20383 assert_eq!(
20384 types,
20385 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20386 );
20387 }
20388
20389 #[test]
20390 fn trailing_hardbreak_in_bullet_item_roundtrips() {
20391 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20395 {"type":"listItem","content":[{"type":"paragraph","content":[
20396 {"type":"text","text":"ends with break"},
20397 {"type":"hardBreak"}
20398 ]}]}
20399 ]}]}"#;
20400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20401 let md = adf_to_markdown(&doc).unwrap();
20402 let rt = markdown_to_adf(&md).unwrap();
20403
20404 let list = &rt.content[0];
20405 assert_eq!(list.node_type, "bulletList");
20406 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
20407 .content
20408 .as_ref()
20409 .unwrap();
20410 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20411 assert_eq!(types, vec!["text", "hardBreak"]);
20412 }
20413
20414 #[test]
20415 fn trailing_hardbreak_in_ordered_item_roundtrips() {
20416 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20419 {"type":"listItem","content":[{"type":"paragraph","content":[
20420 {"type":"text","text":"ends with break"},
20421 {"type":"hardBreak"}
20422 ]}]}
20423 ]}]}"#;
20424 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20425 let md = adf_to_markdown(&doc).unwrap();
20426 let rt = markdown_to_adf(&md).unwrap();
20427
20428 let list = &rt.content[0];
20429 assert_eq!(list.node_type, "orderedList");
20430 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
20431 .content
20432 .as_ref()
20433 .unwrap();
20434 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20435 assert_eq!(types, vec!["text", "hardBreak"]);
20436 }
20437
20438 #[test]
20439 fn trailing_space_hardbreak_continuation_in_bullet_item() {
20440 let md = "- first line \n 2. continued\n";
20444 let doc = markdown_to_adf(md).unwrap();
20445
20446 let list = &doc.content[0];
20447 assert_eq!(list.node_type, "bulletList");
20448 let items = list.content.as_ref().unwrap();
20449 assert_eq!(
20450 items.len(),
20451 1,
20452 "Should be one list item, got {}",
20453 items.len()
20454 );
20455
20456 let para = &items[0].content.as_ref().unwrap()[0];
20457 let inlines = para.content.as_ref().unwrap();
20458 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20459 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20460 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
20461 }
20462
20463 #[test]
20464 fn trailing_space_hardbreak_continuation_in_ordered_item() {
20465 let md = "1. first line \n - continued\n";
20468 let doc = markdown_to_adf(md).unwrap();
20469
20470 let list = &doc.content[0];
20471 assert_eq!(list.node_type, "orderedList");
20472 let items = list.content.as_ref().unwrap();
20473 assert_eq!(
20474 items.len(),
20475 1,
20476 "Should be one list item, got {}",
20477 items.len()
20478 );
20479
20480 let para = &items[0].content.as_ref().unwrap()[0];
20481 let inlines = para.content.as_ref().unwrap();
20482 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20483 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20484 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
20485 }
20486
20487 #[test]
20488 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
20489 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20492 {"type":"listItem","content":[
20493 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
20494 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
20495 ]}
20496 ]}]}"#;
20497 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20498 let md = adf_to_markdown(&doc).unwrap();
20499 let rt = markdown_to_adf(&md).unwrap();
20500
20501 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
20502 let list = &rt.content[0];
20503 assert_eq!(list.node_type, "bulletList");
20504 let items = list.content.as_ref().unwrap();
20505 assert_eq!(items.len(), 1);
20506 let item_content = items[0].content.as_ref().unwrap();
20507 assert_eq!(
20508 item_content.len(),
20509 2,
20510 "Expected 2 paragraphs inside the list item, got {}",
20511 item_content.len()
20512 );
20513 assert_eq!(item_content[0].node_type, "paragraph");
20514 assert_eq!(item_content[1].node_type, "paragraph");
20515 let text = item_content[1].content.as_ref().unwrap()[0]
20516 .text
20517 .as_deref()
20518 .unwrap();
20519 assert_eq!(text, "2. Honouring existing commitments");
20520 }
20521
20522 #[test]
20523 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
20524 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20526 {"type":"listItem","content":[
20527 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
20528 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
20529 ]}
20530 ]}]}"#;
20531 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20532 let md = adf_to_markdown(&doc).unwrap();
20533 let rt = markdown_to_adf(&md).unwrap();
20534
20535 let items = rt.content[0].content.as_ref().unwrap();
20536 assert_eq!(items.len(), 1);
20537 let item_content = items[0].content.as_ref().unwrap();
20538 assert_eq!(item_content.len(), 2);
20539 assert_eq!(item_content[1].node_type, "paragraph");
20540 let text = item_content[1].content.as_ref().unwrap()[0]
20541 .text
20542 .as_deref()
20543 .unwrap();
20544 assert_eq!(text, "- not a sub-item");
20545 }
20546
20547 #[test]
20548 fn backslash_escape_in_inline_text() {
20549 let nodes = parse_inline(r"2\. text");
20551 assert_eq!(nodes.len(), 1, "Should be one text node");
20552 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
20553 }
20554
20555 #[test]
20556 fn escape_list_marker_ordered() {
20557 assert_eq!(escape_list_marker("2. text"), r"2\. text");
20558 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
20559 }
20560
20561 #[test]
20562 fn escape_list_marker_bullet() {
20563 assert_eq!(escape_list_marker("- text"), r"\- text");
20564 assert_eq!(escape_list_marker("* text"), r"\* text");
20565 assert_eq!(escape_list_marker("+ text"), r"\+ text");
20566 }
20567
20568 #[test]
20569 fn escape_list_marker_plain() {
20570 assert_eq!(escape_list_marker("plain text"), "plain text");
20571 assert_eq!(escape_list_marker("no. marker"), "no. marker");
20572 }
20573
20574 #[test]
20575 fn escape_emoji_shortcodes_basic() {
20576 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
20577 assert_eq!(
20578 escape_emoji_shortcodes("hello :wave: world"),
20579 r"hello \:wave: world"
20580 );
20581 }
20582
20583 #[test]
20584 fn escape_emoji_shortcodes_double_colon() {
20585 assert_eq!(
20587 escape_emoji_shortcodes("Status::Active::Running"),
20588 r"Status:\:Active::Running"
20589 );
20590 }
20591
20592 #[test]
20593 fn escape_emoji_shortcodes_no_match() {
20594 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
20596 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
20597 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
20598 assert_eq!(escape_emoji_shortcodes(":"), ":");
20599 }
20600
20601 #[test]
20602 fn escape_emoji_shortcodes_mixed() {
20603 assert_eq!(
20604 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
20605 r"Alert \:fire: on pod:pod42"
20606 );
20607 }
20608
20609 #[test]
20610 fn escape_emoji_shortcodes_unicode() {
20611 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
20616 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
20617 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
20618 assert_eq!(
20619 escape_emoji_shortcodes("ZBC::配置::Production"),
20620 r"ZBC:\:配置::Production"
20621 );
20622 }
20623
20624 #[test]
20625 fn escape_emoji_shortcodes_mixed_script_name() {
20626 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
20629 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
20630 }
20631
20632 #[test]
20633 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
20634 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
20639 }
20640
20641 #[test]
20642 fn escape_emoji_shortcodes_name_runs_to_end() {
20643 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
20649 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
20650 }
20651
20652 #[test]
20653 fn unicode_shortcode_pattern_text_round_trips_as_text() {
20654 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20657 {"type":"text","text":"Visit :Café: today"}
20658 ]}]}"#;
20659 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20660
20661 let md = adf_to_markdown(&doc).unwrap();
20662 let round_tripped = markdown_to_adf(&md).unwrap();
20663 let content = round_tripped.content[0].content.as_ref().unwrap();
20664
20665 assert_eq!(
20666 content.len(),
20667 1,
20668 "should be a single text node, got: {content:?}"
20669 );
20670 assert_eq!(content[0].node_type, "text");
20671 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
20672 }
20673
20674 #[test]
20675 fn unicode_double_colon_pattern_text_round_trips() {
20676 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20678 {"type":"text","text":"Use ZBC::配置::Production for prod"}
20679 ]}]}"#;
20680 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20681
20682 let md = adf_to_markdown(&doc).unwrap();
20683 let round_tripped = markdown_to_adf(&md).unwrap();
20684 let content = round_tripped.content[0].content.as_ref().unwrap();
20685
20686 assert_eq!(
20687 content.len(),
20688 1,
20689 "should be a single text node, got: {content:?}"
20690 );
20691 assert_eq!(
20692 content[0].text.as_deref().unwrap(),
20693 "Use ZBC::配置::Production for prod"
20694 );
20695 }
20696
20697 #[test]
20698 fn merge_adjacent_text_nodes() {
20699 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
20700 merge_adjacent_text(&mut nodes);
20701 assert_eq!(nodes.len(), 1);
20702 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
20703 }
20704
20705 #[test]
20708 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
20709 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20712 {"type":"text","text":"Introduction: "},
20713 {"type":"hardBreak"},
20714 {"type":"text","text":"1. This text follows a hardBreak"}
20715 ]}]}"#;
20716 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20717 let md = adf_to_markdown(&doc).unwrap();
20718 let rt = markdown_to_adf(&md).unwrap();
20719
20720 assert_eq!(rt.content.len(), 1, "Should remain one block");
20721 assert_eq!(rt.content[0].node_type, "paragraph");
20722 let inlines = rt.content[0].content.as_ref().unwrap();
20723 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20724 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20725 assert_eq!(
20726 inlines[2].text.as_deref(),
20727 Some("1. This text follows a hardBreak")
20728 );
20729 }
20730
20731 #[test]
20732 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
20733 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20735 {"type":"text","text":"Intro"},
20736 {"type":"hardBreak"},
20737 {"type":"text","text":"- not a list item"}
20738 ]}]}"#;
20739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20740 let md = adf_to_markdown(&doc).unwrap();
20741 let rt = markdown_to_adf(&md).unwrap();
20742
20743 assert_eq!(rt.content.len(), 1);
20744 assert_eq!(rt.content[0].node_type, "paragraph");
20745 let inlines = rt.content[0].content.as_ref().unwrap();
20746 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20747 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20748 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
20749 }
20750
20751 #[test]
20752 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
20753 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20755 {"type":"text","text":"Intro"},
20756 {"type":"hardBreak"},
20757 {"type":"text","text":"# not a heading"}
20758 ]}]}"##;
20759 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20760 let md = adf_to_markdown(&doc).unwrap();
20761 let rt = markdown_to_adf(&md).unwrap();
20762
20763 assert_eq!(rt.content.len(), 1);
20764 assert_eq!(rt.content[0].node_type, "paragraph");
20765 let inlines = rt.content[0].content.as_ref().unwrap();
20766 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20767 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20768 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
20769 }
20770
20771 #[test]
20772 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
20773 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20775 {"type":"text","text":"Intro"},
20776 {"type":"hardBreak"},
20777 {"type":"text","text":"> not a blockquote"}
20778 ]}]}"#;
20779 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20780 let md = adf_to_markdown(&doc).unwrap();
20781 let rt = markdown_to_adf(&md).unwrap();
20782
20783 assert_eq!(rt.content.len(), 1);
20784 assert_eq!(rt.content[0].node_type, "paragraph");
20785 let inlines = rt.content[0].content.as_ref().unwrap();
20786 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20787 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20788 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
20789 }
20790
20791 #[test]
20792 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
20793 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20795 {"type":"text","text":"Preamble"},
20796 {"type":"hardBreak"},
20797 {"type":"text","text":"1. First"},
20798 {"type":"hardBreak"},
20799 {"type":"text","text":"2. Second"},
20800 {"type":"hardBreak"},
20801 {"type":"text","text":"3. Third"}
20802 ]}]}"#;
20803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20804 let md = adf_to_markdown(&doc).unwrap();
20805 let rt = markdown_to_adf(&md).unwrap();
20806
20807 assert_eq!(rt.content.len(), 1);
20808 assert_eq!(rt.content[0].node_type, "paragraph");
20809 let inlines = rt.content[0].content.as_ref().unwrap();
20810 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20811 assert_eq!(
20812 types,
20813 vec![
20814 "text",
20815 "hardBreak",
20816 "text",
20817 "hardBreak",
20818 "text",
20819 "hardBreak",
20820 "text"
20821 ]
20822 );
20823 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
20824 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
20825 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
20826 }
20827
20828 #[test]
20829 fn issue_455_paragraph_hardbreak_jfm_indentation() {
20830 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20832 {"type":"text","text":"Intro"},
20833 {"type":"hardBreak"},
20834 {"type":"text","text":"1. continued"}
20835 ]}]}"#;
20836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20837 let md = adf_to_markdown(&doc).unwrap();
20838 assert!(
20839 md.contains("Intro\\\n 1. continued"),
20840 "Continuation should be 2-space-indented, got: {md:?}"
20841 );
20842 }
20843
20844 #[test]
20845 fn issue_455_paragraph_hardbreak_from_jfm() {
20846 let md = "Intro\\\n 1. This is continuation text\n";
20849 let doc = markdown_to_adf(md).unwrap();
20850
20851 assert_eq!(doc.content.len(), 1);
20852 assert_eq!(doc.content[0].node_type, "paragraph");
20853 let inlines = doc.content[0].content.as_ref().unwrap();
20854 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20855 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20856 assert_eq!(
20857 inlines[2].text.as_deref(),
20858 Some("1. This is continuation text")
20859 );
20860 }
20861
20862 #[test]
20863 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
20864 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20868 {"type":"text","text":"1. Starting with a number"},
20869 {"type":"hardBreak"},
20870 {"type":"text","text":"continuation after break"}
20871 ]}]}"#;
20872 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20873 let md = adf_to_markdown(&doc).unwrap();
20874 assert!(
20876 md.contains(r"1\. Starting with a number"),
20877 "First line should have escaped list marker, got: {md:?}"
20878 );
20879 let rt = markdown_to_adf(&md).unwrap();
20880
20881 assert_eq!(rt.content.len(), 1);
20882 assert_eq!(rt.content[0].node_type, "paragraph");
20883 let inlines = rt.content[0].content.as_ref().unwrap();
20884 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20885 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20886 assert_eq!(
20887 inlines[0].text.as_deref(),
20888 Some("1. Starting with a number")
20889 );
20890 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
20891 }
20892
20893 #[test]
20894 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
20895 let adf_json = r#"{"version":1,"type":"doc","content":[{
20898 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
20899 "content":[{"type":"tableRow","content":[{
20900 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
20901 "content":[{"type":"paragraph","content":[
20902 {"type":"text","text":"2. Honouring existing commitments"}
20903 ]}]
20904 }]}]
20905 }]}"#;
20906 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20907 let md = adf_to_markdown(&doc).unwrap();
20908 let rt = markdown_to_adf(&md).unwrap();
20909
20910 let table = &rt.content[0];
20911 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
20912 let para = &cell.content.as_ref().unwrap()[0];
20913 assert_eq!(para.node_type, "paragraph");
20914 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
20915 assert_eq!(text, "2. Honouring existing commitments");
20916 }
20917
20918 #[test]
20919 fn bullet_marker_paragraph_standalone_roundtrips() {
20920 let adf_json = r#"{"version":1,"type":"doc","content":[
20923 {"type":"paragraph","content":[
20924 {"type":"text","text":"- not a list item"}
20925 ]}
20926 ]}"#;
20927 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20928 let md = adf_to_markdown(&doc).unwrap();
20929 assert!(
20930 md.contains(r"\- not a list item"),
20931 "Should escape the leading dash, got:\n{md}"
20932 );
20933 let rt = markdown_to_adf(&md).unwrap();
20934 assert_eq!(rt.content[0].node_type, "paragraph");
20935 let text = rt.content[0].content.as_ref().unwrap()[0]
20936 .text
20937 .as_deref()
20938 .unwrap();
20939 assert_eq!(text, "- not a list item");
20940 }
20941
20942 #[test]
20943 fn merge_adjacent_text_skips_non_text_nodes() {
20944 let mut nodes = vec![
20947 AdfNode::text("a"),
20948 AdfNode::hard_break(),
20949 AdfNode::text("b"),
20950 ];
20951 merge_adjacent_text(&mut nodes);
20952 assert_eq!(nodes.len(), 3);
20953 }
20954
20955 #[test]
20956 fn star_bullet_paragraph_roundtrips() {
20957 let adf_json = r#"{"version":1,"type":"doc","content":[
20960 {"type":"paragraph","content":[
20961 {"type":"text","text":"* starred"}
20962 ]}
20963 ]}"#;
20964 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20965 let md = adf_to_markdown(&doc).unwrap();
20966 let rt = markdown_to_adf(&md).unwrap();
20967 assert_eq!(rt.content[0].node_type, "paragraph");
20968 assert_eq!(
20969 rt.content[0].content.as_ref().unwrap()[0]
20970 .text
20971 .as_deref()
20972 .unwrap(),
20973 "* starred"
20974 );
20975 }
20976
20977 #[test]
20980 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
20981 let adf_json = r#"{"version":1,"type":"doc","content":[
20984 {"type":"orderedList","attrs":{"order":1},"content":[
20985 {"type":"listItem","content":[
20986 {"type":"paragraph","content":[
20987 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
20988 {"type":"hardBreak"},
20989 {"type":"text","text":"Content after break"}
20990 ]}
20991 ]},
20992 {"type":"listItem","content":[
20993 {"type":"paragraph","content":[
20994 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
20995 {"type":"hardBreak"},
20996 {"type":"text","text":"More content"}
20997 ]}
20998 ]}
20999 ]}
21000 ]}"#;
21001 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21002 let md = adf_to_markdown(&doc).unwrap();
21003 let rt = markdown_to_adf(&md).unwrap();
21004
21005 assert_eq!(
21007 rt.content.len(),
21008 1,
21009 "Should be 1 block (orderedList), got {}",
21010 rt.content.len()
21011 );
21012 assert_eq!(rt.content[0].node_type, "orderedList");
21013 let items = rt.content[0].content.as_ref().unwrap();
21014 assert_eq!(
21015 items.len(),
21016 2,
21017 "Should have 2 listItems, got {}",
21018 items.len()
21019 );
21020
21021 let p1 = items[0].content.as_ref().unwrap()[0]
21023 .content
21024 .as_ref()
21025 .unwrap();
21026 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21027 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
21028 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
21029 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
21030
21031 let p2 = items[1].content.as_ref().unwrap()[0]
21033 .content
21034 .as_ref()
21035 .unwrap();
21036 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
21037 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
21038 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
21039 assert_eq!(p2[2].text.as_deref(), Some("More content"));
21040 }
21041
21042 #[test]
21043 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
21044 let adf_json = r#"{"version":1,"type":"doc","content":[
21046 {"type":"bulletList","content":[
21047 {"type":"listItem","content":[
21048 {"type":"paragraph","content":[
21049 {"type":"text","text":"First","marks":[{"type":"strong"}]},
21050 {"type":"hardBreak"},
21051 {"type":"text","text":"details"}
21052 ]}
21053 ]},
21054 {"type":"listItem","content":[
21055 {"type":"paragraph","content":[
21056 {"type":"text","text":"Second","marks":[{"type":"em"}]},
21057 {"type":"hardBreak"},
21058 {"type":"text","text":"more details"}
21059 ]}
21060 ]}
21061 ]}
21062 ]}"#;
21063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21064 let md = adf_to_markdown(&doc).unwrap();
21065 let rt = markdown_to_adf(&md).unwrap();
21066
21067 assert_eq!(rt.content.len(), 1);
21068 assert_eq!(rt.content[0].node_type, "bulletList");
21069 let items = rt.content[0].content.as_ref().unwrap();
21070 assert_eq!(items.len(), 2);
21071
21072 let p1 = items[0].content.as_ref().unwrap()[0]
21073 .content
21074 .as_ref()
21075 .unwrap();
21076 assert_eq!(p1[0].text.as_deref(), Some("First"));
21077 assert_eq!(p1[2].text.as_deref(), Some("details"));
21078
21079 let p2 = items[1].content.as_ref().unwrap()[0]
21080 .content
21081 .as_ref()
21082 .unwrap();
21083 assert_eq!(p2[0].text.as_deref(), Some("Second"));
21084 assert_eq!(p2[2].text.as_deref(), Some("more details"));
21085 }
21086
21087 #[test]
21088 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
21089 let adf_json = r#"{"version":1,"type":"doc","content":[
21091 {"type":"orderedList","attrs":{"order":1},"content":[
21092 {"type":"listItem","content":[
21093 {"type":"paragraph","content":[
21094 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
21095 {"type":"hardBreak"},
21096 {"type":"text","text":"body"}
21097 ]}
21098 ]}
21099 ]}
21100 ]}"#;
21101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21102 let md = adf_to_markdown(&doc).unwrap();
21103 assert!(
21104 md.contains("1. **heading**\\\n body"),
21105 "Continuation should be indented, got:\n{md}"
21106 );
21107 }
21108
21109 #[test]
21110 fn issue_388_ordered_list_hardbreak_from_jfm() {
21111 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
21113 let doc = markdown_to_adf(md).unwrap();
21114
21115 assert_eq!(doc.content.len(), 1);
21116 assert_eq!(doc.content[0].node_type, "orderedList");
21117 let items = doc.content[0].content.as_ref().unwrap();
21118 assert_eq!(items.len(), 2);
21119
21120 let p1 = items[0].content.as_ref().unwrap()[0]
21121 .content
21122 .as_ref()
21123 .unwrap();
21124 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21125 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
21126 assert_eq!(p1[0].text.as_deref(), Some("bold"));
21127 assert_eq!(p1[2].text.as_deref(), Some("continued"));
21128
21129 let p2 = items[1].content.as_ref().unwrap()[0]
21130 .content
21131 .as_ref()
21132 .unwrap();
21133 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
21134 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
21135 }
21136
21137 #[test]
21138 fn issue_388_bullet_list_hardbreak_from_jfm() {
21139 let md = "- first\\\n second\n- third\\\n fourth\n";
21141 let doc = markdown_to_adf(md).unwrap();
21142
21143 assert_eq!(doc.content.len(), 1);
21144 assert_eq!(doc.content[0].node_type, "bulletList");
21145 let items = doc.content[0].content.as_ref().unwrap();
21146 assert_eq!(items.len(), 2);
21147
21148 for (i, expected) in [("first", "second"), ("third", "fourth")]
21149 .iter()
21150 .enumerate()
21151 {
21152 let p = items[i].content.as_ref().unwrap()[0]
21153 .content
21154 .as_ref()
21155 .unwrap();
21156 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
21157 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21158 assert_eq!(p[0].text.as_deref(), Some(expected.0));
21159 assert_eq!(p[2].text.as_deref(), Some(expected.1));
21160 }
21161 }
21162
21163 #[test]
21164 fn issue_433_heading_hardbreak_roundtrips() {
21165 let adf_json = r#"{"version":1,"type":"doc","content":[{
21167 "type":"heading",
21168 "attrs":{"level":1},
21169 "content":[
21170 {"type":"text","text":"Line one"},
21171 {"type":"hardBreak"},
21172 {"type":"text","text":"Line two"}
21173 ]
21174 }]}"#;
21175 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21176 let md = adf_to_markdown(&doc).unwrap();
21177 let rt = markdown_to_adf(&md).unwrap();
21178
21179 assert_eq!(
21180 rt.content.len(),
21181 1,
21182 "Should remain a single heading, got {} blocks",
21183 rt.content.len()
21184 );
21185 assert_eq!(rt.content[0].node_type, "heading");
21186 let inlines = rt.content[0].content.as_ref().unwrap();
21187 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21188 assert_eq!(
21189 types,
21190 vec!["text", "hardBreak", "text"],
21191 "hardBreak should be preserved, got: {types:?}"
21192 );
21193 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
21194 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
21195 }
21196
21197 #[test]
21198 fn issue_433_heading_hardbreak_jfm_indentation() {
21199 let adf_json = r#"{"version":1,"type":"doc","content":[{
21201 "type":"heading",
21202 "attrs":{"level":2},
21203 "content":[
21204 {"type":"text","text":"Title"},
21205 {"type":"hardBreak"},
21206 {"type":"text","text":"Subtitle"}
21207 ]
21208 }]}"#;
21209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21210 let md = adf_to_markdown(&doc).unwrap();
21211 assert!(
21212 md.contains("## Title\\\n Subtitle"),
21213 "Continuation should be indented, got:\n{md}"
21214 );
21215 }
21216
21217 #[test]
21218 fn issue_433_heading_hardbreak_from_jfm() {
21219 let md = "# First\\\n Second\n";
21221 let doc = markdown_to_adf(md).unwrap();
21222
21223 assert_eq!(doc.content.len(), 1);
21224 assert_eq!(doc.content[0].node_type, "heading");
21225 let inlines = doc.content[0].content.as_ref().unwrap();
21226 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21227 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21228 assert_eq!(inlines[0].text.as_deref(), Some("First"));
21229 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
21230 }
21231
21232 #[test]
21233 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
21234 let adf_json = r#"{"version":1,"type":"doc","content":[{
21236 "type":"heading",
21237 "attrs":{"level":3},
21238 "content":[
21239 {"type":"text","text":"A"},
21240 {"type":"hardBreak"},
21241 {"type":"hardBreak"},
21242 {"type":"text","text":"B"}
21243 ]
21244 }]}"#;
21245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21246 let md = adf_to_markdown(&doc).unwrap();
21247 let rt = markdown_to_adf(&md).unwrap();
21248
21249 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
21250 assert_eq!(rt.content[0].node_type, "heading");
21251 let inlines = rt.content[0].content.as_ref().unwrap();
21252 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21253 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
21254 }
21255
21256 #[test]
21257 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
21258 let adf_json = r#"{"version":1,"type":"doc","content":[{
21260 "type":"heading",
21261 "attrs":{"level":1},
21262 "content":[
21263 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
21264 {"type":"hardBreak"},
21265 {"type":"text","text":"plain continuation"}
21266 ]
21267 }]}"#;
21268 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21269 let md = adf_to_markdown(&doc).unwrap();
21270 let rt = markdown_to_adf(&md).unwrap();
21271
21272 assert_eq!(rt.content.len(), 1);
21273 assert_eq!(rt.content[0].node_type, "heading");
21274 let inlines = rt.content[0].content.as_ref().unwrap();
21275 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21276 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21277 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
21278 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
21279 }
21280
21281 #[test]
21282 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
21283 let adf_json = r#"{"version":1,"type":"doc","content":[{
21285 "type":"heading",
21286 "attrs":{"level":1},
21287 "content":[
21288 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
21289 {"type":"hardBreak"},
21290 {"type":"text","text":"Subtitle text"}
21291 ]
21292 }]}"#;
21293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21294 let md = adf_to_markdown(&doc).unwrap();
21295 let rt = markdown_to_adf(&md).unwrap();
21296
21297 assert_eq!(rt.content.len(), 1);
21298 assert_eq!(rt.content[0].node_type, "heading");
21299 let inlines = rt.content[0].content.as_ref().unwrap();
21300 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21301 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21302 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
21303 }
21304
21305 #[test]
21306 fn has_trailing_hard_break_backslash() {
21307 assert!(has_trailing_hard_break("text\\"));
21308 assert!(has_trailing_hard_break("**bold**\\"));
21309 }
21310
21311 #[test]
21312 fn has_trailing_hard_break_trailing_spaces() {
21313 assert!(has_trailing_hard_break("text "));
21314 assert!(has_trailing_hard_break("word "));
21315 }
21316
21317 #[test]
21318 fn has_trailing_hard_break_false() {
21319 assert!(!has_trailing_hard_break("plain text"));
21320 assert!(!has_trailing_hard_break("text "));
21321 assert!(!has_trailing_hard_break(""));
21322 }
21323
21324 #[test]
21325 fn collect_hardbreak_continuations_collects_indented() {
21326 let input = "first\\\n second\n third\n";
21329 let mut parser = MarkdownParser::new(input);
21330 parser.advance(); let mut text = "first\\".to_string();
21332 parser.collect_hardbreak_continuations(&mut text);
21333 assert_eq!(text, "first\\\nsecond");
21334 }
21335
21336 #[test]
21337 fn collect_hardbreak_continuations_stops_at_non_indented() {
21338 let input = "first\\\nnot indented\n";
21339 let mut parser = MarkdownParser::new(input);
21340 parser.advance();
21341 let mut text = "first\\".to_string();
21342 parser.collect_hardbreak_continuations(&mut text);
21343 assert_eq!(text, "first\\");
21345 }
21346
21347 #[test]
21348 fn collect_hardbreak_continuations_no_trailing_break() {
21349 let input = "plain\n indented\n";
21351 let mut parser = MarkdownParser::new(input);
21352 parser.advance();
21353 let mut text = "plain".to_string();
21354 parser.collect_hardbreak_continuations(&mut text);
21355 assert_eq!(text, "plain");
21356 }
21357
21358 #[test]
21359 fn collect_hardbreak_continuations_chained() {
21360 let input = "a\\\n b\\\n c\\\n d\n";
21362 let mut parser = MarkdownParser::new(input);
21363 parser.advance();
21364 let mut text = "a\\".to_string();
21365 parser.collect_hardbreak_continuations(&mut text);
21366 assert_eq!(text, "a\\\nb\\\nc\\\nd");
21367 }
21368
21369 #[test]
21370 fn collect_hardbreak_continuations_stops_before_image_line() {
21371 let input = "text\\\n {type=file id=x}\n";
21374 let mut parser = MarkdownParser::new(input);
21375 parser.advance(); let mut text = "text\\".to_string();
21377 parser.collect_hardbreak_continuations(&mut text);
21378 assert_eq!(text, "text\\");
21380 assert!(!parser.at_end());
21382 assert!(parser.current_line().contains(""));
21383 }
21384
21385 #[test]
21386 fn is_block_level_continuation_marker_positive_cases() {
21387 assert!(is_block_level_continuation_marker(""));
21389 assert!(is_block_level_continuation_marker("```ruby"));
21390 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
21391 }
21392
21393 #[test]
21394 fn is_block_level_continuation_marker_negative_cases() {
21395 assert!(!is_block_level_continuation_marker("plain text"));
21397 assert!(!is_block_level_continuation_marker("- nested item"));
21398 assert!(!is_block_level_continuation_marker("continuation\\"));
21399 assert!(!is_block_level_continuation_marker(""));
21400 assert!(!is_block_level_continuation_marker("::partial"));
21402 assert!(!is_block_level_continuation_marker("`inline`"));
21404 }
21405
21406 #[test]
21407 fn collect_hardbreak_continuations_stops_before_code_fence() {
21408 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
21412 let mut parser = MarkdownParser::new(input);
21413 parser.advance();
21414 let mut text = "text\\".to_string();
21415 parser.collect_hardbreak_continuations(&mut text);
21416 assert_eq!(text, "text\\");
21417 assert!(!parser.at_end());
21418 assert!(parser.current_line().starts_with(" ```"));
21419 }
21420
21421 #[test]
21422 fn collect_hardbreak_continuations_stops_before_container_directive() {
21423 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
21427 let mut parser = MarkdownParser::new(input);
21428 parser.advance();
21429 let mut text = "text\\".to_string();
21430 parser.collect_hardbreak_continuations(&mut text);
21431 assert_eq!(text, "text\\");
21432 assert!(!parser.at_end());
21433 assert!(parser.current_line().contains(":::panel"));
21434 }
21435
21436 #[test]
21437 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
21438 let input = "text\\\n ```text\n :fire:\n ```\n";
21442 let mut parser = MarkdownParser::new(input);
21443 parser.advance();
21444 let mut text = "text\\".to_string();
21445 parser.collect_hardbreak_continuations(&mut text);
21446 assert_eq!(text, "text\\");
21447 assert!(!parser.at_end());
21448 assert!(parser.current_line().contains("```text"));
21449 }
21450
21451 #[test]
21452 fn ordered_list_with_sub_content_after_hardbreak() {
21453 let adf_json = r#"{"version":1,"type":"doc","content":[
21456 {"type":"orderedList","attrs":{"order":1},"content":[
21457 {"type":"listItem","content":[
21458 {"type":"paragraph","content":[
21459 {"type":"text","text":"parent"},
21460 {"type":"hardBreak"},
21461 {"type":"text","text":"continued"}
21462 ]},
21463 {"type":"bulletList","content":[
21464 {"type":"listItem","content":[
21465 {"type":"paragraph","content":[
21466 {"type":"text","text":"child"}
21467 ]}
21468 ]}
21469 ]}
21470 ]}
21471 ]}
21472 ]}"#;
21473 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21474 let md = adf_to_markdown(&doc).unwrap();
21475 let rt = markdown_to_adf(&md).unwrap();
21476
21477 assert_eq!(rt.content.len(), 1);
21478 assert_eq!(rt.content[0].node_type, "orderedList");
21479 let item_content = rt.content[0].content.as_ref().unwrap()[0]
21480 .content
21481 .as_ref()
21482 .unwrap();
21483 let p = item_content[0].content.as_ref().unwrap();
21485 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
21486 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21487 assert_eq!(p[0].text.as_deref(), Some("parent"));
21488 assert_eq!(p[2].text.as_deref(), Some("continued"));
21489 assert_eq!(item_content[1].node_type, "bulletList");
21491 }
21492
21493 #[test]
21494 fn render_list_item_content_no_content() {
21495 let item = AdfNode {
21497 node_type: "listItem".to_string(),
21498 attrs: None,
21499 content: None,
21500 text: None,
21501 marks: None,
21502 local_id: None,
21503 parameters: None,
21504 };
21505 let mut output = String::new();
21506 let opts = RenderOptions::default();
21507 render_list_item_content(&item, &mut output, &opts);
21508 assert_eq!(output, "\n");
21509 }
21510
21511 #[test]
21512 fn render_list_item_content_empty_content() {
21513 let item = AdfNode::list_item(vec![]);
21515 let mut output = String::new();
21516 let opts = RenderOptions::default();
21517 render_list_item_content(&item, &mut output, &opts);
21518 assert_eq!(output, "\n");
21519 }
21520
21521 #[test]
21522 fn plus_bullet_paragraph_roundtrips() {
21523 let adf_json = r#"{"version":1,"type":"doc","content":[
21526 {"type":"paragraph","content":[
21527 {"type":"text","text":"+ plus"}
21528 ]}
21529 ]}"#;
21530 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21531 let md = adf_to_markdown(&doc).unwrap();
21532 let rt = markdown_to_adf(&md).unwrap();
21533 assert_eq!(rt.content[0].node_type, "paragraph");
21534 assert_eq!(
21535 rt.content[0].content.as_ref().unwrap()[0]
21536 .text
21537 .as_deref()
21538 .unwrap(),
21539 "+ plus"
21540 );
21541 }
21542
21543 #[test]
21546 fn issue_430_file_media_in_bullet_list_roundtrip() {
21547 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21550 {"type":"listItem","content":[{
21551 "type":"mediaSingle",
21552 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
21553 "content":[{
21554 "type":"media",
21555 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
21556 }]
21557 }]}
21558 ]}]}"#;
21559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21560 let md = adf_to_markdown(&doc).unwrap();
21561 let rt = markdown_to_adf(&md).unwrap();
21562
21563 let list = &rt.content[0];
21564 assert_eq!(list.node_type, "bulletList");
21565 let item = &list.content.as_ref().unwrap()[0];
21566 assert_eq!(item.node_type, "listItem");
21567 let ms = &item.content.as_ref().unwrap()[0];
21568 assert_eq!(ms.node_type, "mediaSingle");
21569 let ms_attrs = ms.attrs.as_ref().unwrap();
21570 assert_eq!(ms_attrs["layout"], "center");
21571 assert_eq!(ms_attrs["width"], 1009);
21572 assert_eq!(ms_attrs["widthType"], "pixel");
21573 let media = &ms.content.as_ref().unwrap()[0];
21574 assert_eq!(media.node_type, "media");
21575 let m_attrs = media.attrs.as_ref().unwrap();
21576 assert_eq!(m_attrs["type"], "file");
21577 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
21578 assert_eq!(m_attrs["collection"], "contentId-123");
21579 assert_eq!(m_attrs["height"], 576);
21580 assert_eq!(m_attrs["width"], 1009);
21581 }
21582
21583 #[test]
21584 fn issue_430_file_media_in_ordered_list_roundtrip() {
21585 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21587 {"type":"listItem","content":[{
21588 "type":"mediaSingle",
21589 "attrs":{"layout":"center"},
21590 "content":[{
21591 "type":"media",
21592 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
21593 }]
21594 }]}
21595 ]}]}"#;
21596 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21597 let md = adf_to_markdown(&doc).unwrap();
21598 let rt = markdown_to_adf(&md).unwrap();
21599
21600 let list = &rt.content[0];
21601 assert_eq!(list.node_type, "orderedList");
21602 let item = &list.content.as_ref().unwrap()[0];
21603 assert_eq!(item.node_type, "listItem");
21604 let ms = &item.content.as_ref().unwrap()[0];
21605 assert_eq!(ms.node_type, "mediaSingle");
21606 let media = &ms.content.as_ref().unwrap()[0];
21607 assert_eq!(media.node_type, "media");
21608 let m_attrs = media.attrs.as_ref().unwrap();
21609 assert_eq!(m_attrs["type"], "file");
21610 assert_eq!(m_attrs["id"], "abc-123");
21611 assert_eq!(m_attrs["collection"], "contentId-456");
21612 }
21613
21614 #[test]
21615 fn issue_430_external_media_in_bullet_list_roundtrip() {
21616 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21618 {"type":"listItem","content":[{
21619 "type":"mediaSingle",
21620 "attrs":{"layout":"center"},
21621 "content":[{
21622 "type":"media",
21623 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
21624 }]
21625 }]}
21626 ]}]}"#;
21627 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21628 let md = adf_to_markdown(&doc).unwrap();
21629 let rt = markdown_to_adf(&md).unwrap();
21630
21631 let list = &rt.content[0];
21632 assert_eq!(list.node_type, "bulletList");
21633 let item = &list.content.as_ref().unwrap()[0];
21634 let ms = &item.content.as_ref().unwrap()[0];
21635 assert_eq!(ms.node_type, "mediaSingle");
21636 let media = &ms.content.as_ref().unwrap()[0];
21637 assert_eq!(media.node_type, "media");
21638 let m_attrs = media.attrs.as_ref().unwrap();
21639 assert_eq!(m_attrs["type"], "external");
21640 assert_eq!(m_attrs["url"], "https://example.com/img.png");
21641 }
21642
21643 #[test]
21644 fn issue_430_media_with_paragraph_siblings_in_list_item() {
21645 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21648 {"type":"listItem","content":[
21649 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
21650 {"type":"mediaSingle","attrs":{"layout":"center"},
21651 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
21652 ]}
21653 ]}]}"#;
21654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21655 let md = adf_to_markdown(&doc).unwrap();
21656 let rt = markdown_to_adf(&md).unwrap();
21657
21658 let item = &rt.content[0].content.as_ref().unwrap()[0];
21659 let children = item.content.as_ref().unwrap();
21660 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21661 assert_eq!(children[0].node_type, "paragraph");
21662 assert_eq!(children[1].node_type, "mediaSingle");
21663 let media = &children[1].content.as_ref().unwrap()[0];
21664 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
21665 }
21666
21667 #[test]
21668 fn issue_430_multiple_media_in_list_items() {
21669 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21671 {"type":"listItem","content":[{
21672 "type":"mediaSingle","attrs":{"layout":"center"},
21673 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
21674 }]},
21675 {"type":"listItem","content":[{
21676 "type":"mediaSingle","attrs":{"layout":"center"},
21677 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
21678 }]}
21679 ]}]}"#;
21680 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21681 let md = adf_to_markdown(&doc).unwrap();
21682 let rt = markdown_to_adf(&md).unwrap();
21683
21684 let items = rt.content[0].content.as_ref().unwrap();
21685 assert_eq!(items.len(), 2);
21686 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
21687 let ms = &items[i].content.as_ref().unwrap()[0];
21688 assert_eq!(ms.node_type, "mediaSingle");
21689 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
21690 assert_eq!(m_attrs["id"], expected_id.0);
21691 assert_eq!(m_attrs["collection"], expected_id.1);
21692 }
21693 }
21694
21695 #[test]
21696 fn issue_430_jfm_to_adf_media_in_bullet_item() {
21697 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
21700 let doc = markdown_to_adf(md).unwrap();
21701
21702 let list = &doc.content[0];
21703 assert_eq!(list.node_type, "bulletList");
21704 let item = &list.content.as_ref().unwrap()[0];
21705 let ms = &item.content.as_ref().unwrap()[0];
21706 assert_eq!(
21707 ms.node_type, "mediaSingle",
21708 "expected mediaSingle, got {}",
21709 ms.node_type
21710 );
21711 let media = &ms.content.as_ref().unwrap()[0];
21712 assert_eq!(media.node_type, "media");
21713 let m_attrs = media.attrs.as_ref().unwrap();
21714 assert_eq!(m_attrs["type"], "file");
21715 assert_eq!(m_attrs["id"], "test-id");
21716 }
21717
21718 #[test]
21719 fn issue_430_jfm_to_adf_media_in_ordered_item() {
21720 let md = "1. \n";
21722 let doc = markdown_to_adf(md).unwrap();
21723
21724 let list = &doc.content[0];
21725 assert_eq!(list.node_type, "orderedList");
21726 let item = &list.content.as_ref().unwrap()[0];
21727 let ms = &item.content.as_ref().unwrap()[0];
21728 assert_eq!(
21729 ms.node_type, "mediaSingle",
21730 "expected mediaSingle, got {}",
21731 ms.node_type
21732 );
21733 }
21734
21735 #[test]
21736 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
21737 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21740 {"type":"listItem","content":[
21741 {"type":"mediaSingle","attrs":{"layout":"center"},
21742 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
21743 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
21744 ]}
21745 ]}]}"#;
21746 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21747 let md = adf_to_markdown(&doc).unwrap();
21748 let rt = markdown_to_adf(&md).unwrap();
21749
21750 let item = &rt.content[0].content.as_ref().unwrap()[0];
21751 let children = item.content.as_ref().unwrap();
21752 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21753 assert_eq!(children[0].node_type, "mediaSingle");
21754 let media = &children[0].content.as_ref().unwrap()[0];
21755 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
21756 assert_eq!(children[1].node_type, "paragraph");
21757 }
21758
21759 #[test]
21760 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
21761 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21763 {"type":"listItem","content":[
21764 {"type":"mediaSingle","attrs":{"layout":"center"},
21765 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
21766 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
21767 ]}
21768 ]}]}"#;
21769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21770 let md = adf_to_markdown(&doc).unwrap();
21771 let rt = markdown_to_adf(&md).unwrap();
21772
21773 let item = &rt.content[0].content.as_ref().unwrap()[0];
21774 let children = item.content.as_ref().unwrap();
21775 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21776 assert_eq!(children[0].node_type, "mediaSingle");
21777 assert_eq!(children[1].node_type, "paragraph");
21778 }
21779
21780 #[test]
21781 fn issue_430_external_media_with_width_type_roundtrip() {
21782 let adf_json = r#"{"version":1,"type":"doc","content":[{
21784 "type":"mediaSingle",
21785 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
21786 "content":[{
21787 "type":"media",
21788 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
21789 }]
21790 }]}"#;
21791 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21792 let md = adf_to_markdown(&doc).unwrap();
21793 assert!(
21794 md.contains("widthType=pixel"),
21795 "expected widthType=pixel in markdown, got: {md}"
21796 );
21797 let rt = markdown_to_adf(&md).unwrap();
21798 let ms = &rt.content[0];
21799 assert_eq!(ms.node_type, "mediaSingle");
21800 let ms_attrs = ms.attrs.as_ref().unwrap();
21801 assert_eq!(ms_attrs["widthType"], "pixel");
21802 assert_eq!(ms_attrs["width"], 800);
21803 assert_eq!(ms_attrs["layout"], "wide");
21804 }
21805
21806 #[test]
21809 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
21810 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21813 {"type":"listItem","content":[
21814 {"type":"paragraph","content":[
21815 {"type":"text","text":"Item with image:"},
21816 {"type":"hardBreak"}
21817 ]},
21818 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
21819 "content":[{"type":"media","attrs":{
21820 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
21821 "type":"file",
21822 "collection":"contentId-123456",
21823 "width":800,
21824 "height":600
21825 }}]}
21826 ]}
21827 ]}]}"#;
21828 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21829 let md = adf_to_markdown(&doc).unwrap();
21830 let rt = markdown_to_adf(&md).unwrap();
21831
21832 let item = &rt.content[0].content.as_ref().unwrap()[0];
21833 let children = item.content.as_ref().unwrap();
21834 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21835 assert_eq!(children[0].node_type, "paragraph");
21836 assert_eq!(
21837 children[1].node_type, "mediaSingle",
21838 "expected mediaSingle, got {:?}",
21839 children[1].node_type
21840 );
21841 let media = &children[1].content.as_ref().unwrap()[0];
21842 let m_attrs = media.attrs.as_ref().unwrap();
21843 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
21844 assert_eq!(m_attrs["collection"], "contentId-123456");
21845 assert_eq!(m_attrs["height"], 600);
21846 assert_eq!(m_attrs["width"], 800);
21847 }
21848
21849 #[test]
21850 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
21851 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21853 {"type":"listItem","content":[
21854 {"type":"paragraph","content":[
21855 {"type":"text","text":"Step with screenshot:"},
21856 {"type":"hardBreak"}
21857 ]},
21858 {"type":"mediaSingle","attrs":{"layout":"center"},
21859 "content":[{"type":"media","attrs":{
21860 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
21861 }}]}
21862 ]}
21863 ]}]}"#;
21864 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21865 let md = adf_to_markdown(&doc).unwrap();
21866 let rt = markdown_to_adf(&md).unwrap();
21867
21868 let item = &rt.content[0].content.as_ref().unwrap()[0];
21869 let children = item.content.as_ref().unwrap();
21870 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21871 assert_eq!(children[0].node_type, "paragraph");
21872 assert_eq!(children[1].node_type, "mediaSingle");
21873 let media = &children[1].content.as_ref().unwrap()[0];
21874 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
21875 }
21876
21877 #[test]
21878 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
21879 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
21882 let doc = markdown_to_adf(md).unwrap();
21883
21884 let item = &doc.content[0].content.as_ref().unwrap()[0];
21885 let children = item.content.as_ref().unwrap();
21886 assert_eq!(children.len(), 2, "expected 2 children in listItem");
21887 assert_eq!(children[0].node_type, "paragraph");
21888 assert_eq!(
21889 children[1].node_type, "mediaSingle",
21890 "expected mediaSingle as second child, got {:?}",
21891 children[1].node_type
21892 );
21893 let media = &children[1].content.as_ref().unwrap()[0];
21894 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
21895 }
21896
21897 #[test]
21898 fn issue_490_hardbreak_continuation_still_works_for_text() {
21899 let md = "- first line\\\n second line\n";
21901 let doc = markdown_to_adf(md).unwrap();
21902
21903 let item = &doc.content[0].content.as_ref().unwrap()[0];
21904 let children = item.content.as_ref().unwrap();
21905 assert_eq!(
21906 children.len(),
21907 1,
21908 "expected 1 child (paragraph) in listItem"
21909 );
21910 assert_eq!(children[0].node_type, "paragraph");
21911 let inlines = children[0].content.as_ref().unwrap();
21912 assert_eq!(inlines.len(), 3);
21914 assert_eq!(inlines[0].node_type, "text");
21915 assert_eq!(inlines[1].node_type, "hardBreak");
21916 assert_eq!(inlines[2].node_type, "text");
21917 }
21918
21919 #[test]
21920 fn issue_490_external_media_after_hardbreak_roundtrip() {
21921 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21923 {"type":"listItem","content":[
21924 {"type":"paragraph","content":[
21925 {"type":"text","text":"See image:"},
21926 {"type":"hardBreak"}
21927 ]},
21928 {"type":"mediaSingle","attrs":{"layout":"center"},
21929 "content":[{"type":"media","attrs":{
21930 "type":"external","url":"https://example.com/photo.png","alt":"photo"
21931 }}]}
21932 ]}
21933 ]}]}"#;
21934 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21935 let md = adf_to_markdown(&doc).unwrap();
21936 let rt = markdown_to_adf(&md).unwrap();
21937
21938 let item = &rt.content[0].content.as_ref().unwrap()[0];
21939 let children = item.content.as_ref().unwrap();
21940 assert_eq!(children.len(), 2);
21941 assert_eq!(children[0].node_type, "paragraph");
21942 assert_eq!(children[1].node_type, "mediaSingle");
21943 let media = &children[1].content.as_ref().unwrap()[0];
21944 let m_attrs = media.attrs.as_ref().unwrap();
21945 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
21946 }
21947
21948 #[test]
21949 fn issue_490_multiple_hardbreaks_then_media_single() {
21950 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21952 {"type":"listItem","content":[
21953 {"type":"paragraph","content":[
21954 {"type":"text","text":"line one"},
21955 {"type":"hardBreak"},
21956 {"type":"text","text":"line two"},
21957 {"type":"hardBreak"}
21958 ]},
21959 {"type":"mediaSingle","attrs":{"layout":"center"},
21960 "content":[{"type":"media","attrs":{
21961 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
21962 }}]}
21963 ]}
21964 ]}]}"#;
21965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21966 let md = adf_to_markdown(&doc).unwrap();
21967 let rt = markdown_to_adf(&md).unwrap();
21968
21969 let item = &rt.content[0].content.as_ref().unwrap()[0];
21970 let children = item.content.as_ref().unwrap();
21971 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
21972 assert_eq!(children[0].node_type, "paragraph");
21973 assert_eq!(children[1].node_type, "mediaSingle");
21974 let media = &children[1].content.as_ref().unwrap()[0];
21975 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
21976 }
21977
21978 #[test]
21981 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
21982 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabbccdd-1234-5678-abcd-000000000001"},"content":[{"type":"mediaSingle","attrs":{"layout":"center","width":100,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000002","type":"file","collection":"test-collection","height":100,"width":100}}]},{"type":"paragraph","content":[{"type":"text","text":"some text"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested item"}]}]}]}]}]}]}"#;
21985 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21986 let md = adf_to_markdown(&doc).unwrap();
21987 let rt = markdown_to_adf(&md).unwrap();
21988
21989 let list = &rt.content[0];
21990 assert_eq!(list.node_type, "bulletList");
21991 let item = &list.content.as_ref().unwrap()[0];
21992 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
21994 assert_eq!(
21995 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
21996 "listItem localId must survive round-trip"
21997 );
21998 let children = item.content.as_ref().unwrap();
21999 assert_eq!(
22000 children.len(),
22001 3,
22002 "expected mediaSingle + paragraph + bulletList"
22003 );
22004 assert_eq!(children[0].node_type, "mediaSingle");
22005 assert_eq!(children[1].node_type, "paragraph");
22006 assert_eq!(children[2].node_type, "bulletList");
22007 }
22008
22009 #[test]
22010 fn issue_525_listitem_localid_with_mediasingle_only() {
22011 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22013 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
22014 {"type":"mediaSingle","attrs":{"layout":"center"},
22015 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
22016 ]}
22017 ]}]}"#;
22018 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22019 let md = adf_to_markdown(&doc).unwrap();
22020 let rt = markdown_to_adf(&md).unwrap();
22021
22022 let item = &rt.content[0].content.as_ref().unwrap()[0];
22023 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22024 assert_eq!(
22025 item_attrs["localId"], "li-media-only",
22026 "listItem localId must survive when sole child is mediaSingle"
22027 );
22028 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
22029 }
22030
22031 #[test]
22032 fn issue_525_listitem_localid_with_external_media() {
22033 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22035 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
22036 {"type":"mediaSingle","attrs":{"layout":"center"},
22037 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
22038 ]}
22039 ]}]}"#;
22040 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22041 let md = adf_to_markdown(&doc).unwrap();
22042 let rt = markdown_to_adf(&md).unwrap();
22043
22044 let item = &rt.content[0].content.as_ref().unwrap()[0];
22045 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22046 assert_eq!(
22047 item_attrs["localId"], "li-ext-media",
22048 "listItem localId must survive with external mediaSingle"
22049 );
22050 }
22051
22052 #[test]
22053 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
22054 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
22056 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
22057 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
22058 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
22059 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
22060 ]}
22061 ]}]}"#;
22062 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22063 let md = adf_to_markdown(&doc).unwrap();
22064 let rt = markdown_to_adf(&md).unwrap();
22065
22066 let item = &rt.content[0].content.as_ref().unwrap()[0];
22067 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22068 assert_eq!(
22069 item_attrs["localId"], "li-ord-media",
22070 "listItem localId must survive in ordered list with mediaSingle"
22071 );
22072 let children = item.content.as_ref().unwrap();
22073 assert_eq!(children[0].node_type, "mediaSingle");
22074 assert_eq!(children[1].node_type, "paragraph");
22075 }
22076
22077 #[test]
22078 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
22079 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
22082 let doc = markdown_to_adf(md).unwrap();
22083
22084 let item = &doc.content[0].content.as_ref().unwrap()[0];
22085 let item_attrs = item
22086 .attrs
22087 .as_ref()
22088 .expect("listItem attrs must be present from JFM");
22089 assert_eq!(item_attrs["localId"], "li-jfm-525");
22090 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
22091 }
22092
22093 #[test]
22094 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
22095 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22098 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
22099 {"type":"mediaSingle","attrs":{"layout":"center"},
22100 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
22101 ]}
22102 ]}]}"#;
22103 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22104 let md = adf_to_markdown(&doc).unwrap();
22105 assert!(
22106 md.contains("{localId=li-emit-check}"),
22107 "expected localId in JFM output, got: {md}"
22108 );
22109 for line in md.lines() {
22111 if line.contains("![") {
22112 assert!(
22113 line.contains("localId=li-emit-check"),
22114 "localId must be on the same line as the image: {line}"
22115 );
22116 }
22117 }
22118 }
22119
22120 #[test]
22123 fn adf_placeholder_to_markdown() {
22124 let doc = AdfDocument {
22125 version: 1,
22126 doc_type: "doc".to_string(),
22127 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
22128 "Type something here",
22129 )])],
22130 };
22131 let md = adf_to_markdown(&doc).unwrap();
22132 assert!(
22133 md.contains(":placeholder[Type something here]"),
22134 "expected :placeholder directive, got: {md}"
22135 );
22136 }
22137
22138 #[test]
22139 fn markdown_placeholder_to_adf() {
22140 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
22141 let content = doc.content[0].content.as_ref().unwrap();
22142 assert_eq!(content[1].node_type, "placeholder");
22143 let attrs = content[1].attrs.as_ref().unwrap();
22144 assert_eq!(attrs["text"], "Enter name");
22145 }
22146
22147 #[test]
22148 fn placeholder_round_trip() {
22149 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
22150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22151 let md = adf_to_markdown(&doc).unwrap();
22152 let rt = markdown_to_adf(&md).unwrap();
22153 let content = rt.content[0].content.as_ref().unwrap();
22154 assert_eq!(content.len(), 1);
22155 assert_eq!(content[0].node_type, "placeholder");
22156 let attrs = content[0].attrs.as_ref().unwrap();
22157 assert_eq!(attrs["text"], "Type something here");
22158 }
22159
22160 #[test]
22161 fn placeholder_empty_text() {
22162 let doc = AdfDocument {
22163 version: 1,
22164 doc_type: "doc".to_string(),
22165 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
22166 };
22167 let md = adf_to_markdown(&doc).unwrap();
22168 assert!(
22169 md.contains(":placeholder[]"),
22170 "expected empty placeholder directive, got: {md}"
22171 );
22172 let rt = markdown_to_adf(&md).unwrap();
22173 let content = rt.content[0].content.as_ref().unwrap();
22174 assert_eq!(content[0].node_type, "placeholder");
22175 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
22176 }
22177
22178 #[test]
22179 fn placeholder_with_surrounding_text() {
22180 let md = "Click :placeholder[here] to continue\n";
22181 let doc = markdown_to_adf(md).unwrap();
22182 let content = doc.content[0].content.as_ref().unwrap();
22183 assert_eq!(content[0].text.as_deref(), Some("Click "));
22184 assert_eq!(content[1].node_type, "placeholder");
22185 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
22186 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
22187 }
22188
22189 #[test]
22190 fn placeholder_missing_attrs() {
22191 let doc = AdfDocument {
22193 version: 1,
22194 doc_type: "doc".to_string(),
22195 content: vec![AdfNode::paragraph(vec![AdfNode {
22196 node_type: "placeholder".to_string(),
22197 attrs: None,
22198 content: None,
22199 text: None,
22200 marks: None,
22201 local_id: None,
22202 parameters: None,
22203 }])],
22204 };
22205 let md = adf_to_markdown(&doc).unwrap();
22206 assert!(!md.contains("placeholder"));
22208 }
22209
22210 #[test]
22212 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
22213 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[200],"rowspan":1},"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"prefix text "},{"type":"mention","attrs":{"id":"aabbccdd11223344aabbccdd","localId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","text":"@Alice Example"}},{"type":"text","text":" "}]}]}]}]}]}]}]}"#;
22214 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22215 let md = adf_to_markdown(&doc).unwrap();
22216 let rt = markdown_to_adf(&md).unwrap();
22217
22218 let cell = &rt.content[0].content.as_ref().unwrap()[0]
22220 .content
22221 .as_ref()
22222 .unwrap()[0];
22223 let list = &cell.content.as_ref().unwrap()[0];
22224 let list_item = &list.content.as_ref().unwrap()[0];
22225
22226 assert!(
22228 list_item
22229 .attrs
22230 .as_ref()
22231 .and_then(|a| a.get("localId"))
22232 .is_none(),
22233 "localId should stay on the mention, not the listItem"
22234 );
22235
22236 let para = &list_item.content.as_ref().unwrap()[0];
22237 let inlines = para.content.as_ref().unwrap();
22238
22239 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
22241
22242 assert_eq!(inlines[0].node_type, "text");
22243 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
22244
22245 assert_eq!(inlines[1].node_type, "mention");
22246 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
22247 assert_eq!(
22248 mention_attrs["id"], "aabbccdd11223344aabbccdd",
22249 "mention id must be preserved"
22250 );
22251 assert_eq!(
22252 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
22253 "mention localId must be preserved"
22254 );
22255 assert_eq!(mention_attrs["text"], "@Alice Example");
22256
22257 assert_eq!(inlines[2].node_type, "text");
22258 assert_eq!(inlines[2].text.as_deref(), Some(" "));
22259 }
22260
22261 #[test]
22262 fn mention_in_bullet_list_preserves_id_and_local_id() {
22263 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","localId":"11111111-2222-3333-4444-555555555555","text":"@Bob"}},{"type":"text","text":" "}]}]}]}]}"#;
22265 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22266 let md = adf_to_markdown(&doc).unwrap();
22267 let rt = markdown_to_adf(&md).unwrap();
22268
22269 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
22270 assert!(
22271 list_item
22272 .attrs
22273 .as_ref()
22274 .and_then(|a| a.get("localId"))
22275 .is_none(),
22276 "localId should stay on the mention, not the listItem"
22277 );
22278
22279 let para = &list_item.content.as_ref().unwrap()[0];
22280 let inlines = para.content.as_ref().unwrap();
22281 assert_eq!(inlines[0].node_type, "mention");
22282 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
22283 assert_eq!(mention_attrs["id"], "user123");
22284 assert_eq!(
22285 mention_attrs["localId"],
22286 "11111111-2222-3333-4444-555555555555"
22287 );
22288 }
22289
22290 #[test]
22291 fn mention_in_ordered_list_preserves_id_and_local_id() {
22292 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"see "},{"type":"mention","attrs":{"id":"xyz","localId":"aaaa-bbbb","text":"@Carol"}}]}]}]}]}"#;
22293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22294 let md = adf_to_markdown(&doc).unwrap();
22295 let rt = markdown_to_adf(&md).unwrap();
22296
22297 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
22298 assert!(
22299 list_item
22300 .attrs
22301 .as_ref()
22302 .and_then(|a| a.get("localId"))
22303 .is_none(),
22304 "localId should stay on the mention, not the listItem"
22305 );
22306
22307 let para = &list_item.content.as_ref().unwrap()[0];
22308 let inlines = para.content.as_ref().unwrap();
22309 assert_eq!(inlines[1].node_type, "mention");
22310 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
22311 assert_eq!(mention_attrs["id"], "xyz");
22312 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
22313 }
22314
22315 #[test]
22316 fn list_item_own_local_id_with_mention_both_preserved() {
22317 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
22320 let doc = markdown_to_adf(md).unwrap();
22321 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
22322
22323 let item_attrs = list_item.attrs.as_ref().unwrap();
22325 assert_eq!(item_attrs["localId"], "item-lid");
22326
22327 let para = &list_item.content.as_ref().unwrap()[0];
22329 let inlines = para.content.as_ref().unwrap();
22330 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
22331 let mention_attrs = mention.attrs.as_ref().unwrap();
22332 assert_eq!(mention_attrs["id"], "e1");
22333 assert_eq!(mention_attrs["localId"], "mention-lid");
22334 }
22335
22336 #[test]
22337 fn extract_trailing_local_id_ignores_directive_attrs() {
22338 let line = "text :mention[@X]{id=abc localId=uuid}";
22341 let (text, lid, plid) = extract_trailing_local_id(line);
22342 assert_eq!(text, line, "text should be unchanged");
22343 assert!(
22344 lid.is_none(),
22345 "should not extract localId from directive attrs"
22346 );
22347 assert!(plid.is_none());
22348 }
22349
22350 #[test]
22351 fn extract_trailing_local_id_matches_standalone_block() {
22352 let line = "some text {localId=abc-123}";
22354 let (text, lid, plid) = extract_trailing_local_id(line);
22355 assert_eq!(text, "some text");
22356 assert_eq!(lid.as_deref(), Some("abc-123"));
22357 assert!(plid.is_none());
22358 }
22359
22360 #[test]
22363 fn newline_in_text_node_roundtrips_in_bullet_list() {
22364 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Run these commands:"},{"type":"hardBreak"},{"type":"text","text":"first command\nsecond command"}]}]}]}]}"#;
22368 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22369 let md = adf_to_markdown(&doc).unwrap();
22370 let rt = markdown_to_adf(&md).unwrap();
22371
22372 assert_eq!(rt.content.len(), 1);
22374 let list = &rt.content[0];
22375 assert_eq!(list.node_type, "bulletList");
22376 let items = list.content.as_ref().unwrap();
22377 assert_eq!(items.len(), 1);
22378
22379 let item_content = items[0].content.as_ref().unwrap();
22381 assert_eq!(
22382 item_content.len(),
22383 1,
22384 "listItem should have exactly one paragraph"
22385 );
22386 assert_eq!(item_content[0].node_type, "paragraph");
22387
22388 let inlines = item_content[0].content.as_ref().unwrap();
22390 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22391 assert_eq!(
22392 types,
22393 vec!["text", "hardBreak", "text"],
22394 "embedded newline should stay in a single text node, not produce extra hardBreaks"
22395 );
22396 assert_eq!(
22397 inlines[2].text.as_deref(),
22398 Some("first command\nsecond command")
22399 );
22400 }
22401
22402 #[test]
22403 fn newline_in_text_node_roundtrips_in_ordered_list() {
22404 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first\nsecond"}]}]}]}]}"#;
22406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22407 let md = adf_to_markdown(&doc).unwrap();
22408 let rt = markdown_to_adf(&md).unwrap();
22409
22410 let list = &rt.content[0];
22411 assert_eq!(list.node_type, "orderedList");
22412 let items = list.content.as_ref().unwrap();
22413 assert_eq!(items.len(), 1);
22414
22415 let item_content = items[0].content.as_ref().unwrap();
22416 assert_eq!(item_content.len(), 1);
22417 assert_eq!(item_content[0].node_type, "paragraph");
22418
22419 let inlines = item_content[0].content.as_ref().unwrap();
22420 assert_eq!(inlines.len(), 1);
22421 assert_eq!(inlines[0].node_type, "text");
22422 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
22423 }
22424
22425 #[test]
22426 fn newline_in_text_node_roundtrips_in_paragraph() {
22427 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
22430 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22431 let md = adf_to_markdown(&doc).unwrap();
22432 assert!(
22433 md.contains("hello\\nworld"),
22434 "newline in text node should render as escaped \\n: {md:?}"
22435 );
22436
22437 let rt = markdown_to_adf(&md).unwrap();
22438 let inlines = rt.content[0].content.as_ref().unwrap();
22439 assert_eq!(inlines.len(), 1);
22440 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
22441 }
22442
22443 #[test]
22444 fn multiple_newlines_in_text_node_roundtrip() {
22445 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
22447 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22448 let md = adf_to_markdown(&doc).unwrap();
22449 let rt = markdown_to_adf(&md).unwrap();
22450
22451 let item_content = rt.content[0].content.as_ref().unwrap()[0]
22452 .content
22453 .as_ref()
22454 .unwrap();
22455 assert_eq!(item_content.len(), 1);
22456
22457 let inlines = item_content[0].content.as_ref().unwrap();
22458 assert_eq!(inlines.len(), 1);
22459 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
22460 }
22461
22462 #[test]
22463 fn newline_in_marked_text_node_roundtrips() {
22464 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
22467 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22468 let md = adf_to_markdown(&doc).unwrap();
22469 assert!(
22470 md.contains("**bold\\ntext**"),
22471 "bold text with embedded newline should stay in one marked run: {md:?}"
22472 );
22473
22474 let rt = markdown_to_adf(&md).unwrap();
22475 let inlines = rt.content[0].content.as_ref().unwrap();
22476 assert_eq!(inlines.len(), 1);
22477 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
22478 assert!(inlines[0]
22479 .marks
22480 .as_ref()
22481 .unwrap()
22482 .iter()
22483 .any(|m| m.mark_type == "strong"));
22484 }
22485
22486 #[test]
22487 fn trailing_newline_in_text_node_roundtrips() {
22488 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
22491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22492 let md = adf_to_markdown(&doc).unwrap();
22493 assert!(
22494 md.contains("trailing\\n"),
22495 "trailing newline should be escaped: {md:?}"
22496 );
22497
22498 let rt = markdown_to_adf(&md).unwrap();
22499 let inlines = rt.content[0].content.as_ref().unwrap();
22500 assert_eq!(inlines.len(), 1);
22501 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
22502 }
22503
22504 #[test]
22505 fn hardbreak_and_embedded_newline_are_distinct() {
22506 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"before"},{"type":"hardBreak"},{"type":"text","text":"mid\ndle"},{"type":"hardBreak"},{"type":"text","text":"after"}]}]}"#;
22509 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22510 let md = adf_to_markdown(&doc).unwrap();
22511 let rt = markdown_to_adf(&md).unwrap();
22512
22513 let inlines = rt.content[0].content.as_ref().unwrap();
22514 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22515 assert_eq!(
22516 types,
22517 vec!["text", "hardBreak", "text", "hardBreak", "text"]
22518 );
22519 assert_eq!(inlines[0].text.as_deref(), Some("before"));
22520 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
22521 assert_eq!(inlines[4].text.as_deref(), Some("after"));
22522 }
22523
22524 #[test]
22527 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
22528 let adf_json = r#"{"version":1,"type":"doc","content":[
22531 {"type":"bulletList","content":[
22532 {"type":"listItem","content":[
22533 {"type":"paragraph","content":[
22534 {"type":"text","text":"First item"},
22535 {"type":"hardBreak"}
22536 ]}
22537 ]},
22538 {"type":"listItem","content":[
22539 {"type":"paragraph","content":[
22540 {"type":"text","text":"Second item"}
22541 ]}
22542 ]}
22543 ]}
22544 ]}"#;
22545 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22546 let md = adf_to_markdown(&doc).unwrap();
22547 let rt = markdown_to_adf(&md).unwrap();
22548
22549 assert_eq!(
22551 rt.content.len(),
22552 1,
22553 "Should be 1 block (bulletList), got {}",
22554 rt.content.len()
22555 );
22556 assert_eq!(rt.content[0].node_type, "bulletList");
22557 let items = rt.content[0].content.as_ref().unwrap();
22558 assert_eq!(
22559 items.len(),
22560 2,
22561 "Should have 2 listItems, got {}",
22562 items.len()
22563 );
22564
22565 let p1 = items[0].content.as_ref().unwrap()[0]
22567 .content
22568 .as_ref()
22569 .unwrap();
22570 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22571 assert_eq!(types1, vec!["text", "hardBreak"]);
22572 assert_eq!(p1[0].text.as_deref(), Some("First item"));
22573
22574 let p2 = items[1].content.as_ref().unwrap()[0]
22576 .content
22577 .as_ref()
22578 .unwrap();
22579 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
22580 }
22581
22582 #[test]
22583 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
22584 let adf_json = r#"{"version":1,"type":"doc","content":[
22586 {"type":"orderedList","attrs":{"order":1},"content":[
22587 {"type":"listItem","content":[
22588 {"type":"paragraph","content":[
22589 {"type":"text","text":"Alpha"},
22590 {"type":"hardBreak"}
22591 ]}
22592 ]},
22593 {"type":"listItem","content":[
22594 {"type":"paragraph","content":[
22595 {"type":"text","text":"Beta"}
22596 ]}
22597 ]}
22598 ]}
22599 ]}"#;
22600 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22601 let md = adf_to_markdown(&doc).unwrap();
22602 let rt = markdown_to_adf(&md).unwrap();
22603
22604 assert_eq!(rt.content.len(), 1);
22605 assert_eq!(rt.content[0].node_type, "orderedList");
22606 let items = rt.content[0].content.as_ref().unwrap();
22607 assert_eq!(items.len(), 2);
22608
22609 let p1 = items[0].content.as_ref().unwrap()[0]
22610 .content
22611 .as_ref()
22612 .unwrap();
22613 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22614 assert_eq!(types1, vec!["text", "hardBreak"]);
22615 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
22616 }
22617
22618 #[test]
22619 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
22620 let adf_json = r#"{"version":1,"type":"doc","content":[
22623 {"type":"bulletList","content":[
22624 {"type":"listItem","content":[
22625 {"type":"paragraph","content":[
22626 {"type":"text","text":"Hello"},
22627 {"type":"hardBreak"}
22628 ]}
22629 ]},
22630 {"type":"listItem","content":[
22631 {"type":"paragraph","content":[
22632 {"type":"text","text":"World"}
22633 ]}
22634 ]}
22635 ]}
22636 ]}"#;
22637 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22638 let md = adf_to_markdown(&doc).unwrap();
22639
22640 assert_eq!(md, "- Hello\\\n- World\n");
22642 }
22643
22644 #[test]
22645 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
22646 let adf_json = r#"{"version":1,"type":"doc","content":[
22648 {"type":"bulletList","content":[
22649 {"type":"listItem","content":[
22650 {"type":"paragraph","content":[
22651 {"type":"text","text":"Item"},
22652 {"type":"hardBreak"},
22653 {"type":"hardBreak"}
22654 ]}
22655 ]},
22656 {"type":"listItem","content":[
22657 {"type":"paragraph","content":[
22658 {"type":"text","text":"Next"}
22659 ]}
22660 ]}
22661 ]}
22662 ]}"#;
22663 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22664 let md = adf_to_markdown(&doc).unwrap();
22665 let rt = markdown_to_adf(&md).unwrap();
22666
22667 assert_eq!(rt.content.len(), 1);
22669 assert_eq!(rt.content[0].node_type, "bulletList");
22670 let items = rt.content[0].content.as_ref().unwrap();
22671 assert_eq!(items.len(), 2);
22672
22673 let p1 = items[0].content.as_ref().unwrap()[0]
22675 .content
22676 .as_ref()
22677 .unwrap();
22678 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22679 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
22680 }
22681
22682 #[test]
22683 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
22684 let adf_json = r#"{"version":1,"type":"doc","content":[
22686 {"type":"bulletList","content":[
22687 {"type":"listItem","content":[
22688 {"type":"paragraph","content":[
22689 {"type":"text","text":"Line one"},
22690 {"type":"hardBreak"},
22691 {"type":"text","text":"Line two"},
22692 {"type":"hardBreak"}
22693 ]}
22694 ]},
22695 {"type":"listItem","content":[
22696 {"type":"paragraph","content":[
22697 {"type":"text","text":"Other item"}
22698 ]}
22699 ]}
22700 ]}
22701 ]}"#;
22702 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22703 let md = adf_to_markdown(&doc).unwrap();
22704 let rt = markdown_to_adf(&md).unwrap();
22705
22706 assert_eq!(rt.content.len(), 1);
22707 assert_eq!(rt.content[0].node_type, "bulletList");
22708 let items = rt.content[0].content.as_ref().unwrap();
22709 assert_eq!(items.len(), 2);
22710
22711 let p1 = items[0].content.as_ref().unwrap()[0]
22712 .content
22713 .as_ref()
22714 .unwrap();
22715 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22716 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
22717 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
22718 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
22719 }
22720
22721 #[test]
22722 fn issue_472_only_hardbreak_in_listitem_paragraph() {
22723 let adf_json = r#"{"version":1,"type":"doc","content":[
22725 {"type":"bulletList","content":[
22726 {"type":"listItem","content":[
22727 {"type":"paragraph","content":[
22728 {"type":"hardBreak"}
22729 ]}
22730 ]},
22731 {"type":"listItem","content":[
22732 {"type":"paragraph","content":[
22733 {"type":"text","text":"After"}
22734 ]}
22735 ]}
22736 ]}
22737 ]}"#;
22738 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22739 let md = adf_to_markdown(&doc).unwrap();
22740 let rt = markdown_to_adf(&md).unwrap();
22741
22742 assert_eq!(rt.content.len(), 1);
22744 assert_eq!(rt.content[0].node_type, "bulletList");
22745 let items = rt.content[0].content.as_ref().unwrap();
22746 assert_eq!(items.len(), 2);
22747 }
22748
22749 #[test]
22750 fn issue_472_three_items_middle_has_trailing_hardbreak() {
22751 let adf_json = r#"{"version":1,"type":"doc","content":[
22753 {"type":"bulletList","content":[
22754 {"type":"listItem","content":[
22755 {"type":"paragraph","content":[
22756 {"type":"text","text":"First"}
22757 ]}
22758 ]},
22759 {"type":"listItem","content":[
22760 {"type":"paragraph","content":[
22761 {"type":"text","text":"Second"},
22762 {"type":"hardBreak"}
22763 ]}
22764 ]},
22765 {"type":"listItem","content":[
22766 {"type":"paragraph","content":[
22767 {"type":"text","text":"Third"}
22768 ]}
22769 ]}
22770 ]}
22771 ]}"#;
22772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22773 let md = adf_to_markdown(&doc).unwrap();
22774 let rt = markdown_to_adf(&md).unwrap();
22775
22776 assert_eq!(rt.content.len(), 1);
22777 assert_eq!(rt.content[0].node_type, "bulletList");
22778 let items = rt.content[0].content.as_ref().unwrap();
22779 assert_eq!(items.len(), 3);
22780 assert_eq!(
22781 items[0].content.as_ref().unwrap()[0]
22782 .content
22783 .as_ref()
22784 .unwrap()[0]
22785 .text
22786 .as_deref(),
22787 Some("First")
22788 );
22789 assert_eq!(
22790 items[2].content.as_ref().unwrap()[0]
22791 .content
22792 .as_ref()
22793 .unwrap()[0]
22794 .text
22795 .as_deref(),
22796 Some("Third")
22797 );
22798 }
22799
22800 #[test]
22803 fn issue_494_space_after_hardbreak_roundtrip() {
22804 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22807 {"type":"text","text":"Some text"},
22808 {"type":"hardBreak"},
22809 {"type":"text","text":" "}
22810 ]}]}"#;
22811 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22812 let md = adf_to_markdown(&doc).unwrap();
22813 let rt = markdown_to_adf(&md).unwrap();
22814 let inlines = rt.content[0].content.as_ref().unwrap();
22815 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22816 assert_eq!(
22817 types,
22818 vec!["text", "hardBreak", "text"],
22819 "space-only text node after hardBreak should survive round-trip"
22820 );
22821 assert_eq!(inlines[2].text.as_deref(), Some(" "));
22822 }
22823
22824 #[test]
22825 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
22826 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22828 {"type":"text","text":"Hello"},
22829 {"type":"hardBreak"},
22830 {"type":"text","text":" "}
22831 ]}]}"#;
22832 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22833 let md = adf_to_markdown(&doc).unwrap();
22834 let rt = markdown_to_adf(&md).unwrap();
22835 let inlines = rt.content[0].content.as_ref().unwrap();
22836 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22837 assert_eq!(
22838 types,
22839 vec!["text", "hardBreak", "text"],
22840 "multi-space text node after hardBreak should survive round-trip"
22841 );
22842 assert_eq!(inlines[2].text.as_deref(), Some(" "));
22843 }
22844
22845 #[test]
22846 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
22847 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22850 {"type":"text","text":"Before"},
22851 {"type":"hardBreak"},
22852 {"type":"text","text":" After"}
22853 ]}]}"#;
22854 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22855 let md = adf_to_markdown(&doc).unwrap();
22856 let rt = markdown_to_adf(&md).unwrap();
22857 let inlines = rt.content[0].content.as_ref().unwrap();
22858 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22859 assert_eq!(types, vec!["text", "hardBreak", "text"]);
22860 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
22861 }
22862
22863 #[test]
22864 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
22865 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22867 {"type":"text","text":"A"},
22868 {"type":"hardBreak"},
22869 {"type":"text","text":" "},
22870 {"type":"hardBreak"},
22871 {"type":"text","text":"B"}
22872 ]}]}"#;
22873 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22874 let md = adf_to_markdown(&doc).unwrap();
22875 let rt = markdown_to_adf(&md).unwrap();
22876 let inlines = rt.content[0].content.as_ref().unwrap();
22877 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22878 assert_eq!(
22879 types,
22880 vec!["text", "hardBreak", "text", "hardBreak", "text"],
22881 "space between two hardBreaks should survive round-trip"
22882 );
22883 assert_eq!(inlines[2].text.as_deref(), Some(" "));
22884 assert_eq!(inlines[4].text.as_deref(), Some("B"));
22885 }
22886
22887 #[test]
22888 fn issue_494_trailing_space_hardbreak_style_not_confused() {
22889 let md = "first paragraph\n\nsecond paragraph\n";
22892 let doc = markdown_to_adf(md).unwrap();
22893 assert_eq!(
22894 doc.content.len(),
22895 2,
22896 "blank line should still separate paragraphs"
22897 );
22898 }
22899
22900 #[test]
22901 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
22902 let md = "line one \n \n";
22905 let doc = markdown_to_adf(md).unwrap();
22909 let inlines = doc.content[0].content.as_ref().unwrap();
22910 let has_text_after_break = inlines.iter().any(|n| {
22911 n.node_type == "text"
22912 && n.text
22913 .as_deref()
22914 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
22915 });
22916 assert!(
22917 has_text_after_break || inlines.len() >= 2,
22918 "space-only line after trailing-space hardBreak should be preserved"
22919 );
22920 }
22921
22922 #[test]
22923 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
22924 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22926 {"type":"listItem","content":[{"type":"paragraph","content":[
22927 {"type":"text","text":"item"},
22928 {"type":"hardBreak"},
22929 {"type":"text","text":" "}
22930 ]}]}
22931 ]}]}"#;
22932 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22933 let md = adf_to_markdown(&doc).unwrap();
22934 let rt = markdown_to_adf(&md).unwrap();
22935 let list = &rt.content[0];
22936 let item = &list.content.as_ref().unwrap()[0];
22937 let para = &item.content.as_ref().unwrap()[0];
22938 let inlines = para.content.as_ref().unwrap();
22939 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22940 assert_eq!(
22941 types,
22942 vec!["text", "hardBreak", "text"],
22943 "space after hardBreak in list item should survive round-trip"
22944 );
22945 assert_eq!(inlines[2].text.as_deref(), Some(" "));
22946 }
22947
22948 #[test]
22951 fn issue_510_trailing_double_space_paragraph_roundtrip() {
22952 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph with trailing spaces "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}"#;
22955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22956 let md = adf_to_markdown(&doc).unwrap();
22957 let rt = markdown_to_adf(&md).unwrap();
22958
22959 assert_eq!(
22961 rt.content.len(),
22962 2,
22963 "should produce two paragraphs, got: {}",
22964 rt.content.len()
22965 );
22966 assert_eq!(rt.content[0].node_type, "paragraph");
22967 assert_eq!(rt.content[1].node_type, "paragraph");
22968
22969 let p1 = rt.content[0].content.as_ref().unwrap();
22971 assert_eq!(
22972 p1[0].text.as_deref(),
22973 Some("first paragraph with trailing spaces "),
22974 "trailing spaces should be preserved in first paragraph"
22975 );
22976
22977 let p2 = rt.content[1].content.as_ref().unwrap();
22979 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
22980
22981 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22983 assert!(
22984 !all_types.contains(&"hardBreak"),
22985 "trailing spaces should not produce hardBreak, got: {all_types:?}"
22986 );
22987 }
22988
22989 #[test]
22990 fn issue_510_trailing_triple_space_roundtrip() {
22991 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
22993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22994 let md = adf_to_markdown(&doc).unwrap();
22995 let rt = markdown_to_adf(&md).unwrap();
22996
22997 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
22998 let p1 = rt.content[0].content.as_ref().unwrap();
22999 assert_eq!(
23000 p1[0].text.as_deref(),
23001 Some("text "),
23002 "three trailing spaces should be preserved"
23003 );
23004 }
23005
23006 #[test]
23007 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
23008 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
23010 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23011 let md = adf_to_markdown(&doc).unwrap();
23012 let rt = markdown_to_adf(&md).unwrap();
23013 let p = rt.content[0].content.as_ref().unwrap();
23014 assert_eq!(
23015 p[0].text.as_deref(),
23016 Some("end\\ "),
23017 "backslash + trailing spaces should both survive"
23018 );
23019 }
23020
23021 #[test]
23022 fn issue_510_jfm_contains_escaped_trailing_space() {
23023 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
23025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23026 let md = adf_to_markdown(&doc).unwrap();
23027 assert!(
23028 md.contains(r"\ "),
23029 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
23030 );
23031 for line in md.lines() {
23033 assert!(
23034 !line.ends_with(" "),
23035 "no JFM line should end with two plain spaces, got: {line:?}"
23036 );
23037 }
23038 }
23039
23040 #[test]
23041 fn issue_510_single_trailing_space_not_escaped() {
23042 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
23044 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23045 let md = adf_to_markdown(&doc).unwrap();
23046 assert!(
23047 !md.contains('\\'),
23048 "single trailing space should not be escaped, got: {md:?}"
23049 );
23050 let rt = markdown_to_adf(&md).unwrap();
23051 let p = rt.content[0].content.as_ref().unwrap();
23052 assert_eq!(p[0].text.as_deref(), Some("word "));
23053 }
23054
23055 #[test]
23056 fn issue_510_trailing_spaces_in_heading_roundtrip() {
23057 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
23059 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23060 let md = adf_to_markdown(&doc).unwrap();
23061 let rt = markdown_to_adf(&md).unwrap();
23062 let h = rt.content[0].content.as_ref().unwrap();
23063 assert_eq!(
23064 h[0].text.as_deref(),
23065 Some("heading "),
23066 "trailing spaces in heading should be preserved"
23067 );
23068 }
23069
23070 #[test]
23071 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
23072 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
23074 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23075 let md = adf_to_markdown(&doc).unwrap();
23076 let rt = markdown_to_adf(&md).unwrap();
23077 let list = &rt.content[0];
23078 let item = &list.content.as_ref().unwrap()[0];
23079 let para = &item.content.as_ref().unwrap()[0];
23080 let inlines = para.content.as_ref().unwrap();
23081 assert_eq!(
23082 inlines[0].text.as_deref(),
23083 Some("item "),
23084 "trailing spaces in list item should be preserved"
23085 );
23086 }
23087
23088 #[test]
23089 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
23090 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
23094 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23095 let md = adf_to_markdown(&doc).unwrap();
23096 let rt = markdown_to_adf(&md).unwrap();
23097 let p = rt.content[0].content.as_ref().unwrap();
23098 assert_eq!(
23099 p[0].text.as_deref(),
23100 Some("bold "),
23101 "trailing spaces in bold text should be preserved"
23102 );
23103 }
23104
23105 #[test]
23106 fn issue_510_hardbreak_between_paragraphs_still_works() {
23107 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
23109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23110 let md = adf_to_markdown(&doc).unwrap();
23111 let rt = markdown_to_adf(&md).unwrap();
23112 let inlines = rt.content[0].content.as_ref().unwrap();
23113 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
23114 assert_eq!(
23115 types,
23116 vec!["text", "hardBreak", "text"],
23117 "explicit hardBreak should still round-trip"
23118 );
23119 }
23120
23121 #[test]
23122 fn issue_510_all_spaces_text_node_roundtrip() {
23123 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
23125 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23126 let md = adf_to_markdown(&doc).unwrap();
23127 let rt = markdown_to_adf(&md).unwrap();
23128 let p = rt.content[0].content.as_ref().unwrap();
23129 assert_eq!(
23130 p[0].text.as_deref(),
23131 Some(" "),
23132 "space-only text node should survive round-trip"
23133 );
23134 }
23135
23136 #[test]
23139 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
23140 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}]}"#;
23143 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23144 let md = adf_to_markdown(&doc).unwrap();
23145 let rt = markdown_to_adf(&md).unwrap();
23146
23147 let items = rt.content[0].content.as_ref().unwrap();
23148 assert_eq!(items.len(), 1);
23149 let children = items[0].content.as_ref().unwrap();
23150 assert_eq!(
23151 children.len(),
23152 3,
23153 "Expected 3 paragraphs in listItem, got {}",
23154 children.len()
23155 );
23156 assert_eq!(children[0].node_type, "paragraph");
23157 assert_eq!(children[1].node_type, "paragraph");
23158 assert_eq!(children[2].node_type, "paragraph");
23159
23160 let text1 = children[1].content.as_ref().unwrap()[0]
23162 .text
23163 .as_deref()
23164 .unwrap();
23165 assert_eq!(text1, "second paragraph");
23166 let text2 = children[2].content.as_ref().unwrap()[0]
23167 .text
23168 .as_deref()
23169 .unwrap();
23170 assert_eq!(text2, "third paragraph");
23171 }
23172
23173 #[test]
23174 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
23175 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"},{"type":"hardBreak"},{"type":"text","text":"continued"}]},{"type":"paragraph","content":[{"type":"text","text":"second para"}]},{"type":"paragraph","content":[{"type":"text","text":"third para"}]}]}]}]}"#;
23177 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23178 let md = adf_to_markdown(&doc).unwrap();
23179 let rt = markdown_to_adf(&md).unwrap();
23180
23181 let items = rt.content[0].content.as_ref().unwrap();
23182 let children = items[0].content.as_ref().unwrap();
23183 assert_eq!(
23184 children.len(),
23185 3,
23186 "Expected 3 paragraphs in ordered listItem, got {}",
23187 children.len()
23188 );
23189 assert_eq!(children[1].node_type, "paragraph");
23190 assert_eq!(children[2].node_type, "paragraph");
23191 assert_eq!(
23192 children[1].content.as_ref().unwrap()[0]
23193 .text
23194 .as_deref()
23195 .unwrap(),
23196 "second para"
23197 );
23198 assert_eq!(
23199 children[2].content.as_ref().unwrap()[0]
23200 .text
23201 .as_deref()
23202 .unwrap(),
23203 "third para"
23204 );
23205 }
23206
23207 #[test]
23208 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
23209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}]}]}"#;
23211 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23212 let md = adf_to_markdown(&doc).unwrap();
23213 let rt = markdown_to_adf(&md).unwrap();
23214
23215 let items = rt.content[0].content.as_ref().unwrap();
23216 let children = items[0].content.as_ref().unwrap();
23217 assert_eq!(
23218 children.len(),
23219 2,
23220 "Expected 2 paragraphs in listItem, got {}",
23221 children.len()
23222 );
23223 assert_eq!(children[0].node_type, "paragraph");
23224 assert_eq!(children[1].node_type, "paragraph");
23225 }
23226
23227 #[test]
23228 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
23229 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"parent"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"child"}]}]}]}]}]}]}"#;
23232 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23233 let md = adf_to_markdown(&doc).unwrap();
23234 assert!(
23236 !md.contains(" \n -"),
23237 "No blank separator between paragraph and nested list"
23238 );
23239 let rt = markdown_to_adf(&md).unwrap();
23240
23241 let items = rt.content[0].content.as_ref().unwrap();
23242 let children = items[0].content.as_ref().unwrap();
23243 assert_eq!(children.len(), 2);
23244 assert_eq!(children[0].node_type, "paragraph");
23245 assert_eq!(children[1].node_type, "bulletList");
23246 }
23247
23248 #[test]
23249 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
23250 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"bravo"}]},{"type":"paragraph","content":[{"type":"text","text":"charlie"}]}]}]}]}"#;
23252 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23253 let md = adf_to_markdown(&doc).unwrap();
23254 let rt = markdown_to_adf(&md).unwrap();
23255
23256 let items = rt.content[0].content.as_ref().unwrap();
23257 let children = items[0].content.as_ref().unwrap();
23258 assert_eq!(
23259 children.len(),
23260 3,
23261 "Expected 3 paragraphs, got {}",
23262 children.len()
23263 );
23264 for (i, child) in children.iter().enumerate() {
23265 assert_eq!(
23266 child.node_type, "paragraph",
23267 "Child {i} should be a paragraph"
23268 );
23269 }
23270 }
23271
23272 #[test]
23273 fn issue_522_multiple_list_items_each_with_paragraphs() {
23274 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item1 p1"}]},{"type":"paragraph","content":[{"type":"text","text":"item1 p2"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item2 p1"},{"type":"hardBreak"},{"type":"text","text":"item2 cont"}]},{"type":"paragraph","content":[{"type":"text","text":"item2 p2"}]}]}]}]}"#;
23276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23277 let md = adf_to_markdown(&doc).unwrap();
23278 let rt = markdown_to_adf(&md).unwrap();
23279
23280 let items = rt.content[0].content.as_ref().unwrap();
23281 assert_eq!(items.len(), 2, "Expected 2 list items");
23282
23283 let item1 = items[0].content.as_ref().unwrap();
23284 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
23285
23286 let item2 = items[1].content.as_ref().unwrap();
23287 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
23288 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
23290 let types: Vec<&str> = item2_p1_inlines
23291 .iter()
23292 .map(|n| n.node_type.as_str())
23293 .collect();
23294 assert_eq!(types, vec!["text", "hardBreak", "text"]);
23295 }
23296
23297 #[test]
23298 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
23299 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}"#;
23303 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23304 let md = adf_to_markdown(&doc).unwrap();
23305 let rt = markdown_to_adf(&md).unwrap();
23306
23307 let children = rt.content[0].content.as_ref().unwrap();
23308 assert_eq!(
23309 children.len(),
23310 3,
23311 "Expected 3 paragraphs in blockquote, got {}",
23312 children.len()
23313 );
23314 assert_eq!(children[0].node_type, "paragraph");
23315 assert_eq!(children[1].node_type, "paragraph");
23316 assert_eq!(children[2].node_type, "paragraph");
23317
23318 let text1 = children[1].content.as_ref().unwrap()[0]
23319 .text
23320 .as_deref()
23321 .unwrap();
23322 assert_eq!(text1, "second paragraph");
23323 let text2 = children[2].content.as_ref().unwrap()[0]
23324 .text
23325 .as_deref()
23326 .unwrap();
23327 assert_eq!(text2, "third paragraph");
23328 }
23329
23330 #[test]
23331 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
23332 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]},{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}]}"#;
23334 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23335 let md = adf_to_markdown(&doc).unwrap();
23336 let rt = markdown_to_adf(&md).unwrap();
23337
23338 let children = rt.content[0].content.as_ref().unwrap();
23339 assert_eq!(
23340 children.len(),
23341 2,
23342 "Expected 2 paragraphs in blockquote, got {}",
23343 children.len()
23344 );
23345 assert_eq!(children[0].node_type, "paragraph");
23346 assert_eq!(children[1].node_type, "paragraph");
23347 }
23348
23349 #[test]
23350 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
23351 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"beta"}]},{"type":"paragraph","content":[{"type":"text","text":"gamma"}]}]}]}"#;
23353 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23354 let md = adf_to_markdown(&doc).unwrap();
23355 let rt = markdown_to_adf(&md).unwrap();
23356
23357 let children = rt.content[0].content.as_ref().unwrap();
23358 assert_eq!(
23359 children.len(),
23360 3,
23361 "Expected 3 paragraphs in blockquote, got {}",
23362 children.len()
23363 );
23364 for child in children {
23365 assert_eq!(child.node_type, "paragraph");
23366 }
23367 }
23368
23369 #[test]
23370 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
23371 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item one"}]}]}]}]}]}"#;
23374 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23375 let md = adf_to_markdown(&doc).unwrap();
23376 let rt = markdown_to_adf(&md).unwrap();
23377
23378 let children = rt.content[0].content.as_ref().unwrap();
23379 assert_eq!(children[0].node_type, "paragraph");
23380 assert_eq!(children[1].node_type, "bulletList");
23381 }
23382
23383 #[test]
23384 fn issue_531_blockquote_single_paragraph_unchanged() {
23385 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
23387 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23388 let md = adf_to_markdown(&doc).unwrap();
23389 let rt = markdown_to_adf(&md).unwrap();
23390
23391 let children = rt.content[0].content.as_ref().unwrap();
23392 assert_eq!(children.len(), 1);
23393 assert_eq!(children[0].node_type, "paragraph");
23394 let text = children[0].content.as_ref().unwrap()[0]
23395 .text
23396 .as_deref()
23397 .unwrap();
23398 assert_eq!(text, "solo");
23399 }
23400
23401 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
23406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23407 let md = adf_to_markdown(&doc).unwrap();
23408 let rt = markdown_to_adf(&md).unwrap();
23409 let node = &rt.content[0].content.as_ref().unwrap()[0];
23410 let mark_types: Vec<&str> = node
23411 .marks
23412 .as_ref()
23413 .expect("should have marks")
23414 .iter()
23415 .map(|m| m.mark_type.as_str())
23416 .collect();
23417 assert_eq!(
23418 mark_types, expected_marks,
23419 "mark order mismatch for md={md}"
23420 );
23421 }
23422
23423 #[test]
23424 fn issue_554_code_and_text_color_preserved() {
23425 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23426 {"type":"text","text":"x","marks":[
23427 {"type":"textColor","attrs":{"color":"#008000"}},
23428 {"type":"code"}
23429 ]}
23430 ]}]}"##;
23431 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
23432 }
23433
23434 #[test]
23435 fn issue_554_code_and_bg_color_preserved() {
23436 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23437 {"type":"text","text":"x","marks":[
23438 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
23439 {"type":"code"}
23440 ]}
23441 ]}]}"##;
23442 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
23443 }
23444
23445 #[test]
23446 fn issue_554_code_and_subsup_preserved() {
23447 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23448 {"type":"text","text":"x","marks":[
23449 {"type":"subsup","attrs":{"type":"sub"}},
23450 {"type":"code"}
23451 ]}
23452 ]}]}"#;
23453 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
23454 }
23455
23456 #[test]
23457 fn issue_554_code_and_underline_preserved() {
23458 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23459 {"type":"text","text":"x","marks":[
23460 {"type":"underline"},
23461 {"type":"code"}
23462 ]}
23463 ]}]}"#;
23464 assert_roundtrip_marks(adf_json, &["underline", "code"]);
23465 }
23466
23467 #[test]
23468 fn issue_554_code_textcolor_and_underline_preserved() {
23469 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23470 {"type":"text","text":"x","marks":[
23471 {"type":"textColor","attrs":{"color":"#008000"}},
23472 {"type":"underline"},
23473 {"type":"code"}
23474 ]}
23475 ]}]}"##;
23476 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
23477 }
23478
23479 #[test]
23480 fn issue_554_textcolor_and_underline_preserved() {
23481 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23482 {"type":"text","text":"x","marks":[
23483 {"type":"textColor","attrs":{"color":"#008000"}},
23484 {"type":"underline"}
23485 ]}
23486 ]}]}"##;
23487 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
23488 }
23489
23490 #[test]
23491 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
23492 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23493 {"type":"text","text":"x","marks":[
23494 {"type":"underline"},
23495 {"type":"textColor","attrs":{"color":"#008000"}}
23496 ]}
23497 ]}]}"##;
23498 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
23500 }
23501
23502 #[test]
23503 fn issue_554_textcolor_and_annotation_preserved() {
23504 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23505 {"type":"text","text":"x","marks":[
23506 {"type":"textColor","attrs":{"color":"#008000"}},
23507 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
23508 ]}
23509 ]}]}"##;
23510 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
23511 }
23512
23513 #[test]
23514 fn issue_554_bgcolor_and_underline_preserved() {
23515 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23516 {"type":"text","text":"x","marks":[
23517 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
23518 {"type":"underline"}
23519 ]}
23520 ]}]}"##;
23521 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
23522 }
23523
23524 #[test]
23525 fn issue_554_subsup_and_underline_preserved() {
23526 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23527 {"type":"text","text":"x","marks":[
23528 {"type":"subsup","attrs":{"type":"sub"}},
23529 {"type":"underline"}
23530 ]}
23531 ]}]}"#;
23532 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
23533 }
23534
23535 #[test]
23536 fn issue_554_exact_reproducer_full_match() {
23537 let adf_json = r##"{
23540 "version": 1,
23541 "type": "doc",
23542 "content": [
23543 {
23544 "type": "paragraph",
23545 "content": [
23546 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
23547 {"type":"text","text":"Approved","marks":[
23548 {"type":"textColor","attrs":{"color":"#008000"}}
23549 ]},
23550 {"type":"text","text":" — ready to proceed"}
23551 ]
23552 }
23553 ]
23554 }"##;
23555 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23556 let md = adf_to_markdown(&doc).unwrap();
23557 assert!(
23558 md.contains(":span[Approved]{color=#008000}"),
23559 "JFM should contain green span: {md}"
23560 );
23561 let rt = markdown_to_adf(&md).unwrap();
23562 let approved = rt.content[0]
23564 .content
23565 .as_ref()
23566 .unwrap()
23567 .iter()
23568 .find(|n| n.text.as_deref() == Some("Approved"))
23569 .expect("Approved text node");
23570 let marks = approved.marks.as_ref().expect("should have marks");
23571 let color_mark = marks
23572 .iter()
23573 .find(|m| m.mark_type == "textColor")
23574 .expect("textColor mark must be preserved");
23575 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
23576 }
23577
23578 #[test]
23579 fn issue_554_textcolor_with_code_renders_span_around_code() {
23580 let doc = AdfDocument {
23583 version: 1,
23584 doc_type: "doc".to_string(),
23585 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
23586 "fn main",
23587 vec![
23588 AdfMark::text_color("#008000"),
23589 AdfMark {
23590 mark_type: "code".to_string(),
23591 attrs: None,
23592 },
23593 ],
23594 )])],
23595 };
23596 let md = adf_to_markdown(&doc).unwrap();
23597 assert!(
23598 md.contains(":span[`fn main`]{color=#008000}"),
23599 "expected span-wrapped code, got: {md}"
23600 );
23601 }
23602
23603 #[test]
23604 fn issue_554_underline_with_code_renders_bracketed_around_code() {
23605 let doc = AdfDocument {
23606 version: 1,
23607 doc_type: "doc".to_string(),
23608 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
23609 "fn main",
23610 vec![
23611 AdfMark::underline(),
23612 AdfMark {
23613 mark_type: "code".to_string(),
23614 attrs: None,
23615 },
23616 ],
23617 )])],
23618 };
23619 let md = adf_to_markdown(&doc).unwrap();
23620 assert!(
23621 md.contains("[`fn main`]{underline}"),
23622 "expected bracketed-span around code, got: {md}"
23623 );
23624 }
23625
23626 #[test]
23629 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
23630 let adf_json = r##"{
23635 "version": 1,
23636 "type": "doc",
23637 "content": [
23638 {
23639 "type": "paragraph",
23640 "content": [
23641 {"type":"text","text":"_ "},
23642 {"type":"text","text":"_Action:*","marks":[
23643 {"type":"textColor","attrs":{"color":"#008000"}}
23644 ]},
23645 {"type":"text","text":" Complete the setup process."}
23646 ]
23647 }
23648 ]
23649 }"##;
23650 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23651 let md = adf_to_markdown(&doc).unwrap();
23652 assert!(
23655 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
23656 "underscores at node boundaries should be escaped: {md}"
23657 );
23658 let rt = markdown_to_adf(&md).unwrap();
23659 let para_content = rt.content[0].content.as_ref().unwrap();
23660 let colored = para_content
23662 .iter()
23663 .find(|n| {
23664 n.marks
23665 .as_deref()
23666 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
23667 })
23668 .expect("textColor node must be preserved");
23669 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
23670 let color_mark = colored
23671 .marks
23672 .as_ref()
23673 .unwrap()
23674 .iter()
23675 .find(|m| m.mark_type == "textColor")
23676 .unwrap();
23677 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
23678 for n in para_content {
23680 if let Some(ms) = n.marks.as_deref() {
23681 assert!(
23682 !ms.iter().any(|m| m.mark_type == "em"),
23683 "no em mark should appear, got marks {:?}",
23684 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
23685 );
23686 }
23687 }
23688 }
23689
23690 #[test]
23691 fn issue_554_underscore_intraword_left_unescaped() {
23692 let doc = AdfDocument {
23696 version: 1,
23697 doc_type: "doc".to_string(),
23698 content: vec![AdfNode::paragraph(vec![AdfNode::text(
23699 "call do_something_useful now",
23700 )])],
23701 };
23702 let md = adf_to_markdown(&doc).unwrap();
23703 assert!(
23704 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
23705 "intraword underscores should not be escaped: {md}"
23706 );
23707 }
23708
23709 #[test]
23710 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
23711 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23714 {"type":"text","text":"x","marks":[
23715 {"type":"underline"},
23716 {"type":"textColor","attrs":{"color":"#008000"}},
23717 {"type":"code"}
23718 ]}
23719 ]}]}"##;
23720 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23721 let md = adf_to_markdown(&doc).unwrap();
23722 assert!(
23724 md.starts_with('[') && md.contains("underline}"),
23725 "bracketed-span should wrap the span, got: {md}"
23726 );
23727 let rt = markdown_to_adf(&md).unwrap();
23728 let node = &rt.content[0].content.as_ref().unwrap()[0];
23729 let mark_types: Vec<&str> = node
23730 .marks
23731 .as_ref()
23732 .unwrap()
23733 .iter()
23734 .map(|m| m.mark_type.as_str())
23735 .collect();
23736 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
23737 }
23738
23739 #[test]
23740 fn issue_554_textcolor_underline_link_all_preserved() {
23741 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23745 {"type":"text","text":"linked","marks":[
23746 {"type":"textColor","attrs":{"color":"#008000"}},
23747 {"type":"underline"},
23748 {"type":"link","attrs":{"href":"https://example.com"}}
23749 ]}
23750 ]}]}"##;
23751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23752 let md = adf_to_markdown(&doc).unwrap();
23753 let rt = markdown_to_adf(&md).unwrap();
23754 let node = &rt.content[0].content.as_ref().unwrap()[0];
23755 let mark_types: Vec<&str> = node
23756 .marks
23757 .as_ref()
23758 .unwrap()
23759 .iter()
23760 .map(|m| m.mark_type.as_str())
23761 .collect();
23762 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
23763 }
23764
23765 #[test]
23766 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
23767 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23770 {"type":"text","text":"linked","marks":[
23771 {"type":"underline"},
23772 {"type":"textColor","attrs":{"color":"#008000"}},
23773 {"type":"link","attrs":{"href":"https://example.com"}}
23774 ]}
23775 ]}]}"##;
23776 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23777 let md = adf_to_markdown(&doc).unwrap();
23778 let rt = markdown_to_adf(&md).unwrap();
23779 let node = &rt.content[0].content.as_ref().unwrap()[0];
23780 let mark_types: Vec<&str> = node
23781 .marks
23782 .as_ref()
23783 .unwrap()
23784 .iter()
23785 .map(|m| m.mark_type.as_str())
23786 .collect();
23787 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
23788 }
23789
23790 #[test]
23791 fn issue_554_link_underline_textcolor_link_outer() {
23792 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23796 {"type":"text","text":"linked","marks":[
23797 {"type":"link","attrs":{"href":"https://example.com"}},
23798 {"type":"underline"},
23799 {"type":"textColor","attrs":{"color":"#008000"}}
23800 ]}
23801 ]}]}"##;
23802 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23803 let md = adf_to_markdown(&doc).unwrap();
23804 assert!(
23805 md.starts_with('[') && md.contains("](https://example.com)"),
23806 "link should be outermost, got: {md}"
23807 );
23808 let rt = markdown_to_adf(&md).unwrap();
23809 let node = &rt.content[0].content.as_ref().unwrap()[0];
23810 let mark_types: Vec<&str> = node
23811 .marks
23812 .as_ref()
23813 .unwrap()
23814 .iter()
23815 .map(|m| m.mark_type.as_str())
23816 .collect();
23817 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
23818 }
23819
23820 #[test]
23821 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
23822 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23826 {"type":"text","text":"end_"},
23827 {"type":"text","text":"_start"}
23828 ]}]}"#;
23829 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23830 let md = adf_to_markdown(&doc).unwrap();
23831 let rt = markdown_to_adf(&md).unwrap();
23832 let combined: String = rt.content[0]
23834 .content
23835 .as_ref()
23836 .unwrap()
23837 .iter()
23838 .filter_map(|n| n.text.as_deref())
23839 .collect();
23840 assert_eq!(combined, "end__start");
23841 for n in rt.content[0].content.as_ref().unwrap() {
23843 if let Some(ms) = n.marks.as_deref() {
23844 assert!(!ms.iter().any(|m| m.mark_type == "em"));
23845 }
23846 }
23847 }
23848
23849 #[test]
23857 fn blockquote_nesting_beyond_cap_errors() {
23858 let md = format!("{}x", "> ".repeat(200));
23859 let err = markdown_to_adf(&md).unwrap_err();
23860 assert!(err.to_string().contains("maximum depth"));
23861 }
23862
23863 #[test]
23864 fn list_nesting_beyond_cap_errors() {
23865 let mut md = String::new();
23866 for i in 0..200 {
23867 md.push_str(&" ".repeat(i));
23868 md.push_str("- x\n");
23869 }
23870 let err = markdown_to_adf(&md).unwrap_err();
23871 assert!(err.to_string().contains("maximum depth"));
23872 }
23873
23874 #[test]
23875 fn container_directive_nesting_beyond_cap_errors() {
23876 let mut md = String::new();
23877 for _ in 0..200 {
23878 md.push_str(":::panel{type=info}\n");
23879 }
23880 md.push_str("body\n");
23881 for _ in 0..200 {
23882 md.push_str(":::\n");
23883 }
23884 let err = markdown_to_adf(&md).unwrap_err();
23885 assert!(err.to_string().contains("maximum depth"));
23886 }
23887
23888 #[test]
23899 fn bare_td_directive_errors_without_panicking() {
23900 let err = markdown_to_adf(":::td\n").unwrap_err();
23901 let msg = err.to_string();
23902 assert!(msg.contains("Unclosed"));
23903 assert!(msg.contains("td"));
23904 }
23905
23906 #[test]
23907 fn bare_tr_directive_errors_without_panicking() {
23908 let err = markdown_to_adf(":::tr\n").unwrap_err();
23909 let msg = err.to_string();
23910 assert!(msg.contains("Unclosed"));
23911 assert!(msg.contains("tr"));
23912 }
23913
23914 #[test]
23915 fn bare_td_directive_after_blank_lines_errors() {
23916 let err = markdown_to_adf("\n\t\n:::td\n").unwrap_err();
23917 let msg = err.to_string();
23918 assert!(msg.contains("Unclosed"));
23919 assert!(msg.contains("td"));
23920 assert!(msg.contains("line 3"));
23921 }
23922
23923 #[test]
23924 fn bare_column_directive_errors_without_panicking() {
23925 let err = markdown_to_adf(":::column\n").unwrap_err();
23926 let msg = err.to_string();
23927 assert!(msg.contains("Unclosed"));
23928 assert!(msg.contains("column"));
23929 }
23930
23931 #[test]
23932 fn directive_table_cell_nesting_beyond_cap_errors() {
23933 let mut md = String::new();
23934 for _ in 0..200 {
23935 md.push_str("::::table\n:::tr\n:::td\n");
23936 }
23937 md.push_str("cell\n");
23938 for _ in 0..200 {
23939 md.push_str(":::\n:::\n::::\n");
23940 }
23941 let err = markdown_to_adf(&md).unwrap_err();
23942 assert!(err.to_string().contains("maximum depth"));
23943 }
23944
23945 #[test]
23946 fn layout_column_nesting_beyond_cap_errors() {
23947 let mut md = String::new();
23948 for _ in 0..200 {
23949 md.push_str("::::layout\n:::column{width=100}\n");
23950 }
23951 md.push_str("x\n");
23952 for _ in 0..200 {
23953 md.push_str(":::\n::::\n");
23954 }
23955 let err = markdown_to_adf(&md).unwrap_err();
23956 assert!(err.to_string().contains("maximum depth"));
23957 }
23958
23959 #[test]
23960 fn blockquote_nesting_at_cap_boundary() {
23961 let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
23964 assert!(markdown_to_adf(&at_cap).is_ok());
23965 let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
23966 let err = markdown_to_adf(&past_cap).unwrap_err();
23967 assert!(err.to_string().contains("maximum depth"));
23968 }
23969
23970 #[test]
23971 fn list_nesting_at_cap_succeeds() {
23972 let mut md = String::new();
23976 for i in 0..MAX_NESTING_DEPTH {
23977 md.push_str(&" ".repeat(i));
23978 md.push_str("- x\n");
23979 }
23980 assert!(markdown_to_adf(&md).is_ok());
23981 }
23982
23983 #[test]
23984 fn deep_but_reasonable_nesting_succeeds() {
23985 let md = format!("{}deep", "> ".repeat(20));
23986 let doc = markdown_to_adf(&md).unwrap();
23987 let mut node = &doc.content[0];
23989 for _ in 0..20 {
23990 assert_eq!(node.node_type, "blockquote");
23991 node = &node.content.as_ref().unwrap()[0];
23992 }
23993 assert_eq!(node.node_type, "paragraph");
23994 }
23995
23996 #[test]
23997 fn deeply_nested_emphasis_degrades_without_overflow() {
23998 let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
23999 let doc = markdown_to_adf(&md).unwrap();
24000 assert_eq!(doc.content[0].node_type, "paragraph");
24001 }
24002
24003 #[test]
24004 fn deeply_nested_links_degrade_without_overflow() {
24005 let mut md = String::from("x");
24006 for _ in 0..400 {
24007 md = format!("[{md}](https://example.com)");
24008 }
24009 let doc = markdown_to_adf(&md).unwrap();
24010 assert_eq!(doc.content[0].node_type, "paragraph");
24011 }
24012
24013 #[test]
24014 fn deeply_nested_bracketed_spans_degrade_without_overflow() {
24015 let mut md = String::from("x");
24016 for _ in 0..400 {
24017 md = format!("[{md}]{{underline}}");
24018 }
24019 let doc = markdown_to_adf(&md).unwrap();
24020 assert_eq!(doc.content[0].node_type, "paragraph");
24021 }
24022}