1use anyhow::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
18pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
22 debug!(
23 "markdown_to_adf: input {} bytes, {} lines",
24 markdown.len(),
25 markdown.lines().count()
26 );
27 let mut doc = AdfDocument::new();
28 let mut parser = MarkdownParser::new(markdown);
29 doc.content = parser.parse_blocks()?;
30 debug!(
31 "markdown_to_adf: produced {} top-level ADF nodes",
32 doc.content.len()
33 );
34 Ok(doc)
35}
36
37struct MarkdownParser<'a> {
39 lines: Vec<&'a str>,
40 pos: usize,
41}
42
43impl<'a> MarkdownParser<'a> {
44 fn new(input: &'a str) -> Self {
45 Self {
46 lines: input.lines().collect(),
47 pos: 0,
48 }
49 }
50
51 fn at_end(&self) -> bool {
52 self.pos >= self.lines.len()
53 }
54
55 fn current_line(&self) -> &'a str {
56 self.lines[self.pos]
57 }
58
59 fn advance(&mut self) {
60 self.pos += 1;
61 }
62
63 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
71 while has_trailing_hard_break(full_text) && !self.at_end() {
72 if !self.try_append_hardbreak_continuation(full_text) {
73 break;
74 }
75 }
76 }
77
78 fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
85 match self
91 .current_line()
92 .strip_prefix(" ")
93 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
94 {
95 Some(stripped) => {
96 full_text.push('\n');
97 full_text.push_str(stripped);
98 self.advance();
99 true
100 }
101 None => false,
102 }
103 }
104
105 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
106 let mut blocks = Vec::new();
107
108 while !self.at_end() {
109 let line = self.current_line();
110
111 if line.trim().is_empty() {
112 self.advance();
113 continue;
114 }
115
116 let mut node = if let Some(node) = self.try_heading() {
117 node
118 } else if let Some(node) = self.try_horizontal_rule() {
119 node
120 } else if let Some(node) = self.try_container_directive()? {
121 node
122 } else if let Some(node) = self.try_code_block()? {
123 node
124 } else if let Some(node) = self.try_table()? {
125 node
126 } else if let Some(node) = self.try_blockquote()? {
127 node
128 } else if let Some(node) = self.try_list()? {
129 node
130 } else if let Some(node) = self.try_leaf_directive() {
131 node
132 } else if let Some(node) = self.try_image() {
133 node
134 } else {
135 self.parse_paragraph()?
136 };
137
138 self.try_apply_block_attrs(&mut node);
140 blocks.push(node);
141 }
142
143 Ok(blocks)
144 }
145
146 fn try_heading(&mut self) -> Option<AdfNode> {
147 let line = self.current_line();
148 let trimmed = line.trim_start();
149
150 if !trimmed.starts_with('#') {
151 return None;
152 }
153
154 let level = trimmed.chars().take_while(|&c| c == '#').count();
155 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
156 return None;
157 }
158
159 let mut full_text = trimmed[level + 1..].to_string();
160 self.advance();
161 self.collect_hardbreak_continuations(&mut full_text);
163 let inline_nodes = parse_inline(&full_text);
164
165 #[allow(clippy::cast_possible_truncation)]
166 Some(AdfNode::heading(level as u8, inline_nodes))
167 }
168
169 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
170 let line = self.current_line().trim();
171 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
172 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
173 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
174
175 if is_rule && line.len() >= 3 {
176 self.advance();
177 Some(AdfNode::rule())
178 } else {
179 None
180 }
181 }
182
183 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
184 let line = self.current_line();
185 if !is_code_fence_opener(line) {
186 return Ok(None);
187 }
188
189 let language = line[3..].trim();
190 let language = if language == "\"\"" {
191 Some(String::new())
193 } else if language.is_empty() {
194 None
195 } else {
196 Some(language.to_string())
197 };
198
199 self.advance();
200 let mut code_lines = Vec::new();
201
202 while !self.at_end() {
203 let line = self.current_line();
204 if line.starts_with("```") {
205 self.advance();
206 break;
207 }
208 code_lines.push(line);
209 self.advance();
210 }
211
212 let code_text = code_lines.join("\n");
213
214 if language.as_deref() == Some("adf-unsupported") {
216 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
217 return Ok(Some(node));
218 }
219 }
220
221 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
222 }
223
224 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
225 let line = self.current_line();
226 if !line.starts_with('>') {
227 return Ok(None);
228 }
229
230 let mut quote_lines = Vec::new();
231 while !self.at_end() {
232 let line = self.current_line();
233 if let Some(rest) = line.strip_prefix("> ") {
234 quote_lines.push(rest);
235 self.advance();
236 } else if let Some(rest) = line.strip_prefix('>') {
237 quote_lines.push(rest);
238 self.advance();
239 } else {
240 break;
241 }
242 }
243
244 let quote_text = quote_lines.join("\n");
245 let mut inner_parser = MarkdownParser::new("e_text);
246 let inner_blocks = inner_parser.parse_blocks()?;
247
248 Ok(Some(AdfNode::blockquote(inner_blocks)))
249 }
250
251 fn try_list(&mut self) -> Result<Option<AdfNode>> {
252 let line = self.current_line();
253 let trimmed = line.trim_start();
254
255 let is_bullet =
256 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
257 let ordered_match = parse_ordered_list_marker(trimmed);
258
259 if !is_bullet && ordered_match.is_none() {
260 return Ok(None);
261 }
262
263 if is_bullet {
264 self.parse_bullet_list()
265 } else {
266 let start = ordered_match.map_or(1, |(n, _)| n);
267 self.parse_ordered_list(start)
268 }
269 }
270
271 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
272 let mut items = Vec::new();
273 let mut is_task_list = false;
274
275 while !self.at_end() {
276 let line = self.current_line();
277 let trimmed = line.trim_start();
278
279 if !(trimmed.starts_with("- ")
280 || trimmed.starts_with("* ")
281 || trimmed.starts_with("+ "))
282 {
283 break;
284 }
285
286 let after_marker = trimmed[2..].trim_start();
287
288 if let Some((state, text)) = try_parse_task_marker(after_marker) {
290 is_task_list = true;
291 self.advance();
292 let mut full_text = text.to_string();
296 self.collect_hardbreak_continuations(&mut full_text);
297 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
298 let inline_nodes = parse_inline(item_text);
299 let content = if let Some(ref plid) = para_local_id {
303 let mut para = AdfNode::paragraph(inline_nodes);
304 if plid != "_" {
305 para.attrs = Some(serde_json::json!({"localId": plid}));
306 }
307 vec![para]
308 } else {
309 inline_nodes
310 };
311 let mut task = AdfNode::task_item(state, content);
312 if let Some(id) = local_id {
314 if let Some(ref mut attrs) = task.attrs {
315 attrs["localId"] = serde_json::Value::String(id);
316 }
317 }
318 let mut sub_lines: Vec<String> = Vec::new();
322 while !self.at_end() && self.current_line().starts_with(" ") {
323 let stripped = &self.current_line()[2..];
324 sub_lines.push(stripped.to_string());
325 self.advance();
326 }
327 if !sub_lines.is_empty() {
328 let sub_text = sub_lines.join("\n");
329 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
330 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
338 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
339 if let Some(task_items) = nested.remove(0).content {
340 task.content = Some(task_items);
341 }
342 if let Some(ref mut attrs) = task.attrs {
343 if let Some(obj) = attrs.as_object_mut() {
344 obj.remove("state");
345 }
346 }
347 items.push(task);
348 } else {
349 let mut sibling_task_lists = Vec::new();
355 let mut child_nodes = Vec::new();
356 for n in nested {
357 if n.node_type == "taskList" {
358 sibling_task_lists.push(n);
359 } else {
360 child_nodes.push(n);
361 }
362 }
363 if !child_nodes.is_empty() {
364 match task.content {
365 Some(ref mut content) => content.append(&mut child_nodes),
366 None => task.content = Some(child_nodes),
367 }
368 }
369 items.push(task);
370 items.append(&mut sibling_task_lists);
371 }
372 } else {
373 items.push(task);
374 }
375 } else {
376 let first_line = &trimmed[2..];
377 self.advance();
378 let mut full_text = first_line.to_string();
379 self.collect_hardbreak_continuations(&mut full_text);
380 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
381 let mut sub_lines: Vec<String> = Vec::new();
385 while !self.at_end() {
386 let next = self.current_line();
387 if let Some(stripped) = next.strip_prefix(" ") {
388 sub_lines.push(stripped.to_string());
389 self.advance();
390 continue;
391 }
392 break;
393 }
394 let item_content =
395 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
396 items.push(item_content);
397 }
398 }
399
400 if items.is_empty() {
401 Ok(None)
402 } else if is_task_list {
403 Ok(Some(AdfNode::task_list(items)))
404 } else {
405 Ok(Some(AdfNode::bullet_list(items)))
406 }
407 }
408
409 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
410 let mut items = Vec::new();
411
412 while !self.at_end() {
413 let line = self.current_line();
414 let trimmed = line.trim_start();
415
416 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
417 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
418 self.advance();
419 let mut full_text = first_line.to_string();
420 self.collect_hardbreak_continuations(&mut full_text);
421 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
422 let mut sub_lines: Vec<String> = Vec::new();
424 while !self.at_end() {
425 let next = self.current_line();
426 if let Some(stripped) = next.strip_prefix(" ") {
427 sub_lines.push(stripped.to_string());
428 self.advance();
429 continue;
430 }
431 break;
432 }
433 let item_content =
434 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
435 items.push(item_content);
436 } else {
437 break;
438 }
439 }
440
441 if items.is_empty() {
442 Ok(None)
443 } else {
444 let order = if start == 1 { None } else { Some(start) };
445 Ok(Some(AdfNode::ordered_list(items, order)))
446 }
447 }
448
449 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
450 if self.at_end() {
451 return;
452 }
453 let line = self.current_line().trim();
454 if !line.starts_with('{') {
455 return;
456 }
457 let Some((_, attrs)) = parse_attrs(line, 0) else {
458 return;
459 };
460
461 let mut marks = Vec::new();
462 if let Some(align) = attrs.get("align") {
463 marks.push(AdfMark::alignment(align));
464 }
465 if let Some(indent) = attrs.get("indent") {
466 if let Ok(level) = indent.parse::<u32>() {
467 marks.push(AdfMark::indentation(level));
468 }
469 }
470 if let Some(mode) = attrs.get("breakout") {
471 let width = attrs
472 .get("breakoutWidth")
473 .and_then(|w| w.parse::<u32>().ok());
474 marks.push(AdfMark::breakout(mode, width));
475 }
476
477 let local_id = attrs.get("localId").map(str::to_string);
479
480 let order = if node.node_type == "orderedList" {
482 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
483 } else {
484 None
485 };
486
487 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
488 if has_attrs {
489 if !marks.is_empty() {
490 let existing = node.marks.get_or_insert_with(Vec::new);
491 existing.extend(marks);
492 }
493 if let Some(id) = local_id {
494 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
495 node_attrs["localId"] = serde_json::Value::String(id);
496 }
497 if let Some(n) = order {
498 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
499 node_attrs["order"] = serde_json::json!(n);
500 }
501 self.advance(); }
503 }
504
505 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
506 let line = self.current_line();
507 let Some((d, colon_count)) = try_parse_container_open(line) else {
508 return Ok(None);
509 };
510 self.advance(); let mut inner_lines = Vec::new();
514 let mut depth: usize = 0;
515 while !self.at_end() {
516 let current = self.current_line();
517 if try_parse_container_open(current).is_some() {
518 depth += 1;
519 } else if depth == 0 && is_container_close(current, colon_count) {
520 self.advance(); break;
522 } else if depth > 0 && is_container_close(current, 3) {
523 depth -= 1;
524 }
525 inner_lines.push(current.to_string());
526 self.advance();
527 }
528
529 let inner_text = inner_lines.join("\n");
530
531 let node = match d.name.as_str() {
532 "panel" => {
533 let panel_type = d
534 .attrs
535 .as_ref()
536 .and_then(|a| a.get("type"))
537 .unwrap_or("info");
538 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
539 let mut node = AdfNode::panel(panel_type, inner_blocks);
540 if let Some(ref attrs) = d.attrs {
542 if let Some(ref mut node_attrs) = node.attrs {
543 if let Some(icon) = attrs.get("icon") {
544 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
545 }
546 if let Some(color) = attrs.get("color") {
547 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
548 }
549 }
550 }
551 node
552 }
553 "expand" => {
554 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
555 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
556 let mut node = AdfNode::expand(title, inner_blocks);
557 pass_through_expand_params(&d.attrs, &mut node);
558 node
559 }
560 "nested-expand" => {
561 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
562 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
563 let mut node = AdfNode::nested_expand(title, inner_blocks);
564 pass_through_expand_params(&d.attrs, &mut node);
565 node
566 }
567 "layout" => {
568 let columns = self.parse_layout_columns(&inner_text)?;
570 AdfNode::layout_section(columns)
571 }
572 "decisions" => {
573 let items = parse_decision_items(&inner_text);
574 AdfNode::decision_list(items)
575 }
576 "table" => {
577 let rows = self.parse_directive_table_rows(&inner_text)?;
578 let mut table_attrs = serde_json::json!({});
579 if let Some(ref attrs) = d.attrs {
580 if let Some(layout) = attrs.get("layout") {
581 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
582 }
583 if attrs.has_flag("numbered") {
584 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
585 } else if attrs.get("numbered") == Some("false") {
586 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
587 }
588 if let Some(tw) = attrs.get("width") {
589 if let Some(w) = parse_numeric_attr(tw) {
590 table_attrs["width"] = w;
591 }
592 }
593 if let Some(local_id) = attrs.get("localId") {
594 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
595 }
596 }
597 if table_attrs == serde_json::json!({}) {
598 AdfNode::table(rows)
599 } else {
600 AdfNode::table_with_attrs(rows, table_attrs)
601 }
602 }
603 "extension" => {
604 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
605 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
606 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
607 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
608 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
609 {
610 if let Some(layout) = dir_attrs.get("layout") {
611 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
612 }
613 if let Some(local_id) = dir_attrs.get("localId") {
614 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
615 }
616 if let Some(params_str) = dir_attrs.get("params") {
617 if let Ok(params_val) =
618 serde_json::from_str::<serde_json::Value>(params_str)
619 {
620 node_attrs["parameters"] = params_val;
621 }
622 }
623 }
624 node
625 }
626 _ => return Ok(None),
627 };
628
629 Ok(Some(node))
630 }
631
632 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
633 let mut columns = Vec::new();
634 let mut current_column_lines: Vec<String> = Vec::new();
635 let mut current_width: serde_json::Value = serde_json::json!(50);
636 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
637 let mut in_column = false;
638 let mut depth: usize = 0;
639
640 let lines: Vec<&str> = inner_text.lines().collect();
641 let mut i = 0;
642
643 while i < lines.len() {
644 let line = lines[i];
645 if let Some((col_d, _)) = try_parse_container_open(line) {
646 if col_d.name == "column" && depth == 0 {
647 if in_column && !current_column_lines.is_empty() {
649 let col_text = current_column_lines.join("\n");
650 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
651 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
652 pass_through_local_id(¤t_dir_attrs, &mut col);
653 columns.push(col);
654 current_column_lines.clear();
655 }
656 current_width = col_d
657 .attrs
658 .as_ref()
659 .and_then(|a| a.get("width"))
660 .and_then(parse_numeric_attr)
661 .unwrap_or_else(|| serde_json::json!(50));
662 current_dir_attrs = col_d.attrs;
663 in_column = true;
664 i += 1;
665 continue;
666 }
667 if in_column {
668 depth += 1;
669 }
670 }
671 if in_column && is_container_close(line, 3) {
672 if depth > 0 {
673 depth -= 1;
674 current_column_lines.push(line.to_string());
675 i += 1;
676 continue;
677 }
678 let col_text = current_column_lines.join("\n");
680 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
681 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
682 pass_through_local_id(¤t_dir_attrs, &mut col);
683 columns.push(col);
684 current_column_lines.clear();
685 current_dir_attrs = None;
686 in_column = false;
687 i += 1;
688 continue;
689 }
690 if in_column {
691 current_column_lines.push(line.to_string());
692 }
693 i += 1;
694 }
695
696 if in_column && !current_column_lines.is_empty() {
698 let col_text = current_column_lines.join("\n");
699 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
700 let mut col = AdfNode::layout_column(current_width, blocks);
701 pass_through_local_id(¤t_dir_attrs, &mut col);
702 columns.push(col);
703 }
704
705 Ok(columns)
706 }
707
708 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
710 debug!(
711 "parse_directive_table_rows: {} lines of inner text",
712 inner_text.lines().count()
713 );
714 let mut rows = Vec::new();
715 let lines: Vec<&str> = inner_text.lines().collect();
716 let mut i = 0;
717
718 while i < lines.len() {
719 let line = lines[i];
720 if let Some((d, _)) = try_parse_container_open(line) {
721 if d.name == "tr" {
722 let tr_attrs = d.attrs.clone();
723 i += 1;
724 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
725 if let Some(ref attrs) = tr_attrs {
727 if let Some(local_id) = attrs.get("localId") {
728 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
729 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
730 }
731 }
732 rows.push(row);
733 i = next_i;
734 continue;
735 }
736 if d.name == "caption" {
737 let dir_attrs = d.attrs.clone();
738 i += 1;
739 let mut caption_lines = Vec::new();
740 while i < lines.len() {
741 if is_container_close(lines[i], 3) {
742 i += 1;
743 break;
744 }
745 caption_lines.push(lines[i]);
746 i += 1;
747 }
748 let caption_text = caption_lines.join("\n");
749 let inline_nodes = parse_inline(&caption_text);
750 let mut caption = AdfNode::caption(inline_nodes);
751 pass_through_local_id(&dir_attrs, &mut caption);
752 rows.push(caption);
753 continue;
754 }
755 }
756 i += 1;
757 }
758
759 Ok(rows)
760 }
761
762 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
764 let mut cells = Vec::new();
765 let mut i = start;
766 let mut depth: usize = 0;
767
768 while i < lines.len() {
769 let line = lines[i];
770 if is_container_close(line, 3) {
771 if depth == 0 {
772 i += 1;
774 break;
775 }
776 depth -= 1;
777 i += 1;
778 continue;
779 }
780 if let Some((d, _)) = try_parse_container_open(line) {
781 if depth == 0 && (d.name == "th" || d.name == "td") {
782 let is_header = d.name == "th";
783 let cell_attrs = d.attrs.clone();
784 i += 1;
785 let (cell, next_i) =
786 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
787 cells.push(cell);
788 i = next_i;
789 continue;
790 }
791 depth += 1;
792 }
793 i += 1;
794 }
795
796 if cells.is_empty() {
797 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
798 warn!(
799 "Directive table row at line {start} has no cells — \
800 Confluence requires at least one. Nearby lines: {context:?}"
801 );
802 }
803 debug!("Parsed directive table row: {} cells", cells.len());
804
805 Ok((AdfNode::table_row(cells), i))
806 }
807
808 fn parse_directive_table_cell(
810 &self,
811 lines: &[&str],
812 start: usize,
813 is_header: bool,
814 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
815 ) -> Result<(AdfNode, usize)> {
816 let mut cell_lines = Vec::new();
817 let mut i = start;
818 let mut depth: usize = 0;
819
820 while i < lines.len() {
821 let line = lines[i];
822 if try_parse_container_open(line).is_some() {
823 depth += 1;
824 } else if is_container_close(line, 3) {
825 if depth == 0 {
826 i += 1;
827 break;
828 }
829 depth -= 1;
830 }
831 cell_lines.push(line.to_string());
832 i += 1;
833 }
834
835 let cell_text = cell_lines.join("\n");
836 let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
837
838 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
839 let cell_marks = cell_attrs
840 .as_ref()
841 .map(build_border_marks)
842 .unwrap_or_default();
843
844 let cell = if cell_marks.is_empty() {
845 if is_header {
846 if let Some(attrs) = adf_attrs {
847 AdfNode::table_header_with_attrs(blocks, attrs)
848 } else {
849 AdfNode::table_header(blocks)
850 }
851 } else if let Some(attrs) = adf_attrs {
852 AdfNode::table_cell_with_attrs(blocks, attrs)
853 } else {
854 AdfNode::table_cell(blocks)
855 }
856 } else if is_header {
857 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
858 } else {
859 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
860 };
861
862 Ok((cell, i))
863 }
864
865 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
866 let line = self.current_line();
867 let d = try_parse_leaf_directive(line)?;
868
869 let node = match d.name.as_str() {
870 "card" => {
871 let content = d.content.as_deref().unwrap_or("");
872 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
877 Some(u) => u,
878 None => content,
879 };
880 let mut node = AdfNode::block_card(url);
881 if let Some(ref attrs) = d.attrs {
883 if let Some(ref mut node_attrs) = node.attrs {
884 if let Some(layout) = attrs.get("layout") {
885 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
886 }
887 if let Some(width) = attrs.get("width") {
888 if let Ok(w) = width.parse::<u64>() {
889 node_attrs["width"] = serde_json::json!(w);
890 }
891 }
892 }
893 }
894 node
895 }
896 "embed" => {
897 let url = d.content.as_deref().unwrap_or("");
898 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
899 let original_height = d
900 .attrs
901 .as_ref()
902 .and_then(|a| a.get("originalHeight"))
903 .and_then(|v| v.parse::<f64>().ok());
904 let width = d
905 .attrs
906 .as_ref()
907 .and_then(|a| a.get("width"))
908 .and_then(|w| w.parse::<f64>().ok());
909 AdfNode::embed_card(url, layout, original_height, width)
910 }
911 "extension" => {
912 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
913 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
914 let params = d
915 .attrs
916 .as_ref()
917 .and_then(|a| a.get("params"))
918 .and_then(|p| serde_json::from_str(p).ok());
919 let mut node = AdfNode::extension(ext_type, ext_key, params);
920 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
921 {
922 if let Some(layout) = dir_attrs.get("layout") {
923 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
924 }
925 if let Some(local_id) = dir_attrs.get("localId") {
926 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
927 }
928 }
929 node
930 }
931 "paragraph" => {
932 let mut node = if let Some(ref text) = d.content {
933 AdfNode::paragraph(parse_inline(text))
934 } else {
935 AdfNode::paragraph(vec![])
936 };
937 pass_through_local_id(&d.attrs, &mut node);
938 node
939 }
940 _ => return None,
941 };
942
943 self.advance();
944 Some(node)
945 }
946
947 fn try_image(&mut self) -> Option<AdfNode> {
948 let line = self.current_line().trim();
949 let mut node = try_parse_media_single_from_line(line)?;
950 self.advance();
951
952 if !self.at_end() {
954 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
955 if d.name == "caption" {
956 let dir_attrs = d.attrs;
957 self.advance(); let mut caption_lines = Vec::new();
959 while !self.at_end() {
960 if is_container_close(self.current_line(), 3) {
961 self.advance(); break;
963 }
964 caption_lines.push(self.current_line());
965 self.advance();
966 }
967 let caption_text = caption_lines.join("\n");
968 let inline_nodes = parse_inline(&caption_text);
969 let mut caption = AdfNode::caption(inline_nodes);
970 pass_through_local_id(&dir_attrs, &mut caption);
971 if let Some(ref mut content) = node.content {
972 content.push(caption);
973 }
974 }
975 }
976 }
977
978 Some(node)
979 }
980
981 fn try_table(&mut self) -> Result<Option<AdfNode>> {
982 let line = self.current_line();
983 if !line.contains('|') || !line.trim_start().starts_with('|') {
984 return Ok(None);
985 }
986
987 if self.pos + 1 >= self.lines.len() {
989 return Ok(None);
990 }
991 let next_line = self.lines[self.pos + 1];
992 if !is_table_separator(next_line) {
993 return Ok(None);
994 }
995
996 let header_cells = parse_table_row(line);
998 self.advance(); let sep_line = self.current_line();
1002 let alignments = parse_table_alignments(sep_line);
1003 self.advance(); let mut rows = Vec::new();
1006
1007 let header_adf_cells: Vec<AdfNode> = header_cells
1009 .iter()
1010 .enumerate()
1011 .map(|(col_idx, cell)| {
1012 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1013 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1014 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1015 if let Some(attrs) = cell_attrs {
1016 AdfNode::table_header_with_attrs(vec![para], attrs)
1017 } else {
1018 AdfNode::table_header(vec![para])
1019 }
1020 })
1021 .collect();
1022 if header_adf_cells.is_empty() {
1023 warn!(
1024 "Pipe table header row at line {} has no cells",
1025 self.pos - 1
1026 );
1027 }
1028 rows.push(AdfNode::table_row(header_adf_cells));
1029
1030 while !self.at_end() {
1032 let line = self.current_line();
1033 if !line.contains('|') || line.trim().is_empty() {
1034 break;
1035 }
1036
1037 let cells = parse_table_row(line);
1038 let adf_cells: Vec<AdfNode> = cells
1039 .iter()
1040 .enumerate()
1041 .map(|(col_idx, cell)| {
1042 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1043 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1044 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1045 if let Some(attrs) = cell_attrs {
1046 AdfNode::table_cell_with_attrs(vec![para], attrs)
1047 } else {
1048 AdfNode::table_cell(vec![para])
1049 }
1050 })
1051 .collect();
1052 if adf_cells.is_empty() {
1053 warn!("Pipe table body row at line {} has no cells", self.pos);
1054 }
1055 rows.push(AdfNode::table_row(adf_cells));
1056 self.advance();
1057 }
1058
1059 debug!("Parsed pipe table with {} rows", rows.len());
1060 let mut table = AdfNode::table(rows);
1061
1062 if !self.at_end() {
1064 let next = self.current_line().trim();
1065 if next.starts_with('{') {
1066 if let Some((_, attrs)) = parse_attrs(next, 0) {
1067 let mut table_attrs = serde_json::json!({});
1068 if let Some(layout) = attrs.get("layout") {
1069 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1070 }
1071 if attrs.has_flag("numbered") {
1072 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1073 } else if attrs.get("numbered") == Some("false") {
1074 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1075 }
1076 if let Some(tw) = attrs.get("width") {
1077 if let Some(w) = parse_numeric_attr(tw) {
1078 table_attrs["width"] = w;
1079 }
1080 }
1081 if let Some(local_id) = attrs.get("localId") {
1082 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1083 }
1084 if table_attrs != serde_json::json!({}) {
1085 table.attrs = Some(table_attrs);
1086 self.advance(); }
1088 }
1089 }
1090 }
1091
1092 Ok(Some(table))
1093 }
1094
1095 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1096 let mut lines: Vec<&str> = Vec::new();
1097
1098 while !self.at_end() {
1099 let line = self.current_line();
1100 if (line.trim().is_empty()
1109 && !lines
1110 .last()
1111 .is_some_and(|prev| has_trailing_hard_break(prev)))
1112 || is_code_fence_opener(line)
1113 || (is_horizontal_rule(line) && !lines.is_empty())
1114 {
1115 break;
1116 }
1117 let is_hardbreak_cont = !lines.is_empty()
1120 && line.starts_with(" ")
1121 && lines
1122 .last()
1123 .is_some_and(|prev| has_trailing_hard_break(prev));
1124 if is_hardbreak_cont {
1125 lines.push(&line[2..]);
1126 self.advance();
1127 continue;
1128 }
1129 if !lines.is_empty()
1130 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1131 {
1132 break;
1133 }
1134 if !lines.is_empty() && is_block_attrs_line(line) {
1136 break;
1137 }
1138 lines.push(line);
1139 self.advance();
1140 }
1141
1142 let text = lines.join("\n");
1143 let inline_nodes = parse_inline(&text);
1144 Ok(AdfNode::paragraph(inline_nodes))
1145 }
1146}
1147
1148fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1151 let mut adf = serde_json::json!({});
1152 if let Some(bg) = attrs.get("bg") {
1153 adf["background"] = serde_json::Value::String(bg.to_string());
1154 }
1155 if let Some(colspan) = attrs.get("colspan") {
1156 if let Ok(n) = colspan.parse::<u32>() {
1157 adf["colspan"] = serde_json::json!(n);
1158 }
1159 }
1160 if let Some(rowspan) = attrs.get("rowspan") {
1161 if let Ok(n) = rowspan.parse::<u32>() {
1162 adf["rowspan"] = serde_json::json!(n);
1163 }
1164 }
1165 if let Some(colwidth) = attrs.get("colwidth") {
1166 let widths: Vec<serde_json::Value> = colwidth
1167 .split(',')
1168 .filter_map(|s| parse_numeric_attr(s.trim()))
1169 .collect();
1170 if !widths.is_empty() {
1171 adf["colwidth"] = serde_json::Value::Array(widths);
1172 }
1173 }
1174 if let Some(local_id) = attrs.get("localId") {
1175 adf["localId"] = serde_json::Value::String(local_id.to_string());
1176 }
1177 adf
1178}
1179
1180fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1182 let mut marks = Vec::new();
1183 let border_color = attrs.get("border-color");
1184 let border_size = attrs.get("border-size");
1185 if border_color.is_some() || border_size.is_some() {
1186 let color = border_color.unwrap_or("#000000");
1187 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1188 marks.push(AdfMark::border(color, size));
1189 }
1190 marks
1191}
1192
1193fn iso_date_to_epoch_ms(date_str: &str) -> String {
1196 if date_str.chars().all(|c| c.is_ascii_digit()) {
1198 return date_str.to_string();
1199 }
1200 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1201 let epoch_ms = date
1202 .and_hms_opt(0, 0, 0)
1203 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1204 epoch_ms.to_string()
1205 } else {
1206 date_str.to_string()
1208 }
1209}
1210
1211fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1214 if timestamp.contains('-') {
1216 return timestamp.to_string();
1217 }
1218 if let Ok(ms) = timestamp.parse::<i64>() {
1219 let secs = ms / 1000;
1220 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1221 return dt.format("%Y-%m-%d").to_string();
1222 }
1223 }
1224 timestamp.to_string()
1226}
1227
1228fn is_block_attrs_line(line: &str) -> bool {
1230 let trimmed = line.trim();
1231 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1232 return false;
1233 }
1234 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1235 attrs.get("align").is_some()
1237 || attrs.get("indent").is_some()
1238 || attrs.get("breakout").is_some()
1239 || attrs.get("breakoutWidth").is_some()
1240 || attrs.get("localId").is_some()
1241 } else {
1242 false
1243 }
1244}
1245
1246fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1249 let mut items = Vec::new();
1250 for line in text.lines() {
1251 let trimmed = line.trim();
1252 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1253 let inline_nodes = parse_inline(rest);
1254 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1255 }
1256 }
1257 items
1258}
1259
1260fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1268 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1269 Some(("TODO", rest))
1270 } else if let Some(rest) =
1271 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1272 {
1273 Some(("DONE", rest))
1274 } else {
1275 None
1276 }
1277}
1278
1279fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1283 let rest = text.strip_prefix(checkbox)?;
1284 if rest.is_empty() {
1285 Some(rest)
1286 } else {
1287 rest.strip_prefix(' ')
1288 }
1289}
1290
1291fn starts_with_task_marker(s: &str) -> bool {
1299 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1300 rest
1301 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1302 rest
1303 } else {
1304 return false;
1305 };
1306 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1307}
1308
1309fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1311 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1312 if digit_end == 0 {
1313 return None;
1314 }
1315 let rest = &line[digit_end..];
1316 let after_marker = rest.strip_prefix(". ")?;
1317 let num: u32 = line[..digit_end].parse().ok()?;
1318 Some((num, after_marker))
1319}
1320
1321fn has_trailing_hard_break(line: &str) -> bool {
1324 line.ends_with('\\') || line.ends_with(" ")
1325}
1326
1327fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1335 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1336}
1337
1338fn is_list_start(line: &str) -> bool {
1340 let trimmed = line.trim_start();
1341 trimmed.starts_with("- ")
1342 || trimmed.starts_with("* ")
1343 || trimmed.starts_with("+ ")
1344 || parse_ordered_list_marker(trimmed).is_some()
1345}
1346
1347fn escape_emphasis_markers(text: &str) -> String {
1360 escape_emphasis_with(text, false)
1361}
1362
1363fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1371 escape_emphasis_with(text, true)
1372}
1373
1374fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1380 let chars: Vec<char> = text.chars().collect();
1381 let mut out = String::with_capacity(text.len());
1382 let mut idx = 0;
1383 while idx < chars.len() {
1384 let ch = chars[idx];
1385 if ch == '*' {
1386 out.push('\\');
1387 out.push(ch);
1388 idx += 1;
1389 } else if ch == '_' {
1390 let run_start = idx;
1394 let mut run_end = idx;
1395 while run_end < chars.len() && chars[run_end] == '_' {
1396 run_end += 1;
1397 }
1398 let escape_run = if escape_underscore_always {
1399 true
1400 } else {
1401 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1402 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1403 !(before_alnum && after_alnum)
1404 };
1405 for _ in run_start..run_end {
1406 if escape_run {
1407 out.push('\\');
1408 }
1409 out.push('_');
1410 }
1411 idx = run_end;
1412 } else {
1413 out.push(ch);
1414 idx += 1;
1415 }
1416 }
1417 out
1418}
1419
1420fn escape_backticks(text: &str) -> String {
1426 let mut out = String::with_capacity(text.len());
1427 for ch in text.chars() {
1428 if ch == '`' {
1429 out.push('\\');
1430 }
1431 out.push(ch);
1432 }
1433 out
1434}
1435
1436fn inline_code_delimiter(text: &str) -> (usize, bool) {
1444 let mut max_run = 0usize;
1445 let mut current = 0usize;
1446 for ch in text.chars() {
1447 if ch == '`' {
1448 current += 1;
1449 if current > max_run {
1450 max_run = current;
1451 }
1452 } else {
1453 current = 0;
1454 }
1455 }
1456 let n = max_run + 1;
1457 let starts_bt = text.starts_with('`');
1458 let ends_bt = text.ends_with('`');
1459 let starts_sp = text.starts_with(' ');
1460 let ends_sp = text.ends_with(' ');
1461 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1462 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1463 (n, needs_pad)
1464}
1465
1466fn render_inline_code(text: &str, output: &mut String) {
1469 let (n, pad) = inline_code_delimiter(text);
1470 for _ in 0..n {
1471 output.push('`');
1472 }
1473 if pad {
1474 output.push(' ');
1475 }
1476 output.push_str(text);
1477 if pad {
1478 output.push(' ');
1479 }
1480 for _ in 0..n {
1481 output.push('`');
1482 }
1483}
1484
1485fn escape_pipes_in_cell(text: &str) -> String {
1492 let mut out = String::with_capacity(text.len());
1493 for ch in text.chars() {
1494 if ch == '|' {
1495 out.push('\\');
1496 }
1497 out.push(ch);
1498 }
1499 out
1500}
1501
1502fn escape_link_brackets(text: &str) -> String {
1507 let mut out = String::with_capacity(text.len());
1508 for ch in text.chars() {
1509 if ch == '[' || ch == ']' {
1510 out.push('\\');
1511 }
1512 out.push(ch);
1513 }
1514 out
1515}
1516
1517fn escape_bare_urls(text: &str) -> String {
1523 let mut result = String::with_capacity(text.len());
1524 for (i, ch) in text.char_indices() {
1525 if ch == 'h' {
1526 let rest = &text[i..];
1527 if rest.starts_with("http://") || rest.starts_with("https://") {
1528 result.push('\\');
1529 }
1530 }
1531 result.push(ch);
1532 }
1533 result
1534}
1535
1536fn url_safe_in_bracket_content(s: &str) -> bool {
1545 if s.contains('\n') {
1546 return false;
1547 }
1548 let mut depth: i32 = 1;
1549 for ch in s.chars() {
1550 match ch {
1551 '[' => depth += 1,
1552 ']' => {
1553 depth -= 1;
1554 if depth == 0 {
1555 return false;
1556 }
1557 }
1558 _ => {}
1559 }
1560 }
1561 true
1562}
1563
1564fn escape_emoji_shortcodes(text: &str) -> String {
1575 let mut result = String::with_capacity(text.len());
1576
1577 for (i, ch) in text.char_indices() {
1578 if ch == ':' {
1579 let after = i + 1;
1582 if after < text.len() {
1583 let name_end = text[after..]
1584 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1585 .map_or(text[after..].len(), |pos| pos);
1586 if name_end > 0
1587 && after + name_end < text.len()
1588 && text.as_bytes()[after + name_end] == b':'
1589 {
1590 result.push('\\');
1592 }
1593 }
1594 }
1595 result.push(ch);
1596 }
1597
1598 result
1599}
1600
1601fn escape_list_marker(line: &str) -> String {
1605 if let Some(dot_pos) = line.find(". ") {
1606 if parse_ordered_list_marker(line).is_some() {
1607 let mut s = String::with_capacity(line.len() + 1);
1608 s.push_str(&line[..dot_pos]);
1609 s.push('\\');
1610 s.push_str(&line[dot_pos..]);
1611 return s;
1612 }
1613 }
1614 for prefix in &["- ", "* ", "+ "] {
1615 if line.starts_with(prefix) {
1616 let mut s = String::with_capacity(line.len() + 1);
1617 s.push('\\');
1618 s.push_str(line);
1619 return s;
1620 }
1621 }
1622 line.to_string()
1623}
1624
1625fn is_code_fence_opener(line: &str) -> bool {
1632 if !line.starts_with("```") {
1633 return false;
1634 }
1635 !line[3..].contains('`')
1636}
1637
1638fn is_horizontal_rule(line: &str) -> bool {
1640 let trimmed = line.trim();
1641 trimmed.len() >= 3
1642 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1643 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1644 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1645}
1646
1647fn is_table_separator(line: &str) -> bool {
1649 let trimmed = line.trim();
1650 trimmed.contains('|')
1651 && trimmed
1652 .chars()
1653 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1654}
1655
1656fn parse_table_row(line: &str) -> Vec<String> {
1663 let trimmed = line.trim();
1664 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1665 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1666
1667 let mut cells: Vec<String> = Vec::new();
1668 let mut current = String::new();
1669 let mut chars = trimmed.chars().peekable();
1670 while let Some(ch) = chars.next() {
1671 if ch == '\\' && chars.peek() == Some(&'|') {
1672 current.push('|');
1673 chars.next();
1674 } else if ch == '|' {
1675 cells.push(std::mem::take(&mut current));
1676 } else {
1677 current.push(ch);
1678 }
1679 }
1680 cells.push(current);
1681
1682 cells
1683 .iter()
1684 .map(|s| {
1685 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1688 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1689 stripped.to_string()
1690 })
1691 .collect()
1692}
1693
1694fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1697 let trimmed = separator_line.trim();
1698 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1699 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1700
1701 trimmed
1702 .split('|')
1703 .map(|cell| {
1704 let cell = cell.trim();
1705 let starts_colon = cell.starts_with(':');
1706 let ends_colon = cell.ends_with(':');
1707 match (starts_colon, ends_colon) {
1708 (true, true) => Some("center"),
1709 (false, true) => Some("end"),
1710 _ => None, }
1712 })
1713 .collect()
1714}
1715
1716fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1718 if let Some(align) = alignment {
1719 para.marks = Some(vec![AdfMark::alignment(align)]);
1720 }
1721}
1722
1723fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1726 let trimmed = cell_text.trim_start();
1727 if !trimmed.starts_with('{') {
1728 return (cell_text.to_string(), None);
1729 }
1730 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1731 let remaining = trimmed[end_pos..].trim_start().to_string();
1732 let adf_attrs = build_cell_attrs(&attrs);
1733 (remaining, Some(adf_attrs))
1734 } else {
1735 (cell_text.to_string(), None)
1736 }
1737}
1738
1739fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1742 let line = line.trim();
1743 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1752 let after_img = line[img_end..].trim_start();
1753
1754 if after_img.starts_with('{') {
1755 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1756 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1758 let mut media_attrs = serde_json::json!({"type": "file"});
1759 if let Some(id) = attrs.get("id") {
1760 media_attrs["id"] = serde_json::Value::String(id.to_string());
1761 }
1762 if let Some(collection) = attrs.get("collection") {
1763 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1764 }
1765 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1766 media_attrs["occurrenceKey"] =
1767 serde_json::Value::String(occurrence_key.to_string());
1768 }
1769 if let Some(height) = attrs.get("height") {
1770 if let Some(h) = parse_numeric_attr(height) {
1771 media_attrs["height"] = h;
1772 }
1773 }
1774 if let Some(width) = attrs.get("width") {
1775 if let Some(w) = parse_numeric_attr(width) {
1776 media_attrs["width"] = w;
1777 }
1778 }
1779 if let Some(alt_text) = alt_opt {
1780 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1781 }
1782 if let Some(local_id) = attrs.get("localId") {
1783 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1784 }
1785 let mut ms_attrs = serde_json::json!({"layout": "center"});
1786 if let Some(layout) = attrs.get("layout") {
1787 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1788 }
1789 if let Some(ms_width) = attrs.get("mediaWidth") {
1790 if let Some(w) = parse_numeric_attr(ms_width) {
1791 ms_attrs["width"] = w;
1792 }
1793 }
1794 if let Some(wt) = attrs.get("widthType") {
1795 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1796 }
1797 if let Some(mode) = attrs.get("mode") {
1798 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1799 }
1800 let border_marks = build_border_marks(&attrs);
1801 let media_marks = if border_marks.is_empty() {
1802 None
1803 } else {
1804 Some(border_marks)
1805 };
1806 return Some(AdfNode {
1807 node_type: "mediaSingle".to_string(),
1808 attrs: Some(ms_attrs),
1809 content: Some(vec![AdfNode {
1810 node_type: "media".to_string(),
1811 attrs: Some(media_attrs),
1812 content: None,
1813 text: None,
1814 marks: media_marks,
1815 local_id: None,
1816 parameters: None,
1817 }]),
1818 text: None,
1819 marks: None,
1820 local_id: None,
1821 parameters: None,
1822 });
1823 }
1824
1825 let mut node = AdfNode::media_single(url, alt_opt);
1827 if let Some(ref mut node_attrs) = node.attrs {
1828 if let Some(layout) = attrs.get("layout") {
1829 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1830 }
1831 if let Some(width) = attrs.get("width") {
1832 if let Some(w) = parse_numeric_attr(width) {
1833 node_attrs["width"] = w;
1834 }
1835 }
1836 if let Some(wt) = attrs.get("widthType") {
1837 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1838 }
1839 if let Some(mode) = attrs.get("mode") {
1840 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1841 }
1842 }
1843 if let Some(ref mut content) = node.content {
1844 if let Some(media) = content.first_mut() {
1845 if let Some(local_id) = attrs.get("localId") {
1846 if let Some(ref mut media_attrs) = media.attrs {
1847 media_attrs["localId"] =
1848 serde_json::Value::String(local_id.to_string());
1849 }
1850 }
1851 let border_marks = build_border_marks(&attrs);
1852 if !border_marks.is_empty() {
1853 media.marks = Some(border_marks);
1854 }
1855 }
1856 }
1857 return Some(node);
1858 }
1859 }
1860
1861 Some(AdfNode::media_single(url, alt_opt))
1862}
1863
1864fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1866 let line = line.trim();
1867 if !line.starts_with("?;
1872 let alt = &line[2..alt_end];
1873 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1875 let url = &line[paren_start + 1..url_end];
1876
1877 Some((alt, url))
1878}
1879
1880fn parse_inline(text: &str) -> Vec<AdfNode> {
1888 parse_inline_impl(text, true)
1889}
1890
1891fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1898 parse_inline_impl(text, false)
1899}
1900
1901fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1906 let mut nodes = Vec::new();
1907 let mut chars = text.char_indices().peekable();
1908 let mut plain_start = 0;
1909
1910 while let Some(&(i, ch)) = chars.peek() {
1911 match ch {
1912 '*' | '_' => {
1913 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1914 flush_plain(text, plain_start, i, &mut nodes);
1915 let mark = if is_bold {
1916 AdfMark::strong()
1917 } else {
1918 AdfMark::em()
1919 };
1920 let inner = parse_inline_no_auto_cards(content);
1921 for mut node in inner {
1922 prepend_mark(&mut node, mark.clone());
1923 nodes.push(node);
1924 }
1925 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1927 chars.next();
1928 }
1929 plain_start = end;
1930 continue;
1931 }
1932 if ch == '_' {
1937 while chars.peek().is_some_and(|&(_, c)| c == '_') {
1938 chars.next();
1939 }
1940 } else {
1941 chars.next();
1942 }
1943 }
1944 '~' => {
1945 if let Some((end, content)) = try_parse_strikethrough(text, i) {
1946 flush_plain(text, plain_start, i, &mut nodes);
1947 let inner = parse_inline_no_auto_cards(content);
1948 for mut node in inner {
1949 prepend_mark(&mut node, AdfMark::strike());
1950 nodes.push(node);
1951 }
1952 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1953 chars.next();
1954 }
1955 plain_start = end;
1956 continue;
1957 }
1958 chars.next();
1959 }
1960 '`' => {
1961 if let Some((end, content)) = try_parse_inline_code(text, i) {
1962 flush_plain(text, plain_start, i, &mut nodes);
1963 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1964 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1965 chars.next();
1966 }
1967 plain_start = end;
1968 continue;
1969 }
1970 while chars.peek().is_some_and(|&(_, c)| c == '`') {
1973 chars.next();
1974 }
1975 }
1976 '[' => {
1977 if let Some((end, link_text, href)) = try_parse_link(text, i) {
1978 flush_plain(text, plain_start, i, &mut nodes);
1979 if link_text.starts_with("http://") || link_text.starts_with("https://") {
1980 nodes.push(AdfNode::text_with_marks(
1985 link_text,
1986 vec![AdfMark::link(href)],
1987 ));
1988 } else {
1989 let inner = parse_inline_no_auto_cards(link_text);
1990 for mut node in inner {
1991 prepend_mark(&mut node, AdfMark::link(href));
1992 nodes.push(node);
1993 }
1994 }
1995 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1996 chars.next();
1997 }
1998 plain_start = end;
1999 continue;
2000 }
2001 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2003 flush_plain(text, plain_start, i, &mut nodes);
2004 nodes.extend(span_nodes);
2005 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2006 chars.next();
2007 }
2008 plain_start = end;
2009 continue;
2010 }
2011 chars.next();
2012 }
2013 ':' => {
2014 if let Some(node) = try_dispatch_inline_directive(text, i) {
2016 flush_plain(text, plain_start, i, &mut nodes);
2017 let end = node.1;
2018 nodes.push(node.0);
2019 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2020 chars.next();
2021 }
2022 plain_start = end;
2023 continue;
2024 }
2025 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2027 flush_plain(text, plain_start, i, &mut nodes);
2028 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2029 nodes.push(emoji_node);
2030 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2031 chars.next();
2032 }
2033 plain_start = final_end;
2034 continue;
2035 }
2036 chars.next();
2037 }
2038 ' ' if text[i..].starts_with(" \n") => {
2039 flush_plain(text, plain_start, i, &mut nodes);
2042 nodes.push(AdfNode::hard_break());
2043 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2045 chars.next();
2046 }
2047 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2049 chars.next();
2050 }
2051 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2052 }
2053 '!' if text[i..].starts_with("![") => {
2054 chars.next();
2058 }
2059 'h' if auto_inline_card
2060 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2061 {
2062 if let Some((end, url)) = try_parse_bare_url(text, i) {
2063 flush_plain(text, plain_start, i, &mut nodes);
2064 nodes.push(AdfNode::inline_card(url));
2065 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2066 chars.next();
2067 }
2068 plain_start = end;
2069 continue;
2070 }
2071 chars.next();
2072 }
2073 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2074 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2075 {
2076 flush_plain(text, plain_start, i, &mut nodes);
2080 nodes.push(AdfNode::text("\n"));
2081 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2084 }
2085 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2086 flush_plain(text, plain_start, i, &mut nodes);
2091 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2096 chars.next(); }
2098 '\\' if text[i..].starts_with("\\\n") => {
2099 flush_plain(text, plain_start, i, &mut nodes);
2101 nodes.push(AdfNode::hard_break());
2102 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2105 chars.next();
2106 }
2107 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2108 }
2109 '\\' if i + 1 == text.len() => {
2110 flush_plain(text, plain_start, i, &mut nodes);
2112 nodes.push(AdfNode::hard_break());
2113 chars.next(); plain_start = text.len();
2115 }
2116 _ => {
2117 chars.next();
2118 }
2119 }
2120 }
2121
2122 if plain_start < text.len() {
2124 let remaining = &text[plain_start..];
2125 if !remaining.is_empty() {
2126 nodes.push(AdfNode::text(remaining));
2127 }
2128 }
2129
2130 merge_adjacent_text(&mut nodes);
2133
2134 nodes
2135}
2136
2137fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2139 let mut i = 0;
2140 while i + 1 < nodes.len() {
2141 if nodes[i].node_type == "text"
2142 && nodes[i + 1].node_type == "text"
2143 && nodes[i].marks.is_none()
2144 && nodes[i + 1].marks.is_none()
2145 {
2146 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2147 if let Some(ref mut t) = nodes[i].text {
2148 t.push_str(&next_text);
2149 }
2150 nodes.remove(i + 1);
2151 } else {
2152 i += 1;
2153 }
2154 }
2155}
2156
2157fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2159 if start < end {
2160 let plain = &text[start..end];
2161 if !plain.is_empty() {
2162 nodes.push(AdfNode::text(plain));
2163 }
2164 }
2165}
2166
2167#[cfg(test)]
2169fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2170 if let Some(ref mut marks) = node.marks {
2171 marks.push(mark);
2172 } else {
2173 node.marks = Some(vec![mark]);
2174 }
2175}
2176
2177fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2179 if let Some(ref mut marks) = node.marks {
2180 marks.insert(0, mark);
2181 } else {
2182 node.marks = Some(vec![mark]);
2183 }
2184}
2185
2186fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2191 let before = text[..delim_pos]
2192 .chars()
2193 .next_back()
2194 .is_some_and(char::is_alphanumeric);
2195 let after = text[delim_pos + len..]
2196 .chars()
2197 .next()
2198 .is_some_and(char::is_alphanumeric);
2199 before && after
2200}
2201
2202fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2206 let needle_bytes = needle.as_bytes();
2207 let hay_bytes = haystack.as_bytes();
2208 let mut i = 0;
2209 while i < hay_bytes.len() {
2210 if hay_bytes[i] == b'\\' {
2211 i += 2; continue;
2213 }
2214 if hay_bytes[i..].starts_with(needle_bytes) {
2215 return Some(i);
2216 }
2217 i += 1;
2218 }
2219 None
2220}
2221
2222fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2225 let hay_bytes = haystack.as_bytes();
2226 let mut i = 0;
2227 while i < hay_bytes.len() {
2228 if hay_bytes[i] == b'\\' {
2229 i += 2;
2230 continue;
2231 }
2232 if hay_bytes[i] == ch {
2233 return Some(i);
2234 }
2235 i += 1;
2236 }
2237 None
2238}
2239
2240fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2250 let rest = &text[i..];
2251
2252 if rest.starts_with("***") || rest.starts_with("___") {
2256 let is_underscore = rest.starts_with("___");
2257 if is_underscore && is_intraword_underscore(text, i, 3) {
2258 return None;
2259 }
2260 let triple = &rest[..3];
2261 let after = &rest[3..];
2262 if let Some(close) = find_unescaped(after, triple) {
2263 if close > 0 {
2264 let close_pos = i + 3 + close;
2265 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2266 return None;
2267 }
2268 let content = &rest[2..=3 + close];
2272 let end = i + 3 + close + 3;
2273 return Some((end, content, true));
2274 }
2275 }
2276 }
2277
2278 if rest.starts_with("**") || rest.starts_with("__") {
2280 let is_underscore = rest.starts_with("__");
2281 if is_underscore && is_intraword_underscore(text, i, 2) {
2282 return None;
2283 }
2284 let delimiter = &rest[..2];
2285 let after = &rest[2..];
2286 let close = find_unescaped(after, delimiter)?;
2287 if close == 0 {
2288 return None;
2289 }
2290 let close_pos = i + 2 + close;
2291 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2292 return None;
2293 }
2294 let content = &after[..close];
2295 let end = i + 2 + close + 2;
2296 return Some((end, content, true));
2297 }
2298
2299 if rest.starts_with('*') || rest.starts_with('_') {
2301 let delim_char = rest.as_bytes()[0];
2302 let is_underscore = delim_char == b'_';
2303 if is_underscore && is_intraword_underscore(text, i, 1) {
2304 return None;
2305 }
2306 let after = &rest[1..];
2307 let close = find_unescaped_char(after, delim_char)?;
2308 if close == 0 {
2309 return None;
2310 }
2311 let close_pos = i + 1 + close;
2312 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2313 return None;
2314 }
2315 let content = &after[..close];
2316 let end = i + 1 + close + 1;
2317 return Some((end, content, false));
2318 }
2319
2320 None
2321}
2322
2323fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2325 let rest = &text[i..];
2326 if !rest.starts_with("~~") {
2327 return None;
2328 }
2329 let after = &rest[2..];
2330 let close = after.find("~~")?;
2331 if close == 0 {
2332 return None;
2333 }
2334 let content = &after[..close];
2335 Some((i + 2 + close + 2, content))
2336}
2337
2338fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2345 let rest = &text[i..];
2346 let bytes = rest.as_bytes();
2347 if bytes.is_empty() || bytes[0] != b'`' {
2348 return None;
2349 }
2350 let mut opening = 0usize;
2351 while opening < bytes.len() && bytes[opening] == b'`' {
2352 opening += 1;
2353 }
2354
2355 let mut j = opening;
2356 while j < bytes.len() {
2357 if bytes[j] == b'`' {
2358 let run_start = j;
2359 while j < bytes.len() && bytes[j] == b'`' {
2360 j += 1;
2361 }
2362 if j - run_start == opening {
2363 let content = &rest[opening..run_start];
2364 let content = strip_code_span_padding(content);
2365 return Some((i + j, content));
2366 }
2367 } else {
2368 j += 1;
2369 }
2370 }
2371 None
2372}
2373
2374fn strip_code_span_padding(content: &str) -> &str {
2378 let bytes = content.as_bytes();
2379 if bytes.len() >= 2
2380 && bytes[0] == b' '
2381 && bytes[bytes.len() - 1] == b' '
2382 && content.bytes().any(|b| b != b' ')
2383 {
2384 &content[1..content.len() - 1]
2385 } else {
2386 content
2387 }
2388}
2389
2390fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2393 let rest = &text[i..];
2394 if !rest.starts_with('[') {
2395 return None;
2396 }
2397
2398 let mut depth: usize = 0;
2402 let mut bracket_close = None;
2403 let bs_bytes = rest.as_bytes();
2404 for (j, ch) in rest.char_indices() {
2405 match ch {
2406 '\\' if j + 1 < bs_bytes.len()
2407 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2408 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2409 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2410 depth -= 1;
2411 if depth == 0 {
2412 bracket_close = Some(j);
2413 break;
2414 }
2415 }
2416 _ => {}
2417 }
2418 }
2419 let bracket_close = bracket_close?;
2420 let after_bracket = &rest[bracket_close + 1..];
2422 if !after_bracket.starts_with('{') {
2423 return None;
2424 }
2425
2426 let span_text = &rest[1..bracket_close];
2427 let attrs_start = i + bracket_close + 1;
2428 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2429
2430 let mut marks = Vec::new();
2431 if attrs.has_flag("underline") {
2432 marks.push(AdfMark::underline());
2433 }
2434 let ann_ids = attrs.get_all("annotation-id");
2435 let ann_types = attrs.get_all("annotation-type");
2436 for (idx, ann_id) in ann_ids.iter().enumerate() {
2437 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2438 marks.push(AdfMark::annotation(ann_id, ann_type));
2439 }
2440
2441 if marks.is_empty() {
2442 return None; }
2444
2445 let inner = parse_inline_no_auto_cards(span_text);
2446 let result: Vec<AdfNode> = inner
2447 .into_iter()
2448 .map(|mut node| {
2449 let mut combined = marks.clone();
2452 if let Some(ref existing) = node.marks {
2453 combined.extend(existing.iter().cloned());
2454 }
2455 node.marks = Some(combined);
2456 node
2457 })
2458 .collect();
2459
2460 Some((attrs_end, result))
2461}
2462
2463fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2466 let d = try_parse_inline_directive(text, pos)?;
2467 let content = d.content.as_deref().unwrap_or("");
2468
2469 let node = match d.name.as_str() {
2470 "card" => {
2471 let url = d
2476 .attrs
2477 .as_ref()
2478 .and_then(|a| a.get("url"))
2479 .unwrap_or(content);
2480 let mut node = AdfNode::inline_card(url);
2481 pass_through_local_id(&d.attrs, &mut node);
2482 node
2483 }
2484 "status" => {
2485 let color = d
2486 .attrs
2487 .as_ref()
2488 .and_then(|a| a.get("color"))
2489 .unwrap_or("neutral");
2490 let mut node = AdfNode::status(content, color);
2491 if let Some(ref attrs) = d.attrs {
2493 if let Some(ref mut node_attrs) = node.attrs {
2494 if let Some(style) = attrs.get("style") {
2495 node_attrs["style"] = serde_json::Value::String(style.to_string());
2496 }
2497 if let Some(local_id) = attrs.get("localId") {
2498 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2499 }
2500 }
2501 }
2502 node
2503 }
2504 "date" => {
2505 let timestamp = d
2506 .attrs
2507 .as_ref()
2508 .and_then(|a| a.get("timestamp"))
2509 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2510 let mut node = AdfNode::date(×tamp);
2511 pass_through_local_id(&d.attrs, &mut node);
2512 node
2513 }
2514 "mention" => {
2515 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2516 let mut node = AdfNode::mention(id, content);
2517 if let Some(ref attrs) = d.attrs {
2519 if let (Some(ref mut node_attrs), true) = (
2520 &mut node.attrs,
2521 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2522 ) {
2523 if let Some(ut) = attrs.get("userType") {
2524 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2525 }
2526 if let Some(al) = attrs.get("accessLevel") {
2527 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2528 }
2529 }
2530 }
2531 pass_through_local_id(&d.attrs, &mut node);
2532 node
2533 }
2534 "span" => {
2535 let mut marks = Vec::new();
2536 if let Some(ref attrs) = d.attrs {
2537 if let Some(color) = attrs.get("color") {
2538 marks.push(AdfMark::text_color(color));
2539 }
2540 if let Some(bg) = attrs.get("bg") {
2541 marks.push(AdfMark::background_color(bg));
2542 }
2543 if attrs.has_flag("sub") {
2544 marks.push(AdfMark::subsup("sub"));
2545 }
2546 if attrs.has_flag("sup") {
2547 marks.push(AdfMark::subsup("sup"));
2548 }
2549 }
2550 if marks.is_empty() {
2551 AdfNode::text(content)
2552 } else {
2553 let inner = parse_inline_no_auto_cards(content);
2556 let mut nodes: Vec<AdfNode> = inner
2557 .into_iter()
2558 .map(|mut node| {
2559 let mut combined = marks.clone();
2560 if let Some(ref existing) = node.marks {
2561 combined.extend(existing.iter().cloned());
2562 }
2563 node.marks = Some(combined);
2564 node
2565 })
2566 .collect();
2567 nodes.remove(0)
2569 }
2570 }
2571 "placeholder" => AdfNode::placeholder(content),
2572 "media-inline" => {
2573 let mut json_attrs = serde_json::Map::new();
2574 if let Some(ref attrs) = d.attrs {
2575 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2576 if let Some(val) = attrs.get(key) {
2577 if *key == "width" || *key == "height" {
2578 if let Ok(n) = val.parse::<u64>() {
2579 json_attrs.insert(
2580 (*key).to_string(),
2581 serde_json::Value::Number(n.into()),
2582 );
2583 continue;
2584 }
2585 }
2586 json_attrs.insert(
2587 (*key).to_string(),
2588 serde_json::Value::String(val.to_string()),
2589 );
2590 }
2591 }
2592 if let Some(local_id) = attrs.get("localId") {
2593 json_attrs.insert(
2594 "localId".to_string(),
2595 serde_json::Value::String(local_id.to_string()),
2596 );
2597 }
2598 }
2599 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2600 }
2601 "extension" => {
2602 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2603 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2604 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2605 }
2606 _ => return None, };
2608
2609 Some((node, d.end_pos))
2610}
2611
2612fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2615 let rest = &text[i..];
2616 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2617 return None;
2618 }
2619 let end = rest
2621 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2622 .unwrap_or(rest.len());
2623 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2625 if url.len() <= "https://".len() {
2626 return None; }
2628 Some((i + url.len(), url))
2629}
2630
2631fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2634 let rest = &text[i..];
2635 if !rest.starts_with(':') {
2636 return None;
2637 }
2638 let after = &rest[1..];
2639 let name_end =
2640 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2641 if name_end == 0 {
2642 return None;
2643 }
2644 if after.as_bytes().get(name_end) != Some(&b':') {
2645 return None;
2646 }
2647 let name = &after[..name_end];
2648 Some((i + 1 + name_end + 1, name))
2649}
2650
2651fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2654 let mut chain_end = shortcode_end;
2659 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2660 chain_end = next_end;
2661 }
2662 if chain_end > shortcode_end {
2663 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2664 return (attr_end, build_emoji_node(&attrs, short_name));
2665 }
2666 }
2667
2668 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2669 (attr_end, build_emoji_node(&attrs, short_name))
2670 } else {
2671 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2672 }
2673}
2674
2675fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2678 let resolved_name = attrs
2679 .get("shortName")
2680 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2681 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2682 if let Some(id) = attrs.get("id") {
2683 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2684 }
2685 if let Some(t) = attrs.get("text") {
2686 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2687 }
2688 if let Some(lid) = attrs.get("localId") {
2689 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2690 }
2691 AdfNode {
2692 node_type: "emoji".to_string(),
2693 attrs: Some(emoji_attrs),
2694 content: None,
2695 text: None,
2696 marks: None,
2697 local_id: None,
2698 parameters: None,
2699 }
2700}
2701
2702fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2707 let mut depth: usize = 0;
2708 for (j, ch) in s[open..].char_indices() {
2709 match ch {
2710 '(' => depth += 1,
2711 ')' => {
2712 depth -= 1;
2713 if depth == 0 {
2714 return Some(open + j);
2715 }
2716 }
2717 _ => {}
2718 }
2719 }
2720 None
2721}
2722
2723fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2729 let rest = &text[i..];
2730 if !rest.starts_with('[') {
2731 return None;
2732 }
2733
2734 let mut depth: usize = 0;
2736 let mut text_end = None;
2737 let bytes = rest.as_bytes();
2738 for (j, ch) in rest.char_indices() {
2739 match ch {
2740 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2741 }
2743 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2744 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2745 depth -= 1;
2746 if depth == 0 {
2747 text_end = Some(j);
2748 break;
2749 }
2750 }
2751 _ => {}
2752 }
2753 }
2754
2755 let text_end = text_end?;
2756 let link_text = &rest[1..text_end];
2757 let after_bracket = &rest[text_end + 1..];
2759 if !after_bracket.starts_with('(') {
2760 return None;
2761 }
2762 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2764 let href = &rest[url_start + 1..url_end];
2765
2766 Some((i + url_end + 1, link_text, href))
2767}
2768
2769#[derive(Debug, Clone, Default)]
2773pub struct RenderOptions {
2774 pub strip_local_ids: bool,
2776}
2777
2778pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2780 adf_to_markdown_with_options(doc, &RenderOptions::default())
2781}
2782
2783pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2785 let mut output = String::new();
2786
2787 for (i, node) in doc.content.iter().enumerate() {
2788 if i > 0 {
2789 output.push('\n');
2790 }
2791 render_block_node(node, &mut output, opts);
2792 }
2793
2794 Ok(output)
2795}
2796
2797fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2801 if let Some(ref attrs) = dir_attrs {
2802 if let Some(local_id) = attrs.get("localId") {
2803 if let Some(ref mut node_attrs) = node.attrs {
2804 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2805 } else {
2806 node.attrs = Some(serde_json::json!({"localId": local_id}));
2807 }
2808 }
2809 }
2810}
2811
2812fn pass_through_expand_params(
2815 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2816 node: &mut AdfNode,
2817) {
2818 if let Some(ref attrs) = dir_attrs {
2819 if let Some(local_id) = attrs.get("localId") {
2820 node.local_id = Some(local_id.to_string());
2821 }
2822 if let Some(params_str) = attrs.get("params") {
2823 if let Ok(params) = serde_json::from_str(params_str) {
2824 node.parameters = Some(params);
2825 }
2826 }
2827 }
2828}
2829
2830fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2840 let trimmed = text.trim_end();
2841 if !trimmed.ends_with('}') {
2842 return (text, None, None);
2843 }
2844 if let Some(brace_pos) = trimmed.rfind('{') {
2849 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2850 return (text, None, None);
2851 }
2852 let attr_str = &trimmed[brace_pos..];
2853 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2854 let local_id = attrs.get("localId").map(str::to_string);
2855 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2856 if local_id.is_some() || para_local_id.is_some() {
2857 let before = trimmed[..brace_pos]
2858 .strip_suffix(' ')
2859 .unwrap_or(&trimmed[..brace_pos]);
2860 return (before, local_id, para_local_id);
2861 }
2862 }
2863 }
2864 (text, None, None)
2865}
2866
2867fn parse_list_item_first_line(
2874 item_text: &str,
2875 sub_lines: Vec<String>,
2876 local_id: Option<String>,
2877 para_local_id: Option<String>,
2878) -> Result<AdfNode> {
2879 if item_text.starts_with("```") {
2880 let mut all_lines = vec![item_text.to_string()];
2882 all_lines.extend(sub_lines);
2883 let combined = all_lines.join("\n");
2884 let nested = MarkdownParser::new(&combined).parse_blocks()?;
2885 Ok(list_item_with_local_id(nested, local_id, para_local_id))
2886 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2887 if sub_lines.is_empty() {
2889 Ok(list_item_with_local_id(
2890 vec![media],
2891 local_id,
2892 para_local_id,
2893 ))
2894 } else {
2895 let sub_text = sub_lines.join("\n");
2896 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2897 let mut content = vec![media];
2898 content.append(&mut nested);
2899 Ok(list_item_with_local_id(content, local_id, para_local_id))
2900 }
2901 } else {
2902 let first_node = AdfNode::paragraph(parse_inline(item_text));
2903 if sub_lines.is_empty() {
2904 Ok(list_item_with_local_id(
2905 vec![first_node],
2906 local_id,
2907 para_local_id,
2908 ))
2909 } else {
2910 let sub_text = sub_lines.join("\n");
2911 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2912 let mut content = vec![first_node];
2913 content.append(&mut nested);
2914 Ok(list_item_with_local_id(content, local_id, para_local_id))
2915 }
2916 }
2917}
2918
2919fn list_item_with_local_id(
2920 mut content: Vec<AdfNode>,
2921 local_id: Option<String>,
2922 para_local_id: Option<String>,
2923) -> AdfNode {
2924 if let Some(id) = ¶_local_id {
2925 if let Some(first) = content.first_mut() {
2926 if first.node_type == "paragraph" {
2927 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2928 node_attrs["localId"] = serde_json::Value::String(id.clone());
2929 }
2930 }
2931 }
2932 let mut item = AdfNode::list_item(content);
2933 if let Some(id) = local_id {
2934 item.attrs = Some(serde_json::json!({"localId": id}));
2935 }
2936 item
2937}
2938
2939fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2940 if opts.strip_local_ids {
2941 return;
2942 }
2943 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2944 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2945 parts.push(format_kv("localId", local_id));
2946 }
2947 }
2948}
2949
2950fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2952 for (i, child) in children.iter().enumerate() {
2953 if i > 0 {
2954 output.push('\n');
2955 }
2956 render_block_node(child, output, opts);
2957 }
2958}
2959
2960fn fmt_f64_attr(v: f64) -> String {
2963 if v.fract() == 0.0 {
2964 format!("{}", v as i64)
2965 } else {
2966 v.to_string()
2967 }
2968}
2969
2970fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
2977 if s.contains('.') || s.contains('e') || s.contains('E') {
2978 s.parse::<f64>().ok().map(serde_json::Value::from)
2979 } else {
2980 s.parse::<i64>().ok().map(serde_json::Value::from)
2981 }
2982}
2983
2984fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
2992 if let Some(n) = v.as_i64() {
2993 return Some(n.to_string());
2994 }
2995 if let Some(n) = v.as_u64() {
2996 return Some(n.to_string());
2997 }
2998 if let Some(n) = v.as_f64() {
2999 if n.fract() == 0.0 && n.is_finite() {
3000 return Some(format!("{n:.1}"));
3001 }
3002 return Some(n.to_string());
3003 }
3004 None
3005}
3006
3007fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3009 match node.node_type.as_str() {
3010 "paragraph" => {
3011 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3012 let dir_attrs = {
3014 let mut parts = Vec::new();
3015 if let Some(ref attrs) = node.attrs {
3016 maybe_push_local_id(attrs, &mut parts, opts);
3017 }
3018 if parts.is_empty() {
3019 String::new()
3020 } else {
3021 format!("{{{}}}", parts.join(" "))
3022 }
3023 };
3024 if is_empty {
3025 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3026 } else {
3027 let mut buf = String::new();
3029 render_inline_content(node, &mut buf, opts);
3030 if buf.trim().is_empty() && !buf.is_empty() {
3031 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3034 } else {
3035 let mut is_first_line = true;
3040 for line in buf.split('\n') {
3041 if is_first_line {
3042 if is_list_start(line) {
3043 output.push_str(&escape_list_marker(line));
3044 } else {
3045 output.push_str(line);
3046 }
3047 is_first_line = false;
3048 } else {
3049 output.push('\n');
3050 if !line.is_empty() {
3051 output.push_str(" ");
3052 }
3053 output.push_str(line);
3054 }
3055 }
3056 output.push('\n');
3057 }
3058 }
3059 }
3060 "heading" => {
3061 let level = node
3062 .attrs
3063 .as_ref()
3064 .and_then(|a| a.get("level"))
3065 .and_then(serde_json::Value::as_u64)
3066 .unwrap_or(1);
3067 for _ in 0..level {
3068 output.push('#');
3069 }
3070 output.push(' ');
3071 let mut buf = String::new();
3072 render_inline_content(node, &mut buf, opts);
3073 let mut is_first_line = true;
3076 for line in buf.split('\n') {
3077 if is_first_line {
3078 output.push_str(line);
3079 is_first_line = false;
3080 } else {
3081 output.push('\n');
3082 if !line.is_empty() {
3083 output.push_str(" ");
3084 }
3085 output.push_str(line);
3086 }
3087 }
3088 output.push('\n');
3089 }
3090 "codeBlock" => {
3091 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3092 let language = language_value
3093 .and_then(serde_json::Value::as_str)
3094 .unwrap_or("");
3095 output.push_str("```");
3096 if language.is_empty() && language_value.is_some() {
3097 output.push_str("\"\"");
3100 } else {
3101 output.push_str(language);
3102 }
3103 output.push('\n');
3104 if let Some(ref content) = node.content {
3105 for child in content {
3106 if let Some(ref text) = child.text {
3107 output.push_str(text);
3108 }
3109 }
3110 }
3111 output.push_str("\n```\n");
3112 }
3113 "blockquote" => {
3114 if let Some(ref content) = node.content {
3115 for (i, child) in content.iter().enumerate() {
3116 if i > 0
3120 && child.node_type == "paragraph"
3121 && content[i - 1].node_type == "paragraph"
3122 {
3123 output.push_str(">\n");
3124 }
3125 let mut inner = String::new();
3126 render_block_node(child, &mut inner, opts);
3127 for line in inner.lines() {
3128 output.push_str("> ");
3129 output.push_str(line);
3130 output.push('\n');
3131 }
3132 }
3133 }
3134 }
3135 "bulletList" => {
3136 if let Some(ref items) = node.content {
3137 for item in items {
3138 output.push_str("- ");
3139 let content_start = output.len();
3140 render_list_item_content(item, output, opts);
3141 if starts_with_task_marker(&output[content_start..]) {
3147 output.insert(content_start, '\\');
3148 }
3149 }
3150 }
3151 }
3152 "orderedList" => {
3153 let start = node
3154 .attrs
3155 .as_ref()
3156 .and_then(|a| a.get("order"))
3157 .and_then(serde_json::Value::as_u64)
3158 .unwrap_or(1);
3159 if let Some(ref items) = node.content {
3160 for (i, item) in items.iter().enumerate() {
3161 let num = start + i as u64;
3162 output.push_str(&format!("{num}. "));
3163 render_list_item_content(item, output, opts);
3164 }
3165 }
3166 }
3167 "taskList" => {
3168 if let Some(ref items) = node.content {
3169 for item in items {
3170 if item.node_type == "taskList" {
3171 let mut nested = String::new();
3175 render_block_node(item, &mut nested, opts);
3176 for line in nested.lines() {
3177 output.push_str(" ");
3178 output.push_str(line);
3179 output.push('\n');
3180 }
3181 } else {
3182 let state = item
3183 .attrs
3184 .as_ref()
3185 .and_then(|a| a.get("state"))
3186 .and_then(serde_json::Value::as_str)
3187 .unwrap_or("TODO");
3188 if state == "DONE" {
3189 output.push_str("- [x] ");
3190 } else {
3191 output.push_str("- [ ] ");
3192 }
3193 render_list_item_content(item, output, opts);
3194 }
3195 }
3196 }
3197 }
3198 "rule" => {
3199 output.push_str("---\n");
3200 }
3201 "table" => {
3202 render_table(node, output, opts);
3203 }
3204 "mediaSingle" => {
3205 if let Some(ref content) = node.content {
3206 for child in content {
3207 if child.node_type == "media" {
3208 render_media(child, node.attrs.as_ref(), output, opts);
3209 }
3210 }
3211 for child in content {
3212 if child.node_type == "caption" {
3213 let mut cap_parts = Vec::new();
3214 if let Some(ref attrs) = child.attrs {
3215 maybe_push_local_id(attrs, &mut cap_parts, opts);
3216 }
3217 if cap_parts.is_empty() {
3218 output.push_str(":::caption\n");
3219 } else {
3220 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3221 }
3222 if let Some(ref caption_content) = child.content {
3223 for inline in caption_content {
3224 render_inline_node(inline, output, opts);
3225 }
3226 output.push('\n');
3227 }
3228 output.push_str(":::\n");
3229 }
3230 }
3231 }
3232 }
3233 "blockCard" => {
3234 if let Some(ref attrs) = node.attrs {
3235 let url = attrs
3236 .get("url")
3237 .and_then(serde_json::Value::as_str)
3238 .unwrap_or("");
3239 let mut attr_parts = Vec::new();
3240 if url_safe_in_bracket_content(url) {
3241 output.push_str(&format!("::card[{url}]"));
3242 } else {
3243 output.push_str("::card[]");
3245 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3246 attr_parts.push(format!("url=\"{escaped}\""));
3247 }
3248 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3249 attr_parts.push(format!("layout={layout}"));
3250 }
3251 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3252 attr_parts.push(format!("width={width}"));
3253 }
3254 if !attr_parts.is_empty() {
3255 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3256 }
3257 output.push('\n');
3258 }
3259 }
3260 "embedCard" => {
3261 if let Some(ref attrs) = node.attrs {
3262 let url = attrs
3263 .get("url")
3264 .and_then(serde_json::Value::as_str)
3265 .unwrap_or("");
3266 output.push_str(&format!("::embed[{url}]"));
3267 let mut attr_parts = Vec::new();
3268 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3269 attr_parts.push(format!("layout={layout}"));
3270 }
3271 if let Some(h) = attrs
3272 .get("originalHeight")
3273 .and_then(serde_json::Value::as_f64)
3274 {
3275 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3276 }
3277 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3278 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3279 }
3280 if !attr_parts.is_empty() {
3281 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3282 }
3283 output.push('\n');
3284 }
3285 }
3286 "extension" => {
3287 if let Some(ref attrs) = node.attrs {
3288 let ext_type = attrs
3289 .get("extensionType")
3290 .and_then(serde_json::Value::as_str)
3291 .unwrap_or("");
3292 let ext_key = attrs
3293 .get("extensionKey")
3294 .and_then(serde_json::Value::as_str)
3295 .unwrap_or("");
3296 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3297 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3298 attr_parts.push(format!("layout={layout}"));
3299 }
3300 if let Some(params) = attrs.get("parameters") {
3301 if let Ok(json_str) = serde_json::to_string(params) {
3302 attr_parts.push(format!("params='{json_str}'"));
3303 }
3304 }
3305 maybe_push_local_id(attrs, &mut attr_parts, opts);
3306 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3307 }
3308 }
3309 "panel" => {
3310 let panel_type = node
3311 .attrs
3312 .as_ref()
3313 .and_then(|a| a.get("panelType"))
3314 .and_then(serde_json::Value::as_str)
3315 .unwrap_or("info");
3316 let mut attr_parts = vec![format!("type={panel_type}")];
3317 if let Some(ref attrs) = node.attrs {
3318 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3319 attr_parts.push(format!("icon=\"{icon}\""));
3320 }
3321 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3322 attr_parts.push(format!("color=\"{color}\""));
3323 }
3324 }
3325 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3326 if let Some(ref content) = node.content {
3327 render_block_children(content, output, opts);
3328 }
3329 output.push_str(":::\n");
3330 }
3331 "expand" | "nestedExpand" => {
3332 let directive_name = if node.node_type == "nestedExpand" {
3333 "nested-expand"
3334 } else {
3335 "expand"
3336 };
3337 let mut attr_parts = Vec::new();
3338 if let Some(t) = node
3339 .attrs
3340 .as_ref()
3341 .and_then(|a| a.get("title"))
3342 .and_then(serde_json::Value::as_str)
3343 {
3344 attr_parts.push(format!("title=\"{t}\""));
3345 }
3346 if let Some(ref lid) = node.local_id {
3348 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3349 attr_parts.push(format!("localId={lid}"));
3350 }
3351 } else if let Some(ref attrs) = node.attrs {
3352 maybe_push_local_id(attrs, &mut attr_parts, opts);
3353 }
3354 if let Some(ref params) = node.parameters {
3356 if let Ok(json_str) = serde_json::to_string(params) {
3357 attr_parts.push(format!("params='{json_str}'"));
3358 }
3359 }
3360 if attr_parts.is_empty() {
3361 output.push_str(&format!(":::{directive_name}\n"));
3362 } else {
3363 output.push_str(&format!(
3364 ":::{directive_name}{{{}}}\n",
3365 attr_parts.join(" ")
3366 ));
3367 }
3368 if let Some(ref content) = node.content {
3369 render_block_children(content, output, opts);
3370 }
3371 output.push_str(":::\n");
3372 }
3373 "layoutSection" => {
3374 output.push_str("::::layout\n");
3375 if let Some(ref content) = node.content {
3376 for child in content {
3377 if child.node_type == "layoutColumn" {
3378 let width_str = child
3379 .attrs
3380 .as_ref()
3381 .and_then(|a| a.get("width"))
3382 .and_then(fmt_numeric_attr)
3383 .unwrap_or_else(|| "50".to_string());
3384 let mut parts = vec![format!("width={width_str}")];
3385 if let Some(ref attrs) = child.attrs {
3386 maybe_push_local_id(attrs, &mut parts, opts);
3387 }
3388 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3389 if let Some(ref col_content) = child.content {
3390 render_block_children(col_content, output, opts);
3391 }
3392 output.push_str(":::\n");
3393 }
3394 }
3395 }
3396 output.push_str("::::\n");
3397 }
3398 "decisionList" => {
3399 output.push_str(":::decisions\n");
3400 if let Some(ref content) = node.content {
3401 for item in content {
3402 output.push_str("- <> ");
3403 render_list_item_content(item, output, opts);
3404 }
3405 }
3406 output.push_str(":::\n");
3407 }
3408 "bodiedExtension" => {
3409 if let Some(ref attrs) = node.attrs {
3410 let ext_type = attrs
3411 .get("extensionType")
3412 .and_then(serde_json::Value::as_str)
3413 .unwrap_or("");
3414 let ext_key = attrs
3415 .get("extensionKey")
3416 .and_then(serde_json::Value::as_str)
3417 .unwrap_or("");
3418 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3419 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3420 attr_parts.push(format!("layout={layout}"));
3421 }
3422 if let Some(params) = attrs.get("parameters") {
3423 if let Ok(json_str) = serde_json::to_string(params) {
3424 attr_parts.push(format!("params='{json_str}'"));
3425 }
3426 }
3427 maybe_push_local_id(attrs, &mut attr_parts, opts);
3428 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3429 if let Some(ref content) = node.content {
3430 render_block_children(content, output, opts);
3431 }
3432 output.push_str(":::\n");
3433 }
3434 }
3435 _ => {
3436 if let Ok(json) = serde_json::to_string_pretty(node) {
3438 output.push_str("```adf-unsupported\n");
3439 output.push_str(&json);
3440 output.push_str("\n```\n");
3441 }
3442 }
3443 }
3444
3445 let mut parts = Vec::new();
3447 if let Some(ref marks) = node.marks {
3448 for mark in marks {
3449 match mark.mark_type.as_str() {
3450 "alignment" => {
3451 if let Some(align) = mark
3452 .attrs
3453 .as_ref()
3454 .and_then(|a| a.get("align"))
3455 .and_then(serde_json::Value::as_str)
3456 {
3457 parts.push(format!("align={align}"));
3458 }
3459 }
3460 "indentation" => {
3461 if let Some(level) = mark
3462 .attrs
3463 .as_ref()
3464 .and_then(|a| a.get("level"))
3465 .and_then(serde_json::Value::as_u64)
3466 {
3467 parts.push(format!("indent={level}"));
3468 }
3469 }
3470 "breakout" => {
3471 if let Some(mode) = mark
3472 .attrs
3473 .as_ref()
3474 .and_then(|a| a.get("mode"))
3475 .and_then(serde_json::Value::as_str)
3476 {
3477 parts.push(format!("breakout={mode}"));
3478 }
3479 if let Some(width) = mark
3480 .attrs
3481 .as_ref()
3482 .and_then(|a| a.get("width"))
3483 .and_then(serde_json::Value::as_u64)
3484 {
3485 parts.push(format!("breakoutWidth={width}"));
3486 }
3487 }
3488 _ => {}
3489 }
3490 }
3491 }
3492 let para_used_directive = node.node_type == "paragraph" && {
3496 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3497 if is_empty {
3498 true
3499 } else {
3500 let mut buf = String::new();
3501 render_inline_content(node, &mut buf, opts);
3502 buf.trim().is_empty() && !buf.is_empty()
3503 }
3504 };
3505 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3506 if let Some(ref attrs) = node.attrs {
3507 maybe_push_local_id(attrs, &mut parts, opts);
3508 }
3509 }
3510 if node.node_type == "orderedList" {
3515 if let Some(ref attrs) = node.attrs {
3516 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3517 parts.push("order=1".to_string());
3518 }
3519 }
3520 }
3521 if !parts.is_empty() {
3522 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3523 }
3524}
3525
3526fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3534 let Some(ref content) = item.content else {
3535 let bare = AdfNode::text("");
3537 emit_list_item_local_ids(item, &bare, output, opts);
3538 output.push('\n');
3539 return;
3540 };
3541 if content.is_empty() {
3542 let bare = AdfNode::text("");
3543 emit_list_item_local_ids(item, &bare, output, opts);
3544 output.push('\n');
3545 return;
3546 }
3547 let first = &content[0];
3548 let rest_start;
3549 if first.node_type == "paragraph" {
3550 let mut buf = String::new();
3551 render_inline_content(first, &mut buf, opts);
3552 let buf = buf.trim_end_matches('\n');
3558 let mut is_first_line = true;
3561 for line in buf.split('\n') {
3562 if is_first_line {
3563 output.push_str(line);
3564 is_first_line = false;
3565 } else {
3566 output.push('\n');
3567 if !line.is_empty() {
3568 output.push_str(" ");
3569 }
3570 output.push_str(line);
3571 }
3572 }
3573 emit_list_item_local_ids(item, first, output, opts);
3575 output.push('\n');
3576 rest_start = 1;
3577 } else if is_inline_node_type(&first.node_type) {
3578 rest_start = content
3580 .iter()
3581 .position(|c| !is_inline_node_type(&c.node_type))
3582 .unwrap_or(content.len());
3583 let mut buf = String::new();
3584 for child in &content[..rest_start] {
3585 render_inline_node(child, &mut buf, opts);
3586 }
3587 let buf = buf.trim_end_matches('\n');
3590 let mut is_first_line = true;
3591 for line in buf.split('\n') {
3592 if is_first_line {
3593 output.push_str(line);
3594 is_first_line = false;
3595 } else {
3596 output.push('\n');
3597 if !line.is_empty() {
3598 output.push_str(" ");
3599 }
3600 output.push_str(line);
3601 }
3602 }
3603 let bare = AdfNode::text("");
3605 emit_list_item_local_ids(item, &bare, output, opts);
3606 output.push('\n');
3607 } else if first.node_type == "taskItem" {
3610 let bare = AdfNode::text("");
3615 emit_list_item_local_ids(item, &bare, output, opts);
3616 output.push('\n');
3617 for child in content {
3618 if child.node_type == "taskItem" {
3619 let state = child
3620 .attrs
3621 .as_ref()
3622 .and_then(|a| a.get("state"))
3623 .and_then(serde_json::Value::as_str)
3624 .unwrap_or("TODO");
3625 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3626 output.push_str(" ");
3627 output.push_str(marker);
3628 render_list_item_content(child, output, opts);
3629 } else {
3630 let mut nested = String::new();
3631 render_block_node(child, &mut nested, opts);
3632 for line in nested.lines() {
3633 output.push_str(" ");
3634 output.push_str(line);
3635 output.push('\n');
3636 }
3637 }
3638 }
3639 return;
3640 } else {
3641 let mut buf = String::new();
3647 render_block_node(first, &mut buf, opts);
3648 let bare = AdfNode::text("");
3649 let mut is_first = true;
3650 for line in buf.lines() {
3651 if is_first {
3652 output.push_str(line);
3653 emit_list_item_local_ids(item, &bare, output, opts);
3654 output.push('\n');
3655 is_first = false;
3656 } else {
3657 output.push_str(" ");
3658 output.push_str(line);
3659 output.push('\n');
3660 }
3661 }
3662 rest_start = 1;
3663 }
3664 let rest = &content[rest_start..];
3665 for (i, child) in rest.iter().enumerate() {
3666 if child.node_type == "paragraph" {
3670 let prev_is_para = if i == 0 {
3671 first.node_type == "paragraph"
3674 } else {
3675 rest[i - 1].node_type == "paragraph"
3676 };
3677 if prev_is_para {
3678 output.push_str(" \n");
3679 }
3680 }
3681 let mut nested = String::new();
3682 render_block_node(child, &mut nested, opts);
3683 for line in nested.lines() {
3684 output.push_str(" ");
3685 output.push_str(line);
3686 output.push('\n');
3687 }
3688 }
3689}
3690
3691fn is_inline_node_type(node_type: &str) -> bool {
3693 matches!(
3694 node_type,
3695 "text"
3696 | "hardBreak"
3697 | "inlineCard"
3698 | "emoji"
3699 | "mention"
3700 | "status"
3701 | "date"
3702 | "placeholder"
3703 | "mediaInline"
3704 )
3705}
3706
3707fn emit_list_item_local_ids(
3710 item: &AdfNode,
3711 paragraph: &AdfNode,
3712 output: &mut String,
3713 opts: &RenderOptions,
3714) {
3715 if opts.strip_local_ids {
3716 return;
3717 }
3718 let mut parts = Vec::new();
3719 if let Some(ref attrs) = item.attrs {
3720 maybe_push_local_id(attrs, &mut parts, opts);
3721 }
3722 if paragraph.node_type == "paragraph" {
3723 let has_real_id = paragraph
3724 .attrs
3725 .as_ref()
3726 .and_then(|a| a.get("localId"))
3727 .and_then(serde_json::Value::as_str)
3728 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3729 if let Some(local_id) = has_real_id {
3730 parts.push(format!("paraLocalId={local_id}"));
3731 } else if item.node_type == "taskItem" {
3732 parts.push("paraLocalId=_".to_string());
3736 }
3737 }
3738 if !parts.is_empty() {
3739 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3740 }
3741}
3742
3743fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3745 let Some(ref rows) = node.content else {
3746 return;
3747 };
3748
3749 if table_qualifies_for_pipe_syntax(rows) {
3750 render_pipe_table(node, rows, output, opts);
3751 } else {
3752 render_directive_table(node, rows, output, opts);
3753 }
3754}
3755
3756fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3763 if rows.iter().any(|n| n.node_type == "caption") {
3765 return false;
3766 }
3767 let mut first_row_has_header = false;
3768 for (row_idx, row) in rows.iter().enumerate() {
3769 let Some(ref cells) = row.content else {
3770 continue;
3771 };
3772 for cell in cells {
3773 if row_idx > 0 && cell.node_type == "tableHeader" {
3775 return false;
3776 }
3777 if row_idx == 0 && cell.node_type == "tableHeader" {
3778 first_row_has_header = true;
3779 }
3780 let Some(ref content) = cell.content else {
3782 continue;
3783 };
3784 if content.len() != 1 || content[0].node_type != "paragraph" {
3785 return false;
3786 }
3787 if cell_contains_hard_break(&content[0]) {
3790 return false;
3791 }
3792 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3795 return false;
3796 }
3797 if content[0]
3800 .attrs
3801 .as_ref()
3802 .and_then(|a| a.get("localId"))
3803 .is_some()
3804 {
3805 return false;
3806 }
3807 }
3808 }
3809 first_row_has_header
3812}
3813
3814fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3816 paragraph
3817 .content
3818 .as_ref()
3819 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3820}
3821
3822fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3824 for (row_idx, row) in rows.iter().enumerate() {
3825 let Some(ref cells) = row.content else {
3826 continue;
3827 };
3828
3829 output.push('|');
3830 for cell in cells {
3831 output.push(' ');
3832 let mut cell_buf = String::new();
3833 render_cell_attrs_prefix(cell, &mut cell_buf);
3834 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3835 output.push_str(&escape_pipes_in_cell(&cell_buf));
3836 output.push_str(" |");
3837 }
3838 output.push('\n');
3839
3840 if row_idx == 0 {
3842 output.push('|');
3843 for cell in cells {
3844 let align = get_cell_paragraph_alignment(cell);
3845 match align {
3846 Some("center") => output.push_str(" :---: |"),
3847 Some("end") => output.push_str(" ---: |"),
3848 _ => output.push_str(" --- |"),
3849 }
3850 }
3851 output.push('\n');
3852 }
3853 }
3854
3855 render_table_level_attrs(node, output, opts);
3857}
3858
3859fn render_directive_table(
3861 node: &AdfNode,
3862 rows: &[AdfNode],
3863 output: &mut String,
3864 opts: &RenderOptions,
3865) {
3866 let mut attr_parts = Vec::new();
3868 if let Some(ref attrs) = node.attrs {
3869 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3870 attr_parts.push(format!("layout={layout}"));
3871 }
3872 if let Some(numbered) = attrs
3873 .get("isNumberColumnEnabled")
3874 .and_then(serde_json::Value::as_bool)
3875 {
3876 if numbered {
3877 attr_parts.push("numbered".to_string());
3878 } else {
3879 attr_parts.push("numbered=false".to_string());
3880 }
3881 }
3882 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3883 let tw_str = if tw.fract() == 0.0 {
3884 (tw as u64).to_string()
3885 } else {
3886 tw.to_string()
3887 };
3888 attr_parts.push(format!("width={tw_str}"));
3889 }
3890 maybe_push_local_id(attrs, &mut attr_parts, opts);
3891 }
3892 if attr_parts.is_empty() {
3893 output.push_str("::::table\n");
3894 } else {
3895 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3896 }
3897
3898 for row in rows {
3899 if row.node_type == "caption" {
3900 let mut cap_parts = Vec::new();
3901 if let Some(ref attrs) = row.attrs {
3902 maybe_push_local_id(attrs, &mut cap_parts, opts);
3903 }
3904 if cap_parts.is_empty() {
3905 output.push_str(":::caption\n");
3906 } else {
3907 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3908 }
3909 if let Some(ref content) = row.content {
3910 for child in content {
3911 render_inline_node(child, output, opts);
3912 }
3913 output.push('\n');
3914 }
3915 output.push_str(":::\n");
3916 continue;
3917 }
3918 let Some(ref cells) = row.content else {
3919 continue;
3920 };
3921 let mut tr_attrs = Vec::new();
3923 if let Some(ref attrs) = row.attrs {
3924 maybe_push_local_id(attrs, &mut tr_attrs, opts);
3925 }
3926 if tr_attrs.is_empty() {
3927 output.push_str(":::tr\n");
3928 } else {
3929 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3930 }
3931 for cell in cells {
3932 let directive_name = if cell.node_type == "tableHeader" {
3933 "th"
3934 } else {
3935 "td"
3936 };
3937 let mut cell_attr_str = build_cell_attrs_string(cell);
3938 if let Some(ref attrs) = cell.attrs {
3940 let mut lid_parts = Vec::new();
3941 maybe_push_local_id(attrs, &mut lid_parts, opts);
3942 if !lid_parts.is_empty() {
3943 if !cell_attr_str.is_empty() {
3944 cell_attr_str.push(' ');
3945 }
3946 cell_attr_str.push_str(&lid_parts.join(" "));
3947 }
3948 }
3949 if let Some(ref marks) = cell.marks {
3951 for mark in marks {
3952 if mark.mark_type == "border" {
3953 if let Some(ref attrs) = mark.attrs {
3954 if let Some(color) =
3955 attrs.get("color").and_then(serde_json::Value::as_str)
3956 {
3957 if !cell_attr_str.is_empty() {
3958 cell_attr_str.push(' ');
3959 }
3960 cell_attr_str.push_str(&format!("border-color={color}"));
3961 }
3962 if let Some(size) =
3963 attrs.get("size").and_then(serde_json::Value::as_u64)
3964 {
3965 if !cell_attr_str.is_empty() {
3966 cell_attr_str.push(' ');
3967 }
3968 cell_attr_str.push_str(&format!("border-size={size}"));
3969 }
3970 }
3971 }
3972 }
3973 }
3974 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3975 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3976 output.push_str(&format!(":::{directive_name}\n"));
3977 } else {
3978 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3979 }
3980 if let Some(ref content) = cell.content {
3981 render_block_children(content, output, opts);
3982 }
3983 output.push_str(":::\n");
3984 }
3985 output.push_str(":::\n");
3986 }
3987
3988 output.push_str("::::\n");
3989}
3990
3991fn needs_attr_quoting(value: &str) -> bool {
3995 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3996}
3997
3998fn build_cell_attrs_string(cell: &AdfNode) -> String {
4000 let Some(ref attrs) = cell.attrs else {
4001 return String::new();
4002 };
4003 let mut parts = Vec::new();
4004 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4005 parts.push(format!("colspan={colspan}"));
4006 }
4007 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4008 parts.push(format!("rowspan={rowspan}"));
4009 }
4010 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4011 if needs_attr_quoting(bg) {
4012 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4013 parts.push(format!("bg=\"{escaped}\""));
4014 } else {
4015 parts.push(format!("bg={bg}"));
4016 }
4017 }
4018 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4019 let widths: Vec<String> = colwidth
4020 .iter()
4021 .filter_map(|v| {
4022 if let Some(n) = v.as_u64() {
4025 Some(n.to_string())
4026 } else if let Some(n) = v.as_f64() {
4027 if n.fract() == 0.0 {
4028 format!("{n:.1}")
4029 } else {
4030 n.to_string()
4031 }
4032 .into()
4033 } else {
4034 None
4035 }
4036 })
4037 .collect();
4038 if !widths.is_empty() {
4039 parts.push(format!("colwidth={}", widths.join(",")));
4040 }
4041 }
4042 parts.join(" ")
4043}
4044
4045fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4047 let Some(ref _attrs) = cell.attrs else {
4048 return;
4049 };
4050 let attr_str = build_cell_attrs_string(cell);
4051 if attr_str.is_empty() {
4052 output.push_str("{} ");
4053 } else {
4054 output.push_str(&format!("{{{attr_str}}} "));
4055 }
4056}
4057
4058fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4060 let content = cell.content.as_ref()?;
4061 let para = content.first()?;
4062 let marks = para.marks.as_ref()?;
4063 marks.iter().find_map(|m| {
4064 if m.mark_type == "alignment" {
4065 m.attrs
4066 .as_ref()
4067 .and_then(|a| a.get("align"))
4068 .and_then(serde_json::Value::as_str)
4069 } else {
4070 None
4071 }
4072 })
4073}
4074
4075fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4077 if let Some(ref attrs) = node.attrs {
4078 let mut parts = Vec::new();
4079 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4080 parts.push(format!("layout={layout}"));
4081 }
4082 if let Some(numbered) = attrs
4083 .get("isNumberColumnEnabled")
4084 .and_then(serde_json::Value::as_bool)
4085 {
4086 if numbered {
4087 parts.push("numbered".to_string());
4088 } else {
4089 parts.push("numbered=false".to_string());
4090 }
4091 }
4092 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4093 parts.push(format!("width={tw_str}"));
4094 }
4095 maybe_push_local_id(attrs, &mut parts, opts);
4096 if !parts.is_empty() {
4097 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4098 }
4099 }
4100}
4101
4102fn render_inline_content_from_first_paragraph(
4104 cell: &AdfNode,
4105 output: &mut String,
4106 opts: &RenderOptions,
4107) {
4108 if let Some(ref content) = cell.content {
4109 if let Some(first) = content.first() {
4110 if first.node_type == "paragraph" {
4111 render_inline_content(first, output, opts);
4112 }
4113 }
4114 }
4115}
4116
4117fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4119 if let Some(ref marks) = marks {
4120 for mark in marks {
4121 if mark.mark_type == "border" {
4122 if let Some(ref attrs) = mark.attrs {
4123 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4124 parts.push(format!("border-color={color}"));
4125 }
4126 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4127 parts.push(format!("border-size={size}"));
4128 }
4129 }
4130 }
4131 }
4132 }
4133}
4134
4135fn render_media(
4137 node: &AdfNode,
4138 parent_attrs: Option<&serde_json::Value>,
4139 output: &mut String,
4140 opts: &RenderOptions,
4141) {
4142 if let Some(ref attrs) = node.attrs {
4143 let media_type = attrs
4144 .get("type")
4145 .and_then(serde_json::Value::as_str)
4146 .unwrap_or("external");
4147 let alt = attrs
4148 .get("alt")
4149 .and_then(serde_json::Value::as_str)
4150 .unwrap_or("");
4151
4152 if media_type == "file" {
4153 output.push_str(&format!("![{alt}]()"));
4155 let mut parts = vec!["type=file".to_string()];
4156 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4157 parts.push(format_kv("id", id));
4158 }
4159 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4160 parts.push(format_kv("collection", collection));
4161 }
4162 if let Some(occurrence_key) = attrs
4163 .get("occurrenceKey")
4164 .and_then(serde_json::Value::as_str)
4165 {
4166 parts.push(format_kv("occurrenceKey", occurrence_key));
4167 }
4168 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4169 parts.push(format!("height={height_str}"));
4170 }
4171 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4172 parts.push(format!("width={width_str}"));
4173 }
4174 maybe_push_local_id(attrs, &mut parts, opts);
4175 if let Some(p_attrs) = parent_attrs {
4177 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4178 if layout != "center" {
4179 parts.push(format!("layout={layout}"));
4180 }
4181 }
4182 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4183 parts.push(format!("mediaWidth={ms_width_str}"));
4184 }
4185 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4186 parts.push(format!("widthType={wt}"));
4187 }
4188 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4189 parts.push(format!("mode={mode}"));
4190 }
4191 }
4192 push_border_mark_attrs(&node.marks, &mut parts);
4193 output.push_str(&format!("{{{}}}", parts.join(" ")));
4194 } else {
4195 let url = attrs
4197 .get("url")
4198 .and_then(serde_json::Value::as_str)
4199 .unwrap_or("");
4200 output.push_str(&format!(""));
4201
4202 {
4204 let mut parts = Vec::new();
4205 if let Some(p_attrs) = parent_attrs {
4206 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4207 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4208 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4209 if let Some(l) = layout {
4210 if l != "center" {
4211 parts.push(format!("layout={l}"));
4212 }
4213 }
4214 if let Some(w) = width_str {
4215 parts.push(format!("width={w}"));
4216 }
4217 if let Some(wt) = width_type {
4218 parts.push(format!("widthType={wt}"));
4219 }
4220 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4221 parts.push(format!("mode={mode}"));
4222 }
4223 }
4224 maybe_push_local_id(attrs, &mut parts, opts);
4225 push_border_mark_attrs(&node.marks, &mut parts);
4226 if !parts.is_empty() {
4227 output.push_str(&format!("{{{}}}", parts.join(" ")));
4228 }
4229 }
4230 }
4231
4232 output.push('\n');
4233 }
4234}
4235
4236fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4238 if let Some(ref content) = node.content {
4239 for child in content {
4240 render_inline_node(child, output, opts);
4241 }
4242 }
4243}
4244
4245fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4247 match node.node_type.as_str() {
4248 "text" => {
4249 let text = node.text.as_deref().unwrap_or("");
4250 let marks = node.marks.as_deref().unwrap_or(&[]);
4251 let has_code = marks.iter().any(|m| m.mark_type == "code");
4252 let owned;
4257 let text = if !has_code {
4258 owned = text.replace('\\', "\\\\");
4259 owned.as_str()
4260 } else {
4261 text
4262 };
4263 let owned_nl;
4268 let text = if text.contains('\n') {
4269 owned_nl = text.replace('\n', "\\n");
4270 owned_nl.as_str()
4271 } else {
4272 text
4273 };
4274 let owned_ts;
4280 let text = if !has_code && text.ends_with(" ") {
4281 let mut s = text.to_string();
4282 s.insert(s.len() - 1, '\\');
4284 owned_ts = s;
4285 owned_ts.as_str()
4286 } else {
4287 text
4288 };
4289 render_marked_text(text, marks, output);
4290 }
4291 "hardBreak" => {
4292 output.push_str("\\\n");
4293 }
4294 other => {
4295 let mut body = String::new();
4299 render_non_text_inline_body(other, node, &mut body, opts);
4300
4301 let annotations: Vec<&AdfMark> = node
4302 .marks
4303 .as_deref()
4304 .unwrap_or(&[])
4305 .iter()
4306 .filter(|m| m.mark_type == "annotation")
4307 .collect();
4308
4309 if annotations.is_empty() {
4310 output.push_str(&body);
4311 } else {
4312 let mut attr_parts = Vec::new();
4313 for ann in &annotations {
4314 if let Some(ref attrs) = ann.attrs {
4315 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4316 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4317 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4318 }
4319 if let Some(at) = attrs
4320 .get("annotationType")
4321 .and_then(serde_json::Value::as_str)
4322 {
4323 attr_parts.push(format!("annotation-type={at}"));
4324 }
4325 }
4326 }
4327 output.push('[');
4328 output.push_str(&body);
4329 output.push_str("]{");
4330 output.push_str(&attr_parts.join(" "));
4331 output.push('}');
4332 }
4333 }
4334 }
4335}
4336
4337fn render_non_text_inline_body(
4339 node_type: &str,
4340 node: &AdfNode,
4341 output: &mut String,
4342 opts: &RenderOptions,
4343) {
4344 match node_type {
4345 "inlineCard" => {
4346 if let Some(ref attrs) = node.attrs {
4347 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4348 let mut attr_parts = Vec::new();
4349 if url_safe_in_bracket_content(url) {
4350 output.push_str(":card[");
4351 output.push_str(url);
4352 output.push(']');
4353 } else {
4354 output.push_str(":card[]");
4358 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4359 attr_parts.push(format!("url=\"{escaped}\""));
4360 }
4361 maybe_push_local_id(attrs, &mut attr_parts, opts);
4362 if !attr_parts.is_empty() {
4363 output.push('{');
4364 output.push_str(&attr_parts.join(" "));
4365 output.push('}');
4366 }
4367 }
4368 }
4369 }
4370 "emoji" => {
4371 if let Some(ref attrs) = node.attrs {
4372 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4373 {
4374 output.push(':');
4375 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4376 let name = name.strip_suffix(':').unwrap_or(name);
4377 output.push_str(name);
4378 output.push(':');
4379
4380 let mut parts = Vec::new();
4381 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4382 parts.push(format!("shortName=\"{escaped_sn}\""));
4383 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4384 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4385 parts.push(format!("id=\"{escaped}\""));
4386 }
4387 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4388 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4389 parts.push(format!("text=\"{escaped}\""));
4390 }
4391 maybe_push_local_id(attrs, &mut parts, opts);
4392 output.push('{');
4393 output.push_str(&parts.join(" "));
4394 output.push('}');
4395 }
4396 }
4397 }
4398 "status" => {
4399 if let Some(ref attrs) = node.attrs {
4400 let text = attrs
4401 .get("text")
4402 .and_then(serde_json::Value::as_str)
4403 .unwrap_or("");
4404 let color = attrs
4405 .get("color")
4406 .and_then(serde_json::Value::as_str)
4407 .unwrap_or("neutral");
4408 let mut attr_parts = vec![format!("color={color}")];
4409 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4410 attr_parts.push(format!("style={style}"));
4411 }
4412 maybe_push_local_id(attrs, &mut attr_parts, opts);
4413 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4414 }
4415 }
4416 "date" => {
4417 if let Some(ref attrs) = node.attrs {
4418 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4419 {
4420 let display = epoch_ms_to_iso_date(timestamp);
4421 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4422 maybe_push_local_id(attrs, &mut attr_parts, opts);
4423 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4424 }
4425 }
4426 }
4427 "mention" => {
4428 if let Some(ref attrs) = node.attrs {
4429 let id = attrs
4430 .get("id")
4431 .and_then(serde_json::Value::as_str)
4432 .unwrap_or("");
4433 let text = attrs
4434 .get("text")
4435 .and_then(serde_json::Value::as_str)
4436 .unwrap_or("");
4437 let mut attr_parts = vec![format!("id={id}")];
4438 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4439 attr_parts.push(format!("userType={ut}"));
4440 }
4441 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4442 attr_parts.push(format!("accessLevel={al}"));
4443 }
4444 maybe_push_local_id(attrs, &mut attr_parts, opts);
4445 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4446 }
4447 }
4448 "placeholder" => {
4449 if let Some(ref attrs) = node.attrs {
4450 let text = attrs
4451 .get("text")
4452 .and_then(serde_json::Value::as_str)
4453 .unwrap_or("");
4454 output.push_str(&format!(":placeholder[{text}]"));
4455 }
4456 }
4457 "inlineExtension" => {
4458 if let Some(ref attrs) = node.attrs {
4459 let ext_type = attrs
4460 .get("extensionType")
4461 .and_then(serde_json::Value::as_str)
4462 .unwrap_or("");
4463 let ext_key = attrs
4464 .get("extensionKey")
4465 .and_then(serde_json::Value::as_str)
4466 .unwrap_or("");
4467 let fallback = node.text.as_deref().unwrap_or("");
4468 output.push_str(&format!(
4469 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4470 ));
4471 }
4472 }
4473 "mediaInline" => {
4474 if let Some(ref attrs) = node.attrs {
4475 let mut attr_parts = Vec::new();
4476 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4477 attr_parts.push(format_kv("type", media_type));
4478 }
4479 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4480 attr_parts.push(format_kv("id", id));
4481 }
4482 if let Some(collection) =
4483 attrs.get("collection").and_then(serde_json::Value::as_str)
4484 {
4485 attr_parts.push(format_kv("collection", collection));
4486 }
4487 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4488 attr_parts.push(format_kv("url", url));
4489 }
4490 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4491 attr_parts.push(format_kv("alt", alt));
4492 }
4493 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4494 attr_parts.push(format!("width={width}"));
4495 }
4496 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4497 attr_parts.push(format!("height={height}"));
4498 }
4499 maybe_push_local_id(attrs, &mut attr_parts, opts);
4500 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4501 }
4502 }
4503 _ => {
4504 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4505 }
4506 }
4507}
4508
4509fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4522 if marks.iter().any(|m| m.mark_type == "code") {
4523 render_code_marked_text(text, marks, output);
4524 return;
4525 }
4526
4527 let has_link = marks.iter().any(|m| m.mark_type == "link");
4528 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4529 let has_em = marks.iter().any(|m| m.mark_type == "em");
4530
4531 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4535 let escaped = escape_emphasis_markers(text);
4536 let escaped = escape_emoji_shortcodes(&escaped);
4537 let escaped = escape_backticks(&escaped);
4538 let escaped = escape_bare_urls(&escaped);
4539 output.push_str("***");
4540 output.push_str(&escaped);
4541 output.push_str("***");
4542 return;
4543 }
4544
4545 let em_delim = if has_strong && has_em { "_" } else { "*" };
4549
4550 let escaped = if em_delim == "_" {
4553 escape_emphasis_markers_with_underscore(text)
4554 } else {
4555 escape_emphasis_markers(text)
4556 };
4557 let escaped = escape_emoji_shortcodes(&escaped);
4558 let escaped = escape_backticks(&escaped);
4559 let escaped = escape_bare_urls(&escaped);
4567 let escaped = if has_link {
4568 escape_link_brackets(&escaped)
4569 } else {
4570 escaped
4571 };
4572
4573 let mut wrappers: Vec<(String, String)> = Vec::new();
4579 let mut i = 0;
4580 while i < marks.len() {
4581 match marks[i].mark_type.as_str() {
4582 "em" => {
4583 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4584 i += 1;
4585 }
4586 "strong" => {
4587 wrappers.push(("**".to_string(), "**".to_string()));
4588 i += 1;
4589 }
4590 "strike" => {
4591 wrappers.push(("~~".to_string(), "~~".to_string()));
4592 i += 1;
4593 }
4594 "link" => {
4595 let href = link_href(&marks[i]);
4596 wrappers.push(("[".to_string(), format!("]({href})")));
4597 i += 1;
4598 }
4599 "textColor" | "backgroundColor" | "subsup" => {
4600 let start = i;
4601 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4602 i += 1;
4603 }
4604 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4605 }
4606 "underline" | "annotation" => {
4607 let start = i;
4608 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4609 i += 1;
4610 }
4611 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4612 }
4613 _ => {
4614 i += 1;
4615 }
4616 }
4617 }
4618
4619 let mut result = escaped;
4621 for (open, close) in wrappers.iter().rev() {
4622 result.insert_str(0, open);
4623 result.push_str(close);
4624 }
4625 output.push_str(&result);
4626}
4627
4628fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4636 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4637
4638 let mut code_str = String::new();
4639 if let Some(link_mark) = link_mark {
4640 let href = link_href(link_mark);
4641 code_str.push('[');
4642 render_inline_code(text, &mut code_str);
4643 code_str.push_str("](");
4644 code_str.push_str(href);
4645 code_str.push(')');
4646 } else {
4647 render_inline_code(text, &mut code_str);
4648 }
4649
4650 let mut wrappers: Vec<(String, String)> = Vec::new();
4653 let mut i = 0;
4654 while i < marks.len() {
4655 match marks[i].mark_type.as_str() {
4656 "textColor" | "backgroundColor" | "subsup" => {
4657 let start = i;
4658 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4659 i += 1;
4660 }
4661 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4662 }
4663 "underline" | "annotation" => {
4664 let start = i;
4665 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4666 i += 1;
4667 }
4668 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4669 }
4670 _ => {
4671 i += 1;
4672 }
4673 }
4674 }
4675
4676 let mut result = code_str;
4678 for (open, close) in wrappers.iter().rev() {
4679 result.insert_str(0, open);
4680 result.push_str(close);
4681 }
4682 output.push_str(&result);
4683}
4684
4685fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4687 match mark.mark_type.as_str() {
4688 "textColor" => {
4689 if let Some(c) = mark
4690 .attrs
4691 .as_ref()
4692 .and_then(|a| a.get("color"))
4693 .and_then(serde_json::Value::as_str)
4694 {
4695 attrs.push(format!("color={c}"));
4696 }
4697 }
4698 "backgroundColor" => {
4699 if let Some(c) = mark
4700 .attrs
4701 .as_ref()
4702 .and_then(|a| a.get("color"))
4703 .and_then(serde_json::Value::as_str)
4704 {
4705 attrs.push(format!("bg={c}"));
4706 }
4707 }
4708 "subsup" => {
4709 if let Some(kind) = mark
4710 .attrs
4711 .as_ref()
4712 .and_then(|a| a.get("type"))
4713 .and_then(serde_json::Value::as_str)
4714 {
4715 attrs.push(kind.to_string());
4716 }
4717 }
4718 _ => {}
4719 }
4720}
4721
4722fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4724 match mark.mark_type.as_str() {
4725 "underline" => attrs.push("underline".to_string()),
4726 "annotation" => {
4727 if let Some(ref a) = mark.attrs {
4728 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4729 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4730 attrs.push(format!("annotation-id=\"{escaped}\""));
4731 }
4732 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4733 attrs.push(format!("annotation-type={at}"));
4734 }
4735 }
4736 }
4737 _ => {}
4738 }
4739}
4740
4741fn is_span_attr_mark(mark_type: &str) -> bool {
4742 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4743}
4744
4745fn is_bracketed_span_mark(mark_type: &str) -> bool {
4746 matches!(mark_type, "underline" | "annotation")
4747}
4748
4749fn span_attr_order(mark_type: &str) -> u8 {
4753 match mark_type {
4754 "textColor" => 0,
4755 "backgroundColor" => 1,
4756 "subsup" => 2,
4757 _ => u8::MAX,
4758 }
4759}
4760
4761fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4766 let mut prev = 0;
4767 for m in run {
4768 let order = span_attr_order(&m.mark_type);
4769 if order == u8::MAX || order < prev {
4770 return false;
4771 }
4772 prev = order;
4773 }
4774 true
4775}
4776
4777fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4782 let mut seen_annotation = false;
4783 for m in run {
4784 match m.mark_type.as_str() {
4785 "underline" => {
4786 if seen_annotation {
4787 return false;
4788 }
4789 }
4790 "annotation" => seen_annotation = true,
4791 _ => return false,
4792 }
4793 }
4794 true
4795}
4796
4797fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4801 if span_run_is_canonical(run) {
4802 let mut attrs = Vec::new();
4803 for m in run {
4804 collect_span_attr(m, &mut attrs);
4805 }
4806 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4807 return;
4808 }
4809 for m in run {
4810 let mut attrs = Vec::new();
4811 collect_span_attr(m, &mut attrs);
4812 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4813 }
4814}
4815
4816fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4820 if bracketed_run_is_canonical(run) {
4821 let mut attrs = Vec::new();
4822 for m in run {
4823 collect_bracketed_attr(m, &mut attrs);
4824 }
4825 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4826 return;
4827 }
4828 for m in run {
4829 let mut attrs = Vec::new();
4830 collect_bracketed_attr(m, &mut attrs);
4831 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4832 }
4833}
4834
4835fn link_href(mark: &AdfMark) -> &str {
4837 mark.attrs
4838 .as_ref()
4839 .and_then(|a| a.get("href"))
4840 .and_then(serde_json::Value::as_str)
4841 .unwrap_or("")
4842}
4843
4844#[cfg(test)]
4845#[allow(
4846 clippy::unwrap_used,
4847 clippy::expect_used,
4848 clippy::needless_update,
4849 clippy::needless_collect,
4850 duplicate_macro_attributes
4851)]
4852mod tests {
4853 use super::*;
4854
4855 #[test]
4858 fn paragraph() {
4859 let doc = markdown_to_adf("Hello world").unwrap();
4860 assert_eq!(doc.content.len(), 1);
4861 assert_eq!(doc.content[0].node_type, "paragraph");
4862 }
4863
4864 #[test]
4865 fn heading_levels() {
4866 for level in 1..=6 {
4867 let hashes = "#".repeat(level);
4868 let md = format!("{hashes} Title");
4869 let doc = markdown_to_adf(&md).unwrap();
4870 assert_eq!(doc.content[0].node_type, "heading");
4871 let attrs = doc.content[0].attrs.as_ref().unwrap();
4872 assert_eq!(attrs["level"], level as u64);
4873 }
4874 }
4875
4876 #[test]
4877 fn code_block() {
4878 let md = "```rust\nfn main() {}\n```";
4879 let doc = markdown_to_adf(md).unwrap();
4880 assert_eq!(doc.content[0].node_type, "codeBlock");
4881 let attrs = doc.content[0].attrs.as_ref().unwrap();
4882 assert_eq!(attrs["language"], "rust");
4883 }
4884
4885 #[test]
4886 fn code_block_no_language() {
4887 let md = "```\nsome code\n```";
4888 let doc = markdown_to_adf(md).unwrap();
4889 assert_eq!(doc.content[0].node_type, "codeBlock");
4890 assert!(doc.content[0].attrs.is_none());
4891 }
4892
4893 #[test]
4894 fn code_block_empty_language() {
4895 let md = "```\"\"\nsome code\n```";
4896 let doc = markdown_to_adf(md).unwrap();
4897 assert_eq!(doc.content[0].node_type, "codeBlock");
4898 let attrs = doc.content[0].attrs.as_ref().unwrap();
4899 assert_eq!(attrs["language"], "");
4900 }
4901
4902 #[test]
4903 fn horizontal_rule() {
4904 let doc = markdown_to_adf("---").unwrap();
4905 assert_eq!(doc.content[0].node_type, "rule");
4906 }
4907
4908 #[test]
4909 fn horizontal_rule_stars() {
4910 let doc = markdown_to_adf("***").unwrap();
4911 assert_eq!(doc.content[0].node_type, "rule");
4912 }
4913
4914 #[test]
4915 fn blockquote() {
4916 let md = "> This is a quote\n> Second line";
4917 let doc = markdown_to_adf(md).unwrap();
4918 assert_eq!(doc.content[0].node_type, "blockquote");
4919 }
4920
4921 #[test]
4922 fn bullet_list() {
4923 let md = "- Item 1\n- Item 2\n- Item 3";
4924 let doc = markdown_to_adf(md).unwrap();
4925 assert_eq!(doc.content[0].node_type, "bulletList");
4926 let items = doc.content[0].content.as_ref().unwrap();
4927 assert_eq!(items.len(), 3);
4928 }
4929
4930 #[test]
4931 fn ordered_list() {
4932 let md = "1. First\n2. Second\n3. Third";
4933 let doc = markdown_to_adf(md).unwrap();
4934 assert_eq!(doc.content[0].node_type, "orderedList");
4935 let items = doc.content[0].content.as_ref().unwrap();
4936 assert_eq!(items.len(), 3);
4937 }
4938
4939 #[test]
4940 fn task_list() {
4941 let md = "- [ ] Todo item\n- [x] Done item";
4942 let doc = markdown_to_adf(md).unwrap();
4943 assert_eq!(doc.content[0].node_type, "taskList");
4944 let items = doc.content[0].content.as_ref().unwrap();
4945 assert_eq!(items.len(), 2);
4946 assert_eq!(items[0].node_type, "taskItem");
4947 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4948 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4949 }
4950
4951 #[test]
4952 fn task_list_uppercase_x() {
4953 let md = "- [X] Done item";
4954 let doc = markdown_to_adf(md).unwrap();
4955 assert_eq!(doc.content[0].node_type, "taskList");
4956 let item = &doc.content[0].content.as_ref().unwrap()[0];
4957 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4958 }
4959
4960 #[test]
4963 fn task_list_empty_todo_no_trailing_space() {
4964 let md = "- [ ]";
4965 let doc = markdown_to_adf(md).unwrap();
4966 assert_eq!(doc.content[0].node_type, "taskList");
4967 let items = doc.content[0].content.as_ref().unwrap();
4968 assert_eq!(items.len(), 1);
4969 assert_eq!(items[0].node_type, "taskItem");
4970 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4971 assert!(items[0].content.is_none());
4972 }
4973
4974 #[test]
4976 fn task_list_empty_done_no_trailing_space() {
4977 let md = "- [x]\n- [X]";
4978 let doc = markdown_to_adf(md).unwrap();
4979 assert_eq!(doc.content[0].node_type, "taskList");
4980 let items = doc.content[0].content.as_ref().unwrap();
4981 assert_eq!(items.len(), 2);
4982 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
4983 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4984 }
4985
4986 #[test]
4989 fn task_list_body_has_no_leading_space() {
4990 let md = "- [ ] Buy groceries";
4991 let doc = markdown_to_adf(md).unwrap();
4992 let item = &doc.content[0].content.as_ref().unwrap()[0];
4993 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
4994 assert_eq!(text, "Buy groceries");
4995 }
4996
4997 #[test]
5001 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5002 let json = r#"{
5003 "version": 1,
5004 "type": "doc",
5005 "content": [{
5006 "type": "taskList",
5007 "attrs": {"localId": "abc"},
5008 "content": [
5009 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5010 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5011 ]
5012 }]
5013 }"#;
5014 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5015 let md = adf_to_markdown(&doc).unwrap();
5016 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5017 let parsed = markdown_to_adf(&stripped).unwrap();
5018 assert_eq!(parsed.content[0].node_type, "taskList");
5019 let items = parsed.content[0].content.as_ref().unwrap();
5020 assert_eq!(items.len(), 2);
5021 assert_eq!(items[0].node_type, "taskItem");
5022 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5023 assert_eq!(items[1].node_type, "taskItem");
5024 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5025 }
5026
5027 #[test]
5028 fn try_parse_task_marker_accepts_bare_checkbox() {
5029 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5030 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5031 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5032 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5033 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5034 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5035 assert_eq!(try_parse_task_marker("[x]foo"), None);
5036 assert_eq!(try_parse_task_marker("[y] foo"), None);
5037 }
5038
5039 #[test]
5040 fn starts_with_task_marker_matches_parser() {
5041 assert!(starts_with_task_marker("[ ]"));
5044 assert!(starts_with_task_marker("[x]"));
5045 assert!(starts_with_task_marker("[X]"));
5046 assert!(starts_with_task_marker("[ ] foo"));
5047 assert!(starts_with_task_marker("[x] foo\n"));
5048 assert!(starts_with_task_marker("[ ]\n"));
5049 assert!(!starts_with_task_marker("[ ]foo"));
5051 assert!(!starts_with_task_marker("[y] foo"));
5052 assert!(!starts_with_task_marker("foo [ ] bar"));
5053 assert!(!starts_with_task_marker(""));
5054 }
5055
5056 #[test]
5060 fn round_trip_bullet_list_with_literal_checkbox_text() {
5061 let json = r#"{
5062 "version": 1,
5063 "type": "doc",
5064 "content": [{
5065 "type": "bulletList",
5066 "content": [{
5067 "type": "listItem",
5068 "content": [{
5069 "type": "paragraph",
5070 "content": [
5071 {"type": "text", "text": "[ ] Review the "},
5072 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5073 {"type": "text", "text": " file"}
5074 ]
5075 }]
5076 }]
5077 }]
5078 }"#;
5079 let original: AdfDocument = serde_json::from_str(json).unwrap();
5080 let md = adf_to_markdown(&original).unwrap();
5081 assert!(
5083 md.contains(r"- \[ ] Review the "),
5084 "rendered markdown: {md:?}"
5085 );
5086 let parsed = markdown_to_adf(&md).unwrap();
5087 assert_eq!(parsed.content[0].node_type, "bulletList");
5088 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5089 assert_eq!(item.node_type, "listItem");
5090 let para = &item.content.as_ref().unwrap()[0];
5091 assert_eq!(para.node_type, "paragraph");
5092 let text_nodes = para.content.as_ref().unwrap();
5093 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5094 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5095 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5096 }
5097
5098 #[test]
5100 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5101 let json = r#"{
5102 "version": 1,
5103 "type": "doc",
5104 "content": [{
5105 "type": "bulletList",
5106 "content": [{
5107 "type": "listItem",
5108 "content": [{
5109 "type": "paragraph",
5110 "content": [{"type": "text", "text": "[x] not actually done"}]
5111 }]
5112 }]
5113 }]
5114 }"#;
5115 let original: AdfDocument = serde_json::from_str(json).unwrap();
5116 let md = adf_to_markdown(&original).unwrap();
5117 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5118 let parsed = markdown_to_adf(&md).unwrap();
5119 assert_eq!(parsed.content[0].node_type, "bulletList");
5120 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5121 let para = &item.content.as_ref().unwrap()[0];
5122 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5123 assert_eq!(text, "[x] not actually done");
5124 }
5125
5126 #[test]
5128 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5129 let json = r#"{
5130 "version": 1,
5131 "type": "doc",
5132 "content": [{
5133 "type": "bulletList",
5134 "content": [{
5135 "type": "listItem",
5136 "content": [{
5137 "type": "paragraph",
5138 "content": [{"type": "text", "text": "[ ]"}]
5139 }]
5140 }]
5141 }]
5142 }"#;
5143 let original: AdfDocument = serde_json::from_str(json).unwrap();
5144 let md = adf_to_markdown(&original).unwrap();
5145 let parsed = markdown_to_adf(&md).unwrap();
5146 assert_eq!(parsed.content[0].node_type, "bulletList");
5147 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5148 let para = &item.content.as_ref().unwrap()[0];
5149 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5150 assert_eq!(text, "[ ]");
5151 }
5152
5153 #[test]
5156 fn bullet_list_non_task_bracket_text_not_escaped() {
5157 let json = r#"{
5158 "version": 1,
5159 "type": "doc",
5160 "content": [{
5161 "type": "bulletList",
5162 "content": [{
5163 "type": "listItem",
5164 "content": [{
5165 "type": "paragraph",
5166 "content": [{"type": "text", "text": "[?] unsure"}]
5167 }]
5168 }]
5169 }]
5170 }"#;
5171 let original: AdfDocument = serde_json::from_str(json).unwrap();
5172 let md = adf_to_markdown(&original).unwrap();
5173 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5174 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5175 }
5176
5177 #[test]
5180 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5181 let json = r#"{
5182 "version": 1,
5183 "type": "doc",
5184 "content": [{
5185 "type": "bulletList",
5186 "content": [{
5187 "type": "listItem",
5188 "content": [
5189 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5190 {"type": "bulletList", "content": [{
5191 "type": "listItem",
5192 "content": [{
5193 "type": "paragraph",
5194 "content": [{"type": "text", "text": "[ ] inner literal"}]
5195 }]
5196 }]}
5197 ]
5198 }]
5199 }]
5200 }"#;
5201 let original: AdfDocument = serde_json::from_str(json).unwrap();
5202 let md = adf_to_markdown(&original).unwrap();
5203 let parsed = markdown_to_adf(&md).unwrap();
5204 let outer = &parsed.content[0];
5205 assert_eq!(outer.node_type, "bulletList");
5206 let outer_item = &outer.content.as_ref().unwrap()[0];
5207 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5208 assert_eq!(inner_list.node_type, "bulletList");
5209 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5210 assert_eq!(inner_item.node_type, "listItem");
5211 let para = &inner_item.content.as_ref().unwrap()[0];
5212 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5213 assert_eq!(text, "[ ] inner literal");
5214 }
5215
5216 #[test]
5217 fn adf_task_list_to_markdown() {
5218 let doc = AdfDocument {
5219 version: 1,
5220 doc_type: "doc".to_string(),
5221 content: vec![AdfNode::task_list(vec![
5222 AdfNode::task_item(
5223 "TODO",
5224 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5225 ),
5226 AdfNode::task_item(
5227 "DONE",
5228 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5229 ),
5230 ])],
5231 };
5232 let md = adf_to_markdown(&doc).unwrap();
5233 assert!(md.contains("- [ ] Todo"));
5234 assert!(md.contains("- [x] Done"));
5235 }
5236
5237 #[test]
5238 fn round_trip_task_list() {
5239 let md = "- [ ] Todo item\n- [x] Done item\n";
5240 let doc = markdown_to_adf(md).unwrap();
5241 let result = adf_to_markdown(&doc).unwrap();
5242 assert!(result.contains("- [ ] Todo item"));
5243 assert!(result.contains("- [x] Done item"));
5244 }
5245
5246 #[test]
5248 fn adf_task_item_unwrapped_inline_content() {
5249 let json = r#"{
5251 "version": 1,
5252 "type": "doc",
5253 "content": [{
5254 "type": "taskList",
5255 "attrs": {"localId": "list-001"},
5256 "content": [{
5257 "type": "taskItem",
5258 "attrs": {"localId": "task-001", "state": "TODO"},
5259 "content": [{"type": "text", "text": "Do something"}]
5260 }]
5261 }]
5262 }"#;
5263 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5264 let md = adf_to_markdown(&doc).unwrap();
5265 assert!(md.contains("- [ ] Do something"), "got: {md}");
5266 assert!(!md.contains("adf-unsupported"), "got: {md}");
5267 }
5268
5269 #[test]
5271 fn adf_task_list_multiple_unwrapped_items() {
5272 let json = r#"{
5273 "version": 1,
5274 "type": "doc",
5275 "content": [{
5276 "type": "taskList",
5277 "attrs": {"localId": "list-001"},
5278 "content": [
5279 {
5280 "type": "taskItem",
5281 "attrs": {"localId": "task-001", "state": "TODO"},
5282 "content": [{"type": "text", "text": "First task"}]
5283 },
5284 {
5285 "type": "taskItem",
5286 "attrs": {"localId": "task-002", "state": "DONE"},
5287 "content": [{"type": "text", "text": "Second task"}]
5288 }
5289 ]
5290 }]
5291 }"#;
5292 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5293 let md = adf_to_markdown(&doc).unwrap();
5294 assert!(md.contains("- [ ] First task"), "got: {md}");
5295 assert!(md.contains("- [x] Second task"), "got: {md}");
5296 assert!(!md.contains("adf-unsupported"), "got: {md}");
5297 }
5298
5299 #[test]
5301 fn adf_task_item_unwrapped_inline_with_marks() {
5302 let json = r#"{
5303 "version": 1,
5304 "type": "doc",
5305 "content": [{
5306 "type": "taskList",
5307 "attrs": {"localId": "list-001"},
5308 "content": [{
5309 "type": "taskItem",
5310 "attrs": {"localId": "task-001", "state": "TODO"},
5311 "content": [
5312 {"type": "text", "text": "Buy "},
5313 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5314 {"type": "text", "text": " today"}
5315 ]
5316 }]
5317 }]
5318 }"#;
5319 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5320 let md = adf_to_markdown(&doc).unwrap();
5321 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5322 }
5323
5324 #[test]
5326 fn adf_task_item_unwrapped_preserves_local_id() {
5327 let json = r#"{
5328 "version": 1,
5329 "type": "doc",
5330 "content": [{
5331 "type": "taskList",
5332 "attrs": {"localId": "list-001"},
5333 "content": [{
5334 "type": "taskItem",
5335 "attrs": {"localId": "task-001", "state": "TODO"},
5336 "content": [{"type": "text", "text": "Do something"}]
5337 }]
5338 }]
5339 }"#;
5340 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5341 let md = adf_to_markdown(&doc).unwrap();
5342 assert!(md.contains("{localId=task-001}"), "got: {md}");
5343 assert!(md.contains("{localId=list-001}"), "got: {md}");
5344 }
5345
5346 #[test]
5348 fn round_trip_task_list_unwrapped_inline() {
5349 let json = r#"{
5350 "version": 1,
5351 "type": "doc",
5352 "content": [{
5353 "type": "taskList",
5354 "attrs": {"localId": "list-001"},
5355 "content": [
5356 {
5357 "type": "taskItem",
5358 "attrs": {"localId": "task-001", "state": "TODO"},
5359 "content": [{"type": "text", "text": "Do something"}]
5360 },
5361 {
5362 "type": "taskItem",
5363 "attrs": {"localId": "task-002", "state": "DONE"},
5364 "content": [{"type": "text", "text": "Already done"}]
5365 }
5366 ]
5367 }]
5368 }"#;
5369 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5370 let md = adf_to_markdown(&doc).unwrap();
5371
5372 let doc2 = markdown_to_adf(&md).unwrap();
5374 assert_eq!(doc2.content[0].node_type, "taskList");
5375
5376 let items = doc2.content[0].content.as_ref().unwrap();
5377 assert_eq!(items.len(), 2);
5378 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5379 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5380
5381 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5383 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5384 assert_eq!(
5385 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5386 "list-001"
5387 );
5388 }
5389
5390 #[test]
5392 fn adf_task_item_unwrapped_inline_then_block() {
5393 let json = r#"{
5394 "version": 1,
5395 "type": "doc",
5396 "content": [{
5397 "type": "taskList",
5398 "attrs": {"localId": "list-001"},
5399 "content": [{
5400 "type": "taskItem",
5401 "attrs": {"localId": "task-001", "state": "TODO"},
5402 "content": [
5403 {"type": "text", "text": "Parent task"},
5404 {
5405 "type": "bulletList",
5406 "content": [{
5407 "type": "listItem",
5408 "content": [{
5409 "type": "paragraph",
5410 "content": [{"type": "text", "text": "sub-item"}]
5411 }]
5412 }]
5413 }
5414 ]
5415 }]
5416 }]
5417 }"#;
5418 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5419 let md = adf_to_markdown(&doc).unwrap();
5420 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5421 assert!(md.contains(" - sub-item"), "got: {md}");
5422 assert!(!md.contains("adf-unsupported"), "got: {md}");
5423 }
5424
5425 #[test]
5427 fn adf_task_item_empty_content() {
5428 let json = r#"{
5429 "version": 1,
5430 "type": "doc",
5431 "content": [{
5432 "type": "taskList",
5433 "attrs": {"localId": "list-001"},
5434 "content": [{
5435 "type": "taskItem",
5436 "attrs": {"localId": "task-001", "state": "TODO"},
5437 "content": []
5438 }]
5439 }]
5440 }"#;
5441 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5442 let md = adf_to_markdown(&doc).unwrap();
5443 assert!(md.contains("- [ ] "), "got: {md}");
5444 assert!(!md.contains("adf-unsupported"), "got: {md}");
5445 }
5446
5447 #[test]
5450 fn adf_nested_task_item_renders_without_corruption() {
5451 let json = r#"{
5452 "type": "doc",
5453 "version": 1,
5454 "content": [{
5455 "type": "taskList",
5456 "attrs": {"localId": ""},
5457 "content": [
5458 {
5459 "type": "taskItem",
5460 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5461 "content": [{"type": "text", "text": "Normal task"}]
5462 },
5463 {
5464 "type": "taskItem",
5465 "attrs": {"localId": ""},
5466 "content": [
5467 {
5468 "type": "taskItem",
5469 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5470 "content": [{"type": "text", "text": "Nested task one"}]
5471 },
5472 {
5473 "type": "taskItem",
5474 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5475 "content": [{"type": "text", "text": "Nested task two"}]
5476 }
5477 ]
5478 }
5479 ]
5480 }]
5481 }"#;
5482 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5483 let md = adf_to_markdown(&doc).unwrap();
5484 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5486 assert!(!md.contains("adf-unsupported"), "got: {md}");
5488 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5489 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5490 }
5491
5492 #[test]
5494 fn round_trip_nested_task_item() {
5495 let json = r#"{
5496 "type": "doc",
5497 "version": 1,
5498 "content": [{
5499 "type": "taskList",
5500 "attrs": {"localId": ""},
5501 "content": [
5502 {
5503 "type": "taskItem",
5504 "attrs": {"localId": "task-001", "state": "TODO"},
5505 "content": [{"type": "text", "text": "Normal task"}]
5506 },
5507 {
5508 "type": "taskItem",
5509 "attrs": {"localId": ""},
5510 "content": [
5511 {
5512 "type": "taskItem",
5513 "attrs": {"localId": "task-002", "state": "TODO"},
5514 "content": [{"type": "text", "text": "Nested one"}]
5515 },
5516 {
5517 "type": "taskItem",
5518 "attrs": {"localId": "task-003", "state": "DONE"},
5519 "content": [{"type": "text", "text": "Nested two"}]
5520 }
5521 ]
5522 }
5523 ]
5524 }]
5525 }"#;
5526 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5527 let md = adf_to_markdown(&doc).unwrap();
5528 let doc2 = markdown_to_adf(&md).unwrap();
5529
5530 assert_eq!(doc2.content[0].node_type, "taskList");
5532 let items = doc2.content[0].content.as_ref().unwrap();
5533 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5534
5535 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5537 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5538 let first_content = items[0].content.as_ref().unwrap();
5539 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5540
5541 let container = &items[1];
5543 assert_eq!(container.node_type, "taskItem");
5544 let c_attrs = container.attrs.as_ref().unwrap();
5545 assert!(
5546 c_attrs.get("state").is_none(),
5547 "container should have no state attr, got: {c_attrs:?}"
5548 );
5549
5550 let container_content = container.content.as_ref().unwrap();
5552 assert_eq!(
5553 container_content.len(),
5554 2,
5555 "expected 2 bare taskItem children"
5556 );
5557 assert_eq!(container_content[0].node_type, "taskItem");
5558 assert_eq!(
5559 container_content[0].attrs.as_ref().unwrap()["state"],
5560 "TODO"
5561 );
5562 assert_eq!(
5563 container_content[0].attrs.as_ref().unwrap()["localId"],
5564 "task-002"
5565 );
5566 assert_eq!(container_content[1].node_type, "taskItem");
5567 assert_eq!(
5568 container_content[1].attrs.as_ref().unwrap()["state"],
5569 "DONE"
5570 );
5571 assert_eq!(
5572 container_content[1].attrs.as_ref().unwrap()["localId"],
5573 "task-003"
5574 );
5575 }
5576
5577 #[test]
5579 fn adf_nested_task_item_preserves_local_ids() {
5580 let json = r#"{
5581 "type": "doc",
5582 "version": 1,
5583 "content": [{
5584 "type": "taskList",
5585 "attrs": {"localId": "list-001"},
5586 "content": [{
5587 "type": "taskItem",
5588 "attrs": {"localId": "container-001", "state": "TODO"},
5589 "content": [{
5590 "type": "taskItem",
5591 "attrs": {"localId": "child-001", "state": "DONE"},
5592 "content": [{"type": "text", "text": "Nested child"}]
5593 }]
5594 }]
5595 }]
5596 }"#;
5597 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5598 let md = adf_to_markdown(&doc).unwrap();
5599 assert!(
5601 md.contains("localId=container-001"),
5602 "container localId missing: {md}"
5603 );
5604 assert!(
5606 md.contains("localId=child-001"),
5607 "child localId missing: {md}"
5608 );
5609 assert!(!md.contains("adf-unsupported"), "got: {md}");
5610 }
5611
5612 #[test]
5615 fn adf_nested_task_item_mixed_with_block_node() {
5616 let json = r#"{
5617 "type": "doc",
5618 "version": 1,
5619 "content": [{
5620 "type": "taskList",
5621 "attrs": {"localId": ""},
5622 "content": [{
5623 "type": "taskItem",
5624 "attrs": {"localId": "", "state": "TODO"},
5625 "content": [
5626 {
5627 "type": "taskItem",
5628 "attrs": {"localId": "", "state": "TODO"},
5629 "content": [{"type": "text", "text": "A nested task"}]
5630 },
5631 {
5632 "type": "paragraph",
5633 "content": [{"type": "text", "text": "Stray paragraph"}]
5634 }
5635 ]
5636 }]
5637 }]
5638 }"#;
5639 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5640 let md = adf_to_markdown(&doc).unwrap();
5641 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5642 assert!(md.contains(" Stray paragraph"), "got: {md}");
5643 assert!(!md.contains("adf-unsupported"), "got: {md}");
5644 }
5645
5646 #[test]
5650 fn task_item_with_text_and_nested_sub_content() {
5651 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5652 let doc = markdown_to_adf(md).unwrap();
5653 assert_eq!(doc.content[0].node_type, "taskList");
5654 let items = doc.content[0].content.as_ref().unwrap();
5655 assert_eq!(items.len(), 2, "got: {items:?}");
5658 let parent = &items[0];
5659 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5660 let parent_content = parent.content.as_ref().unwrap();
5661 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5662 assert_eq!(items[1].node_type, "taskList");
5664 let nested = items[1].content.as_ref().unwrap();
5665 assert_eq!(nested.len(), 1);
5666 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5667 }
5668
5669 #[test]
5673 fn task_item_empty_with_non_tasklist_sub_content() {
5674 let md = "- [ ] \n Some paragraph text\n";
5675 let doc = markdown_to_adf(md).unwrap();
5676 assert_eq!(doc.content[0].node_type, "taskList");
5677 let items = doc.content[0].content.as_ref().unwrap();
5678 assert_eq!(items.len(), 1);
5679 let item = &items[0];
5680 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5681 let content = item.content.as_ref().unwrap();
5682 assert_eq!(content[0].node_type, "paragraph");
5684 }
5685
5686 #[test]
5688 fn adf_nested_task_item_single_child() {
5689 let json = r#"{
5690 "type": "doc",
5691 "version": 1,
5692 "content": [{
5693 "type": "taskList",
5694 "attrs": {"localId": ""},
5695 "content": [{
5696 "type": "taskItem",
5697 "attrs": {"localId": "", "state": "TODO"},
5698 "content": [{
5699 "type": "taskItem",
5700 "attrs": {"localId": "", "state": "DONE"},
5701 "content": [{"type": "text", "text": "Only child"}]
5702 }]
5703 }]
5704 }]
5705 }"#;
5706 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5707 let md = adf_to_markdown(&doc).unwrap();
5708 assert!(md.contains(" - [x] Only child"), "got: {md}");
5709 assert!(!md.contains("adf-unsupported"), "got: {md}");
5710 }
5711
5712 #[test]
5715 fn adf_nested_tasklist_sibling_renders_indented() {
5716 let json = r#"{
5717 "version": 1,
5718 "type": "doc",
5719 "content": [{
5720 "type": "taskList",
5721 "attrs": {"localId": ""},
5722 "content": [
5723 {
5724 "type": "taskItem",
5725 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5726 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5727 },
5728 {
5729 "type": "taskList",
5730 "attrs": {"localId": ""},
5731 "content": [{
5732 "type": "taskItem",
5733 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5734 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5735 }]
5736 },
5737 {
5738 "type": "taskItem",
5739 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5740 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5741 }
5742 ]
5743 }]
5744 }"#;
5745 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5746 let md = adf_to_markdown(&doc).unwrap();
5747 assert!(md.contains("- [ ] parent task one"), "got: {md}");
5749 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
5750 assert!(md.contains("- [ ] parent task two"), "got: {md}");
5751 }
5752
5753 #[test]
5755 fn round_trip_nested_tasklist_preserves_type() {
5756 let json = r#"{
5757 "version": 1,
5758 "type": "doc",
5759 "content": [{
5760 "type": "taskList",
5761 "attrs": {"localId": ""},
5762 "content": [
5763 {
5764 "type": "taskItem",
5765 "attrs": {"localId": "", "state": "TODO"},
5766 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5767 },
5768 {
5769 "type": "taskList",
5770 "attrs": {"localId": ""},
5771 "content": [{
5772 "type": "taskItem",
5773 "attrs": {"localId": "", "state": "TODO"},
5774 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5775 }]
5776 },
5777 {
5778 "type": "taskItem",
5779 "attrs": {"localId": "", "state": "TODO"},
5780 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5781 }
5782 ]
5783 }]
5784 }"#;
5785 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5786 let md = adf_to_markdown(&doc).unwrap();
5787 let rt_doc = markdown_to_adf(&md).unwrap();
5788 assert_eq!(rt_doc.content[0].node_type, "taskList");
5790 let items = rt_doc.content[0].content.as_ref().unwrap();
5791 assert_eq!(items.len(), 3, "got: {items:?}");
5794 assert_eq!(items[0].node_type, "taskItem");
5795 assert_eq!(
5796 items[1].node_type, "taskList",
5797 "nested taskList should survive round-trip"
5798 );
5799 assert_eq!(items[2].node_type, "taskItem");
5800 let nested_items = items[1].content.as_ref().unwrap();
5801 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5802 }
5803
5804 #[test]
5806 fn adf_nested_tasklist_done_state() {
5807 let json = r#"{
5808 "version": 1,
5809 "type": "doc",
5810 "content": [{
5811 "type": "taskList",
5812 "attrs": {"localId": ""},
5813 "content": [
5814 {
5815 "type": "taskItem",
5816 "attrs": {"localId": "", "state": "TODO"},
5817 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5818 },
5819 {
5820 "type": "taskList",
5821 "attrs": {"localId": ""},
5822 "content": [{
5823 "type": "taskItem",
5824 "attrs": {"localId": "", "state": "DONE"},
5825 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5826 }]
5827 }
5828 ]
5829 }]
5830 }"#;
5831 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5832 let md = adf_to_markdown(&doc).unwrap();
5833 assert!(md.contains(" - [x] done child"), "got: {md}");
5834 let rt_doc = markdown_to_adf(&md).unwrap();
5836 let items = rt_doc.content[0].content.as_ref().unwrap();
5837 assert_eq!(
5838 items[1].node_type, "taskList",
5839 "nested taskList should survive round-trip"
5840 );
5841 let nested_item = &items[1].content.as_ref().unwrap()[0];
5842 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5843 }
5844
5845 #[test]
5847 fn adf_multiple_nested_tasklists() {
5848 let json = r#"{
5849 "version": 1,
5850 "type": "doc",
5851 "content": [{
5852 "type": "taskList",
5853 "attrs": {"localId": ""},
5854 "content": [
5855 {
5856 "type": "taskItem",
5857 "attrs": {"localId": "", "state": "TODO"},
5858 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5859 },
5860 {
5861 "type": "taskList",
5862 "attrs": {"localId": ""},
5863 "content": [{
5864 "type": "taskItem",
5865 "attrs": {"localId": "", "state": "TODO"},
5866 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5867 }]
5868 },
5869 {
5870 "type": "taskItem",
5871 "attrs": {"localId": "", "state": "TODO"},
5872 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5873 },
5874 {
5875 "type": "taskList",
5876 "attrs": {"localId": ""},
5877 "content": [{
5878 "type": "taskItem",
5879 "attrs": {"localId": "", "state": "DONE"},
5880 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5881 }]
5882 }
5883 ]
5884 }]
5885 }"#;
5886 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5887 let md = adf_to_markdown(&doc).unwrap();
5888 assert!(md.contains("- [ ] first parent"), "got: {md}");
5889 assert!(md.contains(" - [ ] child A"), "got: {md}");
5890 assert!(md.contains("- [ ] second parent"), "got: {md}");
5891 assert!(md.contains(" - [x] child B"), "got: {md}");
5892 }
5893
5894 #[test]
5897 fn round_trip_nested_tasklist_stable() {
5898 let json = r#"{
5899 "version": 1,
5900 "type": "doc",
5901 "content": [{
5902 "type": "taskList",
5903 "attrs": {"localId": ""},
5904 "content": [
5905 {
5906 "type": "taskItem",
5907 "attrs": {"localId": "", "state": "TODO"},
5908 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5909 },
5910 {
5911 "type": "taskList",
5912 "attrs": {"localId": ""},
5913 "content": [{
5914 "type": "taskItem",
5915 "attrs": {"localId": "", "state": "TODO"},
5916 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5917 }]
5918 }
5919 ]
5920 }]
5921 }"#;
5922 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5923 let md1 = adf_to_markdown(&doc).unwrap();
5925 let rt1 = markdown_to_adf(&md1).unwrap();
5926 let md2 = adf_to_markdown(&rt1).unwrap();
5928 let rt2 = markdown_to_adf(&md2).unwrap();
5929 assert_eq!(md1, md2, "markdown should be stable across round-trips");
5931 let rt1_json = serde_json::to_string(&rt1).unwrap();
5933 let rt2_json = serde_json::to_string(&rt2).unwrap();
5934 assert_eq!(
5935 rt1_json, rt2_json,
5936 "ADF should be stable across round-trips"
5937 );
5938 }
5939
5940 #[test]
5945 fn task_item_mixed_sub_content_splits_siblings() {
5946 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
5947 let doc = markdown_to_adf(md).unwrap();
5948 let items = doc.content[0].content.as_ref().unwrap();
5949 assert_eq!(items.len(), 2, "got: {items:?}");
5951 assert_eq!(items[0].node_type, "taskItem");
5952 let parent_content = items[0].content.as_ref().unwrap();
5953 assert!(
5955 parent_content.iter().any(|n| n.node_type == "paragraph"),
5956 "non-taskList sub-content should stay as child: {parent_content:?}"
5957 );
5958 assert_eq!(items[1].node_type, "taskList");
5960 }
5961
5962 #[test]
5966 fn empty_task_item_mixed_sub_content_none_arm() {
5967 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
5968 let doc = markdown_to_adf(md).unwrap();
5969 let items = doc.content[0].content.as_ref().unwrap();
5970 assert_eq!(items.len(), 2, "got: {items:?}");
5972 assert_eq!(items[0].node_type, "taskItem");
5973 let parent_content = items[0].content.as_ref().unwrap();
5974 assert!(
5975 parent_content.iter().any(|n| n.node_type == "paragraph"),
5976 "paragraph should be assigned to taskItem: {parent_content:?}"
5977 );
5978 assert_eq!(items[1].node_type, "taskList");
5979 }
5980
5981 #[test]
5986 fn task_item_text_with_non_tasklist_sub_content_only() {
5987 let md = "- [ ] My task\n Extra paragraph content\n";
5988 let doc = markdown_to_adf(md).unwrap();
5989 let items = doc.content[0].content.as_ref().unwrap();
5990 assert_eq!(items.len(), 1, "got: {items:?}");
5992 assert_eq!(items[0].node_type, "taskItem");
5993 let content = items[0].content.as_ref().unwrap();
5994 assert!(
5996 content.iter().any(|n| n.node_type == "paragraph"),
5997 "paragraph sub-content should be a child of taskItem: {content:?}"
5998 );
5999 }
6000
6001 #[test]
6004 fn adf_list_item_leading_block_node() {
6005 let json = r#"{
6006 "version": 1,
6007 "type": "doc",
6008 "content": [{
6009 "type": "bulletList",
6010 "content": [{
6011 "type": "listItem",
6012 "content": [{
6013 "type": "codeBlock",
6014 "attrs": {"language": "rust"},
6015 "content": [{"type": "text", "text": "let x = 1;"}]
6016 }]
6017 }]
6018 }]
6019 }"#;
6020 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6021 let md = adf_to_markdown(&doc).unwrap();
6022 assert!(md.contains("```rust"), "got: {md}");
6023 assert!(md.contains("let x = 1;"), "got: {md}");
6024 for line in md.lines() {
6027 if line.starts_with("- ") {
6028 continue; }
6030 if line.trim().is_empty() {
6031 continue;
6032 }
6033 assert!(
6034 line.starts_with(" "),
6035 "continuation line not indented: {line:?}"
6036 );
6037 }
6038 }
6039
6040 #[test]
6043 fn code_block_in_list_item_backtick_roundtrip() {
6044 let json = r#"{
6045 "version": 1,
6046 "type": "doc",
6047 "content": [{
6048 "type": "bulletList",
6049 "content": [{
6050 "type": "listItem",
6051 "content": [{
6052 "type": "codeBlock",
6053 "attrs": {"language": ""},
6054 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6055 }]
6056 }]
6057 }]
6058 }"#;
6059 let original: AdfDocument = serde_json::from_str(json).unwrap();
6060 let md = adf_to_markdown(&original).unwrap();
6061 let roundtripped = markdown_to_adf(&md).unwrap();
6062 let list = &roundtripped.content[0];
6063 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6064 let item = &list.content.as_ref().unwrap()[0];
6065 let first_child = &item.content.as_ref().unwrap()[0];
6066 assert_eq!(
6067 first_child.node_type, "codeBlock",
6068 "expected codeBlock, got: {}",
6069 first_child.node_type
6070 );
6071 let text = first_child.content.as_ref().unwrap()[0]
6072 .text
6073 .as_deref()
6074 .unwrap();
6075 assert_eq!(text, "error: some value with a backtick ` at end");
6076 }
6077
6078 #[test]
6080 fn code_block_with_language_in_list_item_roundtrip() {
6081 let json = r#"{
6082 "version": 1,
6083 "type": "doc",
6084 "content": [{
6085 "type": "bulletList",
6086 "content": [{
6087 "type": "listItem",
6088 "content": [{
6089 "type": "codeBlock",
6090 "attrs": {"language": "rust"},
6091 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6092 }]
6093 }]
6094 }]
6095 }"#;
6096 let original: AdfDocument = serde_json::from_str(json).unwrap();
6097 let md = adf_to_markdown(&original).unwrap();
6098 let roundtripped = markdown_to_adf(&md).unwrap();
6099 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6100 let code = &item.content.as_ref().unwrap()[0];
6101 assert_eq!(code.node_type, "codeBlock");
6102 let lang = code
6103 .attrs
6104 .as_ref()
6105 .and_then(|a| a.get("language"))
6106 .and_then(serde_json::Value::as_str)
6107 .unwrap_or("");
6108 assert_eq!(lang, "rust");
6109 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6110 assert!(text.contains("println!"), "code content: {text}");
6111 }
6112
6113 #[test]
6115 fn code_block_in_ordered_list_item_roundtrip() {
6116 let json = r#"{
6117 "version": 1,
6118 "type": "doc",
6119 "content": [{
6120 "type": "orderedList",
6121 "attrs": {"order": 1},
6122 "content": [{
6123 "type": "listItem",
6124 "content": [{
6125 "type": "codeBlock",
6126 "attrs": {"language": ""},
6127 "content": [{"type": "text", "text": "backtick ` here"}]
6128 }]
6129 }]
6130 }]
6131 }"#;
6132 let original: AdfDocument = serde_json::from_str(json).unwrap();
6133 let md = adf_to_markdown(&original).unwrap();
6134 let roundtripped = markdown_to_adf(&md).unwrap();
6135 let list = &roundtripped.content[0];
6136 assert_eq!(list.node_type, "orderedList");
6137 let item = &list.content.as_ref().unwrap()[0];
6138 let code = &item.content.as_ref().unwrap()[0];
6139 assert_eq!(code.node_type, "codeBlock");
6140 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6141 assert_eq!(text, "backtick ` here");
6142 }
6143
6144 #[test]
6146 fn code_block_then_paragraph_in_list_item() {
6147 let json = r#"{
6148 "version": 1,
6149 "type": "doc",
6150 "content": [{
6151 "type": "bulletList",
6152 "content": [{
6153 "type": "listItem",
6154 "content": [
6155 {
6156 "type": "codeBlock",
6157 "attrs": {"language": ""},
6158 "content": [{"type": "text", "text": "code with ` backtick"}]
6159 },
6160 {
6161 "type": "paragraph",
6162 "content": [{"type": "text", "text": "description"}]
6163 }
6164 ]
6165 }]
6166 }]
6167 }"#;
6168 let original: AdfDocument = serde_json::from_str(json).unwrap();
6169 let md = adf_to_markdown(&original).unwrap();
6170 let roundtripped = markdown_to_adf(&md).unwrap();
6171 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6172 let children = item.content.as_ref().unwrap();
6173 assert_eq!(children[0].node_type, "codeBlock");
6174 assert_eq!(children[1].node_type, "paragraph");
6175 }
6176
6177 #[test]
6179 fn code_block_multiple_backticks_in_list_item() {
6180 let json = r#"{
6181 "version": 1,
6182 "type": "doc",
6183 "content": [{
6184 "type": "bulletList",
6185 "content": [{
6186 "type": "listItem",
6187 "content": [{
6188 "type": "codeBlock",
6189 "attrs": {"language": ""},
6190 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6191 }]
6192 }]
6193 }]
6194 }"#;
6195 let original: AdfDocument = serde_json::from_str(json).unwrap();
6196 let md = adf_to_markdown(&original).unwrap();
6197 let roundtripped = markdown_to_adf(&md).unwrap();
6198 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6199 let code = &item.content.as_ref().unwrap()[0];
6200 assert_eq!(code.node_type, "codeBlock");
6201 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6202 assert_eq!(text, "a ` b `` c ``` d");
6203 }
6204
6205 #[test]
6208 fn media_first_child_with_sub_content_in_list_item() {
6209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6210 {"type":"listItem","content":[
6211 {"type":"mediaSingle","attrs":{"layout":"center"},
6212 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6213 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6214 ]}
6215 ]}]}"#;
6216 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6217 let md = adf_to_markdown(&doc).unwrap();
6218 let rt = markdown_to_adf(&md).unwrap();
6219 let item = &rt.content[0].content.as_ref().unwrap()[0];
6220 let children = item.content.as_ref().unwrap();
6221 assert_eq!(
6222 children.len(),
6223 2,
6224 "expected 2 children, got {}",
6225 children.len()
6226 );
6227 assert_eq!(children[0].node_type, "mediaSingle");
6228 let media = &children[0].content.as_ref().unwrap()[0];
6229 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6230 assert_eq!(children[1].node_type, "paragraph");
6231 }
6232
6233 #[test]
6234 fn inline_bold() {
6235 let doc = markdown_to_adf("Some **bold** text").unwrap();
6236 let content = doc.content[0].content.as_ref().unwrap();
6237 assert!(content.len() >= 3);
6238 let bold_node = &content[1];
6239 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6240 let marks = bold_node.marks.as_ref().unwrap();
6241 assert_eq!(marks[0].mark_type, "strong");
6242 }
6243
6244 #[test]
6245 fn inline_italic() {
6246 let doc = markdown_to_adf("Some *italic* text").unwrap();
6247 let content = doc.content[0].content.as_ref().unwrap();
6248 let italic_node = &content[1];
6249 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6250 let marks = italic_node.marks.as_ref().unwrap();
6251 assert_eq!(marks[0].mark_type, "em");
6252 }
6253
6254 #[test]
6255 fn inline_code() {
6256 let doc = markdown_to_adf("Use `code` here").unwrap();
6257 let content = doc.content[0].content.as_ref().unwrap();
6258 let code_node = &content[1];
6259 assert_eq!(code_node.text.as_deref(), Some("code"));
6260 let marks = code_node.marks.as_ref().unwrap();
6261 assert_eq!(marks[0].mark_type, "code");
6262 }
6263
6264 #[test]
6268 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6269 let doc = AdfDocument {
6270 version: 1,
6271 doc_type: "doc".to_string(),
6272 content: vec![AdfNode::paragraph(vec![
6273 AdfNode::text("Run "),
6274 AdfNode::text_with_marks(
6275 "ADD `custom_threshold` TEXT NOT NULL",
6276 vec![AdfMark::code()],
6277 ),
6278 AdfNode::text(" to update the schema."),
6279 ])],
6280 };
6281 let md = adf_to_markdown(&doc).unwrap();
6282 assert!(
6283 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6284 "expected double-backtick delimiters, got: {md}"
6285 );
6286 }
6287
6288 #[test]
6291 fn inline_code_double_backtick_delimiters_parse() {
6292 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6293 let content = doc.content[0].content.as_ref().unwrap();
6294 assert_eq!(content.len(), 3, "content: {content:?}");
6295 let code_node = &content[1];
6296 assert_eq!(
6297 code_node.text.as_deref(),
6298 Some("ADD `custom_threshold` TEXT NOT NULL")
6299 );
6300 let marks = code_node.marks.as_ref().unwrap();
6301 assert_eq!(marks[0].mark_type, "code");
6302 }
6303
6304 #[test]
6307 fn inline_code_with_backtick_roundtrip() {
6308 let json = r#"{
6309 "version": 1,
6310 "type": "doc",
6311 "content": [{
6312 "type": "paragraph",
6313 "content": [
6314 {"type": "text", "text": "Run "},
6315 {
6316 "type": "text",
6317 "text": "ADD `custom_threshold` TEXT NOT NULL",
6318 "marks": [{"type": "code"}]
6319 },
6320 {"type": "text", "text": " to update the schema."}
6321 ]
6322 }]
6323 }"#;
6324 let original: AdfDocument = serde_json::from_str(json).unwrap();
6325 let md = adf_to_markdown(&original).unwrap();
6326 let roundtripped = markdown_to_adf(&md).unwrap();
6327 let para = &roundtripped.content[0];
6328 let children = para.content.as_ref().unwrap();
6329 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6330 assert_eq!(children[0].text.as_deref(), Some("Run "));
6331 assert_eq!(
6332 children[1].text.as_deref(),
6333 Some("ADD `custom_threshold` TEXT NOT NULL")
6334 );
6335 let marks = children[1].marks.as_ref().unwrap();
6336 assert_eq!(marks.len(), 1);
6337 assert_eq!(marks[0].mark_type, "code");
6338 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6339 }
6340
6341 #[test]
6346 fn inline_code_with_double_backtick_roundtrip() {
6347 let doc = AdfDocument {
6348 version: 1,
6349 doc_type: "doc".to_string(),
6350 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6351 "x `` y",
6352 vec![AdfMark::code()],
6353 )])],
6354 };
6355 let md = adf_to_markdown(&doc).unwrap();
6356 let roundtripped = markdown_to_adf(&md).unwrap();
6357 let content = roundtripped.content[0].content.as_ref().unwrap();
6358 assert_eq!(content.len(), 1);
6359 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6360 let marks = content[0].marks.as_ref().unwrap();
6361 assert_eq!(marks[0].mark_type, "code");
6362 }
6363
6364 #[test]
6367 fn inline_code_leading_backtick_roundtrip() {
6368 let doc = AdfDocument {
6369 version: 1,
6370 doc_type: "doc".to_string(),
6371 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6372 "`start",
6373 vec![AdfMark::code()],
6374 )])],
6375 };
6376 let md = adf_to_markdown(&doc).unwrap();
6377 let roundtripped = markdown_to_adf(&md).unwrap();
6378 let content = roundtripped.content[0].content.as_ref().unwrap();
6379 assert_eq!(content[0].text.as_deref(), Some("`start"));
6380 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6381 }
6382
6383 #[test]
6385 fn inline_code_trailing_backtick_roundtrip() {
6386 let doc = AdfDocument {
6387 version: 1,
6388 doc_type: "doc".to_string(),
6389 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6390 "end`",
6391 vec![AdfMark::code()],
6392 )])],
6393 };
6394 let md = adf_to_markdown(&doc).unwrap();
6395 let roundtripped = markdown_to_adf(&md).unwrap();
6396 let content = roundtripped.content[0].content.as_ref().unwrap();
6397 assert_eq!(content[0].text.as_deref(), Some("end`"));
6398 }
6399
6400 #[test]
6403 fn inline_code_space_padded_content_roundtrip() {
6404 let doc = AdfDocument {
6405 version: 1,
6406 doc_type: "doc".to_string(),
6407 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6408 " foo ",
6409 vec![AdfMark::code()],
6410 )])],
6411 };
6412 let md = adf_to_markdown(&doc).unwrap();
6413 let roundtripped = markdown_to_adf(&md).unwrap();
6414 let content = roundtripped.content[0].content.as_ref().unwrap();
6415 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6416 }
6417
6418 #[test]
6421 fn inline_code_all_spaces_roundtrip() {
6422 let doc = AdfDocument {
6423 version: 1,
6424 doc_type: "doc".to_string(),
6425 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6426 " ",
6427 vec![AdfMark::code()],
6428 )])],
6429 };
6430 let md = adf_to_markdown(&doc).unwrap();
6431 let roundtripped = markdown_to_adf(&md).unwrap();
6432 let content = roundtripped.content[0].content.as_ref().unwrap();
6433 assert_eq!(content[0].text.as_deref(), Some(" "));
6434 }
6435
6436 #[test]
6439 fn inline_code_with_link_and_backtick_roundtrip() {
6440 let doc = AdfDocument {
6441 version: 1,
6442 doc_type: "doc".to_string(),
6443 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6444 "fn `inner`",
6445 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6446 )])],
6447 };
6448 let md = adf_to_markdown(&doc).unwrap();
6449 assert!(
6450 md.contains("`` fn `inner` ``"),
6451 "expected padded double-backtick delimiters inside link, got: {md}"
6452 );
6453 let roundtripped = markdown_to_adf(&md).unwrap();
6454 let content = roundtripped.content[0].content.as_ref().unwrap();
6455 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6456 let mark_types: Vec<&str> = content[0]
6457 .marks
6458 .as_ref()
6459 .unwrap()
6460 .iter()
6461 .map(|m| m.mark_type.as_str())
6462 .collect();
6463 assert!(mark_types.contains(&"code"));
6464 assert!(mark_types.contains(&"link"));
6465 }
6466
6467 #[test]
6469 fn inline_code_unmatched_run_is_plain_text() {
6470 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6471 let content = doc.content[0].content.as_ref().unwrap();
6472 assert_eq!(content.len(), 1);
6473 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6474 assert!(content[0].marks.is_none());
6475 }
6476
6477 #[test]
6481 fn inline_code_mismatched_delimiters_is_plain_text() {
6482 let doc = markdown_to_adf("``foo` bar").unwrap();
6483 let content = doc.content[0].content.as_ref().unwrap();
6484 assert_eq!(content.len(), 1);
6485 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6486 assert!(content[0].marks.is_none());
6487 }
6488
6489 #[test]
6490 fn inline_code_delimiter_chooses_correct_length() {
6491 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6492 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6493 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6494 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6495 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6496 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6497 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6498 assert_eq!(inline_code_delimiter(" "), (1, false));
6499 assert_eq!(inline_code_delimiter(" "), (1, false));
6500 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6501 }
6502
6503 #[test]
6504 fn try_parse_inline_code_strips_paired_spaces() {
6505 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6506 assert_eq!(end, 11);
6507 assert_eq!(content, "`foo`");
6508 }
6509
6510 #[test]
6511 fn try_parse_inline_code_all_space_content_is_preserved() {
6512 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6513 assert_eq!(content, " ");
6514 }
6515
6516 #[test]
6517 fn try_parse_inline_code_single_run_matches_first_close() {
6518 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6519 assert_eq!(end, 5);
6520 assert_eq!(content, "foo");
6521 }
6522
6523 #[test]
6524 fn try_parse_inline_code_no_match_returns_none() {
6525 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6526 assert!(try_parse_inline_code("plain text", 0).is_none());
6527 }
6528
6529 #[test]
6530 fn is_code_fence_opener_rejects_info_with_backtick() {
6531 assert!(is_code_fence_opener("```"));
6532 assert!(is_code_fence_opener("```rust"));
6533 assert!(is_code_fence_opener("```\"\""));
6534 assert!(!is_code_fence_opener("```x `` y```"));
6535 assert!(!is_code_fence_opener("``not-enough"));
6536 assert!(!is_code_fence_opener("no fence"));
6537 }
6538
6539 #[test]
6540 fn inline_strikethrough() {
6541 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6542 let content = doc.content[0].content.as_ref().unwrap();
6543 let strike_node = &content[1];
6544 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6545 let marks = strike_node.marks.as_ref().unwrap();
6546 assert_eq!(marks[0].mark_type, "strike");
6547 }
6548
6549 #[test]
6550 fn inline_link() {
6551 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6552 let content = doc.content[0].content.as_ref().unwrap();
6553 let link_node = &content[1];
6554 assert_eq!(link_node.text.as_deref(), Some("here"));
6555 let marks = link_node.marks.as_ref().unwrap();
6556 assert_eq!(marks[0].mark_type, "link");
6557 }
6558
6559 #[test]
6560 fn block_image() {
6561 let md = "";
6562 let doc = markdown_to_adf(md).unwrap();
6563 assert_eq!(doc.content[0].node_type, "mediaSingle");
6564 }
6565
6566 #[test]
6567 fn table() {
6568 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6569 let doc = markdown_to_adf(md).unwrap();
6570 assert_eq!(doc.content[0].node_type, "table");
6571 let rows = doc.content[0].content.as_ref().unwrap();
6572 assert_eq!(rows.len(), 2); }
6574
6575 #[test]
6578 fn adf_paragraph_to_markdown() {
6579 let doc = AdfDocument {
6580 version: 1,
6581 doc_type: "doc".to_string(),
6582 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6583 };
6584 let md = adf_to_markdown(&doc).unwrap();
6585 assert_eq!(md.trim(), "Hello world");
6586 }
6587
6588 #[test]
6589 fn adf_heading_to_markdown() {
6590 let doc = AdfDocument {
6591 version: 1,
6592 doc_type: "doc".to_string(),
6593 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6594 };
6595 let md = adf_to_markdown(&doc).unwrap();
6596 assert_eq!(md.trim(), "## Title");
6597 }
6598
6599 #[test]
6600 fn adf_bold_to_markdown() {
6601 let doc = AdfDocument {
6602 version: 1,
6603 doc_type: "doc".to_string(),
6604 content: vec![AdfNode::paragraph(vec![
6605 AdfNode::text("Normal "),
6606 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6607 AdfNode::text(" text"),
6608 ])],
6609 };
6610 let md = adf_to_markdown(&doc).unwrap();
6611 assert_eq!(md.trim(), "Normal **bold** text");
6612 }
6613
6614 #[test]
6615 fn adf_code_block_to_markdown() {
6616 let doc = AdfDocument {
6617 version: 1,
6618 doc_type: "doc".to_string(),
6619 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6620 };
6621 let md = adf_to_markdown(&doc).unwrap();
6622 assert!(md.contains("```rust"));
6623 assert!(md.contains("let x = 1;"));
6624 assert!(md.contains("```"));
6625 }
6626
6627 #[test]
6628 fn adf_rule_to_markdown() {
6629 let doc = AdfDocument {
6630 version: 1,
6631 doc_type: "doc".to_string(),
6632 content: vec![AdfNode::rule()],
6633 };
6634 let md = adf_to_markdown(&doc).unwrap();
6635 assert!(md.contains("---"));
6636 }
6637
6638 #[test]
6639 fn adf_bullet_list_to_markdown() {
6640 let doc = AdfDocument {
6641 version: 1,
6642 doc_type: "doc".to_string(),
6643 content: vec![AdfNode::bullet_list(vec![
6644 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6645 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6646 ])],
6647 };
6648 let md = adf_to_markdown(&doc).unwrap();
6649 assert!(md.contains("- A"));
6650 assert!(md.contains("- B"));
6651 }
6652
6653 #[test]
6654 fn adf_link_to_markdown() {
6655 let doc = AdfDocument {
6656 version: 1,
6657 doc_type: "doc".to_string(),
6658 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6659 "click",
6660 vec![AdfMark::link("https://example.com")],
6661 )])],
6662 };
6663 let md = adf_to_markdown(&doc).unwrap();
6664 assert_eq!(md.trim(), "[click](https://example.com)");
6665 }
6666
6667 #[test]
6668 fn unsupported_block_preserved_as_json() {
6669 let doc = AdfDocument {
6670 version: 1,
6671 doc_type: "doc".to_string(),
6672 content: vec![AdfNode {
6673 node_type: "unknownBlock".to_string(),
6674 attrs: Some(serde_json::json!({"key": "value"})),
6675 content: None,
6676 text: None,
6677 marks: None,
6678 local_id: None,
6679 parameters: None,
6680 }],
6681 };
6682 let md = adf_to_markdown(&doc).unwrap();
6683 assert!(md.contains("```adf-unsupported"));
6684 assert!(md.contains("\"unknownBlock\""));
6685 }
6686
6687 #[test]
6688 fn unsupported_block_round_trips() {
6689 let original = AdfDocument {
6690 version: 1,
6691 doc_type: "doc".to_string(),
6692 content: vec![AdfNode {
6693 node_type: "unknownBlock".to_string(),
6694 attrs: Some(serde_json::json!({"key": "value"})),
6695 content: None,
6696 text: None,
6697 marks: None,
6698 local_id: None,
6699 parameters: None,
6700 }],
6701 };
6702 let md = adf_to_markdown(&original).unwrap();
6703 let restored = markdown_to_adf(&md).unwrap();
6704 assert_eq!(restored.content[0].node_type, "unknownBlock");
6705 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6706 }
6707
6708 #[test]
6711 fn round_trip_simple_document() {
6712 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6713 let adf = markdown_to_adf(md).unwrap();
6714 let restored = adf_to_markdown(&adf).unwrap();
6715
6716 assert!(restored.contains("# Hello"));
6717 assert!(restored.contains("**bold**"));
6718 assert!(restored.contains("*italic*"));
6719 assert!(restored.contains("- Item 1"));
6720 assert!(restored.contains("- Item 2"));
6721 }
6722
6723 #[test]
6724 fn round_trip_code_block() {
6725 let md = "```python\nprint('hello')\n```\n";
6726 let adf = markdown_to_adf(md).unwrap();
6727 let restored = adf_to_markdown(&adf).unwrap();
6728
6729 assert!(restored.contains("```python"));
6730 assert!(restored.contains("print('hello')"));
6731 }
6732
6733 #[test]
6734 fn round_trip_code_block_no_attrs() {
6735 let adf_json = r#"{"version":1,"type":"doc","content":[
6736 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6737 ]}"#;
6738 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6739 assert!(doc.content[0].attrs.is_none());
6740 let md = adf_to_markdown(&doc).unwrap();
6741 let round_tripped = markdown_to_adf(&md).unwrap();
6742 assert!(round_tripped.content[0].attrs.is_none());
6743 }
6744
6745 #[test]
6746 fn round_trip_code_block_empty_language() {
6747 let adf_json = r#"{"version":1,"type":"doc","content":[
6748 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6749 ]}"#;
6750 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6751 let attrs = doc.content[0].attrs.as_ref().unwrap();
6752 assert_eq!(attrs["language"], "");
6753 let md = adf_to_markdown(&doc).unwrap();
6754 let round_tripped = markdown_to_adf(&md).unwrap();
6755 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6756 assert_eq!(rt_attrs["language"], "");
6757 }
6758
6759 #[test]
6760 fn round_trip_code_block_with_language() {
6761 let adf_json = r#"{"version":1,"type":"doc","content":[
6762 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6763 ]}"#;
6764 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6765 let md = adf_to_markdown(&doc).unwrap();
6766 let round_tripped = markdown_to_adf(&md).unwrap();
6767 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6768 assert_eq!(rt_attrs["language"], "python");
6769 }
6770
6771 #[test]
6772 fn multiple_paragraphs() {
6773 let md = "First paragraph.\n\nSecond paragraph.\n";
6774 let adf = markdown_to_adf(md).unwrap();
6775 assert_eq!(adf.content.len(), 2);
6776 assert_eq!(adf.content[0].node_type, "paragraph");
6777 assert_eq!(adf.content[1].node_type, "paragraph");
6778 }
6779
6780 #[test]
6783 fn horizontal_rule_underscores() {
6784 let doc = markdown_to_adf("___").unwrap();
6785 assert_eq!(doc.content[0].node_type, "rule");
6786 }
6787
6788 #[test]
6789 fn not_a_horizontal_rule_too_short() {
6790 let doc = markdown_to_adf("--").unwrap();
6791 assert_eq!(doc.content[0].node_type, "paragraph");
6792 }
6793
6794 #[test]
6795 fn bullet_list_star_marker() {
6796 let md = "* Apple\n* Banana";
6797 let doc = markdown_to_adf(md).unwrap();
6798 assert_eq!(doc.content[0].node_type, "bulletList");
6799 let items = doc.content[0].content.as_ref().unwrap();
6800 assert_eq!(items.len(), 2);
6801 }
6802
6803 #[test]
6804 fn bullet_list_plus_marker() {
6805 let md = "+ One\n+ Two";
6806 let doc = markdown_to_adf(md).unwrap();
6807 assert_eq!(doc.content[0].node_type, "bulletList");
6808 }
6809
6810 #[test]
6811 fn ordered_list_non_one_start() {
6812 let md = "5. Fifth\n6. Sixth";
6813 let doc = markdown_to_adf(md).unwrap();
6814 let node = &doc.content[0];
6815 assert_eq!(node.node_type, "orderedList");
6816 let attrs = node.attrs.as_ref().unwrap();
6817 assert_eq!(attrs["order"], 5);
6818 }
6819
6820 #[test]
6821 fn ordered_list_start_at_one_omits_order_attr() {
6822 let md = "1. First\n2. Second";
6826 let doc = markdown_to_adf(md).unwrap();
6827 let node = &doc.content[0];
6828 assert_eq!(node.node_type, "orderedList");
6829 assert!(
6830 node.attrs.is_none(),
6831 "attrs should be omitted when order=1, got: {:?}",
6832 node.attrs
6833 );
6834 }
6835
6836 #[test]
6837 fn blockquote_bare_marker() {
6838 let md = ">quoted text";
6840 let doc = markdown_to_adf(md).unwrap();
6841 assert_eq!(doc.content[0].node_type, "blockquote");
6842 }
6843
6844 #[test]
6845 fn image_no_alt() {
6846 let md = "";
6847 let doc = markdown_to_adf(md).unwrap();
6848 let node = &doc.content[0];
6849 assert_eq!(node.node_type, "mediaSingle");
6850 let media = &node.content.as_ref().unwrap()[0];
6852 let attrs = media.attrs.as_ref().unwrap();
6853 assert!(attrs.get("alt").is_none());
6854 }
6855
6856 #[test]
6857 fn image_with_alt() {
6858 let md = "";
6859 let doc = markdown_to_adf(md).unwrap();
6860 let media = &doc.content[0].content.as_ref().unwrap()[0];
6861 let attrs = media.attrs.as_ref().unwrap();
6862 assert_eq!(attrs["alt"], "A photo");
6863 }
6864
6865 #[test]
6866 fn table_multi_body_rows() {
6867 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6868 let doc = markdown_to_adf(md).unwrap();
6869 let rows = doc.content[0].content.as_ref().unwrap();
6870 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
6873 assert_eq!(header_cells[0].node_type, "tableHeader");
6874 let body_cells = rows[1].content.as_ref().unwrap();
6876 assert_eq!(body_cells[0].node_type, "tableCell");
6877 }
6878
6879 #[test]
6880 fn table_no_separator_is_not_table() {
6881 let md = "| not | a table |";
6883 let doc = markdown_to_adf(md).unwrap();
6884 assert_eq!(doc.content[0].node_type, "paragraph");
6885 }
6886
6887 #[test]
6888 fn inline_underscore_bold() {
6889 let doc = markdown_to_adf("Some __bold__ text").unwrap();
6890 let content = doc.content[0].content.as_ref().unwrap();
6891 let bold_node = &content[1];
6892 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6893 let marks = bold_node.marks.as_ref().unwrap();
6894 assert_eq!(marks[0].mark_type, "strong");
6895 }
6896
6897 #[test]
6898 fn inline_underscore_italic() {
6899 let doc = markdown_to_adf("Some _italic_ text").unwrap();
6900 let content = doc.content[0].content.as_ref().unwrap();
6901 let italic_node = &content[1];
6902 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6903 let marks = italic_node.marks.as_ref().unwrap();
6904 assert_eq!(marks[0].mark_type, "em");
6905 }
6906
6907 #[test]
6908 fn intraword_underscore_not_emphasis() {
6909 let doc = markdown_to_adf("call do_something_useful now").unwrap();
6911 let content = doc.content[0].content.as_ref().unwrap();
6912 assert_eq!(content.len(), 1, "should be a single text node");
6913 assert_eq!(
6914 content[0].text.as_deref(),
6915 Some("call do_something_useful now")
6916 );
6917 assert!(content[0].marks.is_none());
6918 }
6919
6920 #[test]
6921 fn intraword_underscore_multiple() {
6922 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
6924 let content = doc.content[0].content.as_ref().unwrap();
6925 assert_eq!(content.len(), 1);
6926 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
6927 assert!(content[0].marks.is_none());
6928 }
6929
6930 #[test]
6931 fn intraword_double_underscore_not_bold() {
6932 let doc = markdown_to_adf("foo__bar__baz").unwrap();
6934 let content = doc.content[0].content.as_ref().unwrap();
6935 assert_eq!(content.len(), 1);
6936 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
6937 assert!(content[0].marks.is_none());
6938 }
6939
6940 #[test]
6941 fn intraword_triple_underscore_not_bold_italic() {
6942 let doc = markdown_to_adf("x___y___z").unwrap();
6944 let content = doc.content[0].content.as_ref().unwrap();
6945 assert_eq!(content.len(), 1);
6946 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
6947 assert!(content[0].marks.is_none());
6948 }
6949
6950 #[test]
6951 fn underscore_emphasis_still_works_with_spaces() {
6952 let doc = markdown_to_adf("some _italic_ here").unwrap();
6954 let content = doc.content[0].content.as_ref().unwrap();
6955 assert_eq!(content.len(), 3);
6956 assert_eq!(content[1].text.as_deref(), Some("italic"));
6957 let marks = content[1].marks.as_ref().unwrap();
6958 assert_eq!(marks[0].mark_type, "em");
6959 }
6960
6961 #[test]
6962 fn underscore_bold_still_works_with_spaces() {
6963 let doc = markdown_to_adf("some __bold__ here").unwrap();
6965 let content = doc.content[0].content.as_ref().unwrap();
6966 assert_eq!(content.len(), 3);
6967 assert_eq!(content[1].text.as_deref(), Some("bold"));
6968 let marks = content[1].marks.as_ref().unwrap();
6969 assert_eq!(marks[0].mark_type, "strong");
6970 }
6971
6972 #[test]
6973 fn intraword_underscore_closing_only() {
6974 let doc = markdown_to_adf("_foo_bar").unwrap();
6976 let content = doc.content[0].content.as_ref().unwrap();
6977 assert_eq!(content.len(), 1);
6978 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
6979 }
6980
6981 #[test]
6982 fn intraword_double_underscore_closing_only() {
6983 let doc = markdown_to_adf("__foo__bar").unwrap();
6985 let content = doc.content[0].content.as_ref().unwrap();
6986 assert_eq!(content.len(), 1);
6987 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
6988 }
6989
6990 #[test]
6991 fn intraword_triple_underscore_closing_only() {
6992 let doc = markdown_to_adf("___foo___bar").unwrap();
6994 let content = doc.content[0].content.as_ref().unwrap();
6995 assert_eq!(content.len(), 1);
6996 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6997 }
6998
6999 #[test]
7000 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7001 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7003 let content = doc.content[0].content.as_ref().unwrap();
7004 assert!(content.len() > 1 || content[0].marks.is_some());
7006 }
7007
7008 #[test]
7009 fn intraword_underscore_at_start_of_text() {
7010 let doc = markdown_to_adf("_italic_ word").unwrap();
7012 let content = doc.content[0].content.as_ref().unwrap();
7013 assert_eq!(content[0].text.as_deref(), Some("italic"));
7014 let marks = content[0].marks.as_ref().unwrap();
7015 assert_eq!(marks[0].mark_type, "em");
7016 }
7017
7018 #[test]
7019 fn intraword_underscore_at_end_of_text() {
7020 let doc = markdown_to_adf("word _italic_").unwrap();
7022 let content = doc.content[0].content.as_ref().unwrap();
7023 let last = content.last().unwrap();
7024 assert_eq!(last.text.as_deref(), Some("italic"));
7025 let marks = last.marks.as_ref().unwrap();
7026 assert_eq!(marks[0].mark_type, "em");
7027 }
7028
7029 #[test]
7030 fn intraword_underscore_opening_only() {
7031 let doc = markdown_to_adf("a_b c_d").unwrap();
7034 let content = doc.content[0].content.as_ref().unwrap();
7035 assert_eq!(content.len(), 1);
7036 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7037 }
7038
7039 #[test]
7040 fn intraword_underscore_roundtrip() {
7041 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7043 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7044 let jfm = adf_to_markdown(&adf).unwrap();
7045 let roundtripped = markdown_to_adf(&jfm).unwrap();
7046 let content = roundtripped.content[0].content.as_ref().unwrap();
7047 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7048 assert_eq!(
7049 content[0].text.as_deref(),
7050 Some("call the do_something_useful function")
7051 );
7052 assert!(content[0].marks.is_none());
7053 }
7054
7055 #[test]
7056 fn asterisk_emphasis_roundtrip() {
7057 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7059 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7060 let jfm = adf_to_markdown(&adf).unwrap();
7061 let roundtripped = markdown_to_adf(&jfm).unwrap();
7062 let content = roundtripped.content[0].content.as_ref().unwrap();
7063 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7064 assert_eq!(
7065 content[0].text.as_deref(),
7066 Some("Status: *confirmed* and active")
7067 );
7068 assert!(content[0].marks.is_none());
7069 }
7070
7071 #[test]
7072 fn double_asterisk_roundtrip() {
7073 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7075 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7076 let jfm = adf_to_markdown(&adf).unwrap();
7077 let roundtripped = markdown_to_adf(&jfm).unwrap();
7078 let content = roundtripped.content[0].content.as_ref().unwrap();
7079 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7080 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7081 assert!(content[0].marks.is_none());
7082 }
7083
7084 #[test]
7085 fn asterisk_with_em_mark_roundtrip() {
7086 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7088 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7089 let jfm = adf_to_markdown(&adf).unwrap();
7090 let roundtripped = markdown_to_adf(&jfm).unwrap();
7091 let content = roundtripped.content[0].content.as_ref().unwrap();
7092 let em_node = content.iter().find(|n| {
7094 n.marks
7095 .as_ref()
7096 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7097 });
7098 assert!(em_node.is_some(), "should have an em-marked node");
7099 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7100 }
7101
7102 #[test]
7103 fn lone_asterisk_roundtrip() {
7104 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7106 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7107 let jfm = adf_to_markdown(&adf).unwrap();
7108 let roundtripped = markdown_to_adf(&jfm).unwrap();
7109 let content = roundtripped.content[0].content.as_ref().unwrap();
7110 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7111 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7112 }
7113
7114 #[test]
7115 fn escape_emphasis_markers_unit() {
7116 assert_eq!(escape_emphasis_markers("hello"), "hello");
7117 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7118 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7119 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7120 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7121 assert_eq!(escape_emphasis_markers(""), "");
7122 }
7123
7124 #[test]
7125 fn escape_emphasis_markers_underscore_intraword() {
7126 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7129 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7130 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7131 assert_eq!(
7132 escape_emphasis_markers("call do_something_useful"),
7133 "call do_something_useful"
7134 );
7135 }
7136
7137 #[test]
7138 fn escape_emphasis_markers_underscore_at_boundary() {
7139 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7142 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7143 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7144 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7145 assert_eq!(escape_emphasis_markers("_"), r"\_");
7146 }
7147
7148 #[test]
7149 fn escape_emphasis_markers_underscore_with_punctuation() {
7150 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7152 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7153 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7154 }
7155
7156 #[test]
7157 fn find_unescaped_skips_backslash_escaped() {
7158 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7160 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7162 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7164 assert_eq!(find_unescaped("", "**"), None);
7166 }
7167
7168 #[test]
7169 fn find_unescaped_char_skips_backslash_escaped() {
7170 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7172 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7174 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7176 assert_eq!(find_unescaped_char("", b'*'), None);
7178 }
7179
7180 #[test]
7181 fn double_asterisk_in_strong_mark_roundtrip() {
7182 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7184 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7185 let jfm = adf_to_markdown(&adf).unwrap();
7186 let roundtripped = markdown_to_adf(&jfm).unwrap();
7187 let content = roundtripped.content[0].content.as_ref().unwrap();
7188 let strong_node = content.iter().find(|n| {
7189 n.marks
7190 .as_ref()
7191 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7192 });
7193 assert!(strong_node.is_some(), "should have a strong-marked node");
7194 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7195 }
7196
7197 #[test]
7198 fn backtick_code_roundtrip() {
7199 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7201 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7202 let jfm = adf_to_markdown(&adf).unwrap();
7203 let roundtripped = markdown_to_adf(&jfm).unwrap();
7204 let content = roundtripped.content[0].content.as_ref().unwrap();
7205 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7206 assert_eq!(
7207 content[0].text.as_deref(),
7208 Some("Set `max_retries` to 3 in the config")
7209 );
7210 assert!(content[0].marks.is_none());
7211 }
7212
7213 #[test]
7214 fn multiple_backtick_spans_roundtrip() {
7215 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7217 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7218 let jfm = adf_to_markdown(&adf).unwrap();
7219 let roundtripped = markdown_to_adf(&jfm).unwrap();
7220 let content = roundtripped.content[0].content.as_ref().unwrap();
7221 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7222 assert_eq!(
7223 content[0].text.as_deref(),
7224 Some("Use `foo` and `bar` together")
7225 );
7226 assert!(content[0].marks.is_none());
7227 }
7228
7229 #[test]
7230 fn lone_backtick_roundtrip() {
7231 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7233 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7234 let jfm = adf_to_markdown(&adf).unwrap();
7235 let roundtripped = markdown_to_adf(&jfm).unwrap();
7236 let content = roundtripped.content[0].content.as_ref().unwrap();
7237 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7238 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7239 assert!(content[0].marks.is_none());
7240 }
7241
7242 #[test]
7243 fn backtick_with_code_mark_roundtrip() {
7244 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7246 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7247 let jfm = adf_to_markdown(&adf).unwrap();
7248 assert_eq!(jfm.trim(), "`max_retries`");
7249 let roundtripped = markdown_to_adf(&jfm).unwrap();
7250 let content = roundtripped.content[0].content.as_ref().unwrap();
7251 let code_node = content.iter().find(|n| {
7252 n.marks
7253 .as_ref()
7254 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7255 });
7256 assert!(code_node.is_some(), "should have a code-marked node");
7257 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7258 }
7259
7260 #[test]
7261 fn backtick_with_em_mark_roundtrip() {
7262 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7264 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7265 let jfm = adf_to_markdown(&adf).unwrap();
7266 let roundtripped = markdown_to_adf(&jfm).unwrap();
7267 let content = roundtripped.content[0].content.as_ref().unwrap();
7268 let em_node = content.iter().find(|n| {
7269 n.marks
7270 .as_ref()
7271 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7272 });
7273 assert!(em_node.is_some(), "should have an em-marked node");
7274 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7275 }
7276
7277 #[test]
7278 fn escape_pipes_in_cell_unit() {
7279 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7280 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7281 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7282 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7283 assert_eq!(escape_pipes_in_cell(""), "");
7284 assert_eq!(
7285 escape_pipes_in_cell("`parser.decode[T|json]`"),
7286 r"`parser.decode[T\|json]`"
7287 );
7288 }
7289
7290 #[test]
7291 fn escape_backticks_unit() {
7292 assert_eq!(escape_backticks("hello"), "hello");
7293 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7294 assert_eq!(escape_backticks("no ticks"), "no ticks");
7295 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7296 assert_eq!(escape_backticks(""), "");
7297 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7298 }
7299
7300 #[test]
7303 fn backslash_in_text_roundtrip() {
7304 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7306 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7307 let jfm = adf_to_markdown(&adf).unwrap();
7308 let roundtripped = markdown_to_adf(&jfm).unwrap();
7309 let content = roundtripped.content[0].content.as_ref().unwrap();
7310 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7311 assert_eq!(
7312 content[0].text.as_deref(),
7313 Some(r"The path is C:\Users\admin\file.txt")
7314 );
7315 }
7316
7317 #[test]
7318 fn backslash_emitted_as_double_backslash() {
7319 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7320 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7321 let jfm = adf_to_markdown(&adf).unwrap();
7322 assert!(
7323 jfm.contains(r"a\\b"),
7324 "JFM should contain escaped backslash: {jfm}"
7325 );
7326 }
7327
7328 #[test]
7329 fn consecutive_backslashes_roundtrip() {
7330 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7331 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7332 let jfm = adf_to_markdown(&adf).unwrap();
7333 let roundtripped = markdown_to_adf(&jfm).unwrap();
7334 let content = roundtripped.content[0].content.as_ref().unwrap();
7335 assert_eq!(
7336 content[0].text.as_deref(),
7337 Some(r"a\\b"),
7338 "consecutive backslashes should survive round-trip"
7339 );
7340 }
7341
7342 #[test]
7343 fn backslash_with_strong_mark_roundtrip() {
7344 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7345 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7346 let jfm = adf_to_markdown(&adf).unwrap();
7347 let roundtripped = markdown_to_adf(&jfm).unwrap();
7348 let content = roundtripped.content[0].content.as_ref().unwrap();
7349 let strong_node = content.iter().find(|n| {
7350 n.marks
7351 .as_ref()
7352 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7353 });
7354 assert!(strong_node.is_some(), "should have a strong-marked node");
7355 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7356 }
7357
7358 #[test]
7359 fn backslash_with_code_mark_not_escaped() {
7360 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7362 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7363 let jfm = adf_to_markdown(&adf).unwrap();
7364 assert_eq!(jfm.trim(), r"`C:\Users`");
7365 let roundtripped = markdown_to_adf(&jfm).unwrap();
7366 let content = roundtripped.content[0].content.as_ref().unwrap();
7367 let code_node = content.iter().find(|n| {
7368 n.marks
7369 .as_ref()
7370 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7371 });
7372 assert!(code_node.is_some(), "should have a code-marked node");
7373 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7374 }
7375
7376 #[test]
7377 fn backslash_before_special_chars_roundtrip() {
7378 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7380 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7381 let jfm = adf_to_markdown(&adf).unwrap();
7382 let roundtripped = markdown_to_adf(&jfm).unwrap();
7383 let content = roundtripped.content[0].content.as_ref().unwrap();
7384 assert_eq!(
7385 content[0].text.as_deref(),
7386 Some(r"\*not bold\*"),
7387 "backslash before special char should survive round-trip"
7388 );
7389 }
7390
7391 #[test]
7392 fn backslash_and_newline_in_text_roundtrip() {
7393 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7395 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7396 let jfm = adf_to_markdown(&adf).unwrap();
7397 let roundtripped = markdown_to_adf(&jfm).unwrap();
7398 let content = roundtripped.content[0].content.as_ref().unwrap();
7399 assert_eq!(
7400 content[0].text.as_deref(),
7401 Some("C:\\path\nline2"),
7402 "backslash and newline should both survive round-trip"
7403 );
7404 }
7405
7406 #[test]
7407 fn lone_backslash_roundtrip() {
7408 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7409 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7410 let jfm = adf_to_markdown(&adf).unwrap();
7411 let roundtripped = markdown_to_adf(&jfm).unwrap();
7412 let content = roundtripped.content[0].content.as_ref().unwrap();
7413 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7414 }
7415
7416 #[test]
7417 fn trailing_backslash_in_text_roundtrip() {
7418 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7420 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7421 let jfm = adf_to_markdown(&adf).unwrap();
7422 let roundtripped = markdown_to_adf(&jfm).unwrap();
7423 let content = roundtripped.content[0].content.as_ref().unwrap();
7424 assert_eq!(
7425 content[0].text.as_deref(),
7426 Some(r"end\"),
7427 "trailing backslash should survive round-trip"
7428 );
7429 }
7430
7431 #[test]
7432 fn escape_bare_urls_unit() {
7433 assert_eq!(escape_bare_urls("hello"), "hello");
7434 assert_eq!(escape_bare_urls(""), "");
7435 assert_eq!(
7436 escape_bare_urls("https://example.com"),
7437 r"\https://example.com"
7438 );
7439 assert_eq!(
7440 escape_bare_urls("http://example.com"),
7441 r"\http://example.com"
7442 );
7443 assert_eq!(
7444 escape_bare_urls("see https://a.com and https://b.com"),
7445 r"see \https://a.com and \https://b.com"
7446 );
7447 assert_eq!(escape_bare_urls("http header"), "http header");
7449 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7450 }
7451
7452 #[test]
7453 fn heading_not_valid_without_space() {
7454 let doc = markdown_to_adf("#Title").unwrap();
7456 assert_eq!(doc.content[0].node_type, "paragraph");
7457 }
7458
7459 #[test]
7460 fn heading_level_too_high() {
7461 let doc = markdown_to_adf("####### Not a heading").unwrap();
7463 assert_eq!(doc.content[0].node_type, "paragraph");
7464 }
7465
7466 #[test]
7467 fn empty_document() {
7468 let doc = markdown_to_adf("").unwrap();
7469 assert!(doc.content.is_empty());
7470 }
7471
7472 #[test]
7473 fn only_blank_lines() {
7474 let doc = markdown_to_adf("\n\n\n").unwrap();
7475 assert!(doc.content.is_empty());
7476 }
7477
7478 #[test]
7479 fn code_block_unterminated() {
7480 let md = "```rust\nfn main() {}";
7482 let doc = markdown_to_adf(md).unwrap();
7483 assert_eq!(doc.content[0].node_type, "codeBlock");
7484 }
7485
7486 #[test]
7487 fn mixed_document() {
7488 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7489 let doc = markdown_to_adf(md).unwrap();
7490 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7491 assert_eq!(
7492 types,
7493 vec![
7494 "heading",
7495 "paragraph",
7496 "bulletList",
7497 "codeBlock",
7498 "blockquote",
7499 "rule",
7500 "orderedList",
7501 ]
7502 );
7503 }
7504
7505 #[test]
7508 fn adf_ordered_list_to_markdown() {
7509 let doc = AdfDocument {
7510 version: 1,
7511 doc_type: "doc".to_string(),
7512 content: vec![AdfNode::ordered_list(
7513 vec![
7514 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7515 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7516 ],
7517 None,
7518 )],
7519 };
7520 let md = adf_to_markdown(&doc).unwrap();
7521 assert!(md.contains("1. First"));
7522 assert!(md.contains("2. Second"));
7523 }
7524
7525 #[test]
7526 fn adf_ordered_list_custom_start() {
7527 let doc = AdfDocument {
7528 version: 1,
7529 doc_type: "doc".to_string(),
7530 content: vec![AdfNode::ordered_list(
7531 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7532 AdfNode::text("Third"),
7533 ])])],
7534 Some(3),
7535 )],
7536 };
7537 let md = adf_to_markdown(&doc).unwrap();
7538 assert!(md.contains("3. Third"));
7539 }
7540
7541 #[test]
7542 fn adf_blockquote_to_markdown() {
7543 let doc = AdfDocument {
7544 version: 1,
7545 doc_type: "doc".to_string(),
7546 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7547 AdfNode::text("A quote"),
7548 ])])],
7549 };
7550 let md = adf_to_markdown(&doc).unwrap();
7551 assert!(md.contains("> A quote"));
7552 }
7553
7554 #[test]
7555 fn adf_table_to_markdown() {
7556 let doc = AdfDocument {
7557 version: 1,
7558 doc_type: "doc".to_string(),
7559 content: vec![AdfNode::table(vec![
7560 AdfNode::table_row(vec![
7561 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7562 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7563 ]),
7564 AdfNode::table_row(vec![
7565 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7566 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7567 ]),
7568 ])],
7569 };
7570 let md = adf_to_markdown(&doc).unwrap();
7571 assert!(md.contains("| Name | Value |"));
7572 assert!(md.contains("| --- | --- |"));
7573 assert!(md.contains("| a | 1 |"));
7574 }
7575
7576 #[test]
7577 fn adf_media_to_markdown() {
7578 let doc = AdfDocument {
7579 version: 1,
7580 doc_type: "doc".to_string(),
7581 content: vec![AdfNode::media_single(
7582 "https://example.com/img.png",
7583 Some("Alt"),
7584 )],
7585 };
7586 let md = adf_to_markdown(&doc).unwrap();
7587 assert!(md.contains(""));
7588 }
7589
7590 #[test]
7591 fn adf_media_no_alt_to_markdown() {
7592 let doc = AdfDocument {
7593 version: 1,
7594 doc_type: "doc".to_string(),
7595 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7596 };
7597 let md = adf_to_markdown(&doc).unwrap();
7598 assert!(md.contains(""));
7599 }
7600
7601 #[test]
7602 fn adf_italic_to_markdown() {
7603 let doc = AdfDocument {
7604 version: 1,
7605 doc_type: "doc".to_string(),
7606 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7607 "emphasis",
7608 vec![AdfMark::em()],
7609 )])],
7610 };
7611 let md = adf_to_markdown(&doc).unwrap();
7612 assert_eq!(md.trim(), "*emphasis*");
7613 }
7614
7615 #[test]
7616 fn adf_strikethrough_to_markdown() {
7617 let doc = AdfDocument {
7618 version: 1,
7619 doc_type: "doc".to_string(),
7620 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7621 "deleted",
7622 vec![AdfMark::strike()],
7623 )])],
7624 };
7625 let md = adf_to_markdown(&doc).unwrap();
7626 assert_eq!(md.trim(), "~~deleted~~");
7627 }
7628
7629 #[test]
7630 fn adf_inline_code_to_markdown() {
7631 let doc = AdfDocument {
7632 version: 1,
7633 doc_type: "doc".to_string(),
7634 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7635 "code",
7636 vec![AdfMark::code()],
7637 )])],
7638 };
7639 let md = adf_to_markdown(&doc).unwrap();
7640 assert_eq!(md.trim(), "`code`");
7641 }
7642
7643 #[test]
7644 fn adf_code_with_link_to_markdown() {
7645 let doc = AdfDocument {
7646 version: 1,
7647 doc_type: "doc".to_string(),
7648 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7649 "func",
7650 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7651 )])],
7652 };
7653 let md = adf_to_markdown(&doc).unwrap();
7654 assert_eq!(md.trim(), "[`func`](https://example.com)");
7655 }
7656
7657 #[test]
7658 fn adf_bold_italic_to_markdown() {
7659 let doc = AdfDocument {
7660 version: 1,
7661 doc_type: "doc".to_string(),
7662 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7663 "both",
7664 vec![AdfMark::strong(), AdfMark::em()],
7665 )])],
7666 };
7667 let md = adf_to_markdown(&doc).unwrap();
7668 assert_eq!(md.trim(), "***both***");
7669 }
7670
7671 #[test]
7672 fn adf_bold_link_to_markdown() {
7673 let doc = AdfDocument {
7674 version: 1,
7675 doc_type: "doc".to_string(),
7676 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7677 "bold link",
7678 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7679 )])],
7680 };
7681 let md = adf_to_markdown(&doc).unwrap();
7682 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7683 }
7684
7685 #[test]
7686 fn adf_strikethrough_bold_to_markdown() {
7687 let doc = AdfDocument {
7688 version: 1,
7689 doc_type: "doc".to_string(),
7690 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7691 "struck",
7692 vec![AdfMark::strike(), AdfMark::strong()],
7693 )])],
7694 };
7695 let md = adf_to_markdown(&doc).unwrap();
7696 assert_eq!(md.trim(), "~~**struck**~~");
7697 }
7698
7699 #[test]
7700 fn adf_hard_break_to_markdown() {
7701 let doc = AdfDocument {
7702 version: 1,
7703 doc_type: "doc".to_string(),
7704 content: vec![AdfNode::paragraph(vec![
7705 AdfNode::text("Line 1"),
7706 AdfNode::hard_break(),
7707 AdfNode::text("Line 2"),
7708 ])],
7709 };
7710 let md = adf_to_markdown(&doc).unwrap();
7711 assert!(md.contains("Line 1\\\n Line 2"));
7712 }
7713
7714 #[test]
7715 #[test]
7716 fn adf_unsupported_inline_to_markdown() {
7717 let doc = AdfDocument {
7718 version: 1,
7719 doc_type: "doc".to_string(),
7720 content: vec![AdfNode::paragraph(vec![AdfNode {
7721 node_type: "unknownInline".to_string(),
7722 attrs: None,
7723 content: None,
7724 text: None,
7725 marks: None,
7726 local_id: None,
7727 parameters: None,
7728 }])],
7729 };
7730 let md = adf_to_markdown(&doc).unwrap();
7731 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7732 }
7733
7734 #[test]
7737 fn adf_media_inline_to_markdown() {
7738 let doc = AdfDocument {
7739 version: 1,
7740 doc_type: "doc".to_string(),
7741 content: vec![AdfNode::paragraph(vec![
7742 AdfNode::text("see "),
7743 AdfNode::media_inline(serde_json::json!({
7744 "type": "image",
7745 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7746 "collection": "contentId-111111",
7747 "width": 200,
7748 "height": 100
7749 })),
7750 AdfNode::text(" for details"),
7751 ])],
7752 };
7753 let md = adf_to_markdown(&doc).unwrap();
7754 assert!(md.contains(":media-inline[]{"), "got: {md}");
7755 assert!(md.contains("type=image"));
7756 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7757 assert!(md.contains("collection=contentId-111111"));
7758 assert!(md.contains("width=200"));
7759 assert!(md.contains("height=100"));
7760 assert!(!md.contains("<!-- unsupported inline"));
7761 }
7762
7763 #[test]
7764 fn media_inline_round_trip() {
7765 let doc = AdfDocument {
7766 version: 1,
7767 doc_type: "doc".to_string(),
7768 content: vec![AdfNode::paragraph(vec![
7769 AdfNode::text("see "),
7770 AdfNode::media_inline(serde_json::json!({
7771 "type": "image",
7772 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7773 "collection": "contentId-111111",
7774 "width": 200,
7775 "height": 100
7776 })),
7777 AdfNode::text(" for details"),
7778 ])],
7779 };
7780 let md = adf_to_markdown(&doc).unwrap();
7781 let rt = markdown_to_adf(&md).unwrap();
7782
7783 let content = rt.content[0].content.as_ref().unwrap();
7784 assert_eq!(content[0].text.as_deref(), Some("see "));
7785 assert_eq!(content[1].node_type, "mediaInline");
7786 let attrs = content[1].attrs.as_ref().unwrap();
7787 assert_eq!(attrs["type"], "image");
7788 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7789 assert_eq!(attrs["collection"], "contentId-111111");
7790 assert_eq!(attrs["width"], 200);
7791 assert_eq!(attrs["height"], 100);
7792 assert_eq!(content[2].text.as_deref(), Some(" for details"));
7793 }
7794
7795 #[test]
7796 fn media_inline_external_url_round_trip() {
7797 let doc = AdfDocument {
7798 version: 1,
7799 doc_type: "doc".to_string(),
7800 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7801 serde_json::json!({
7802 "type": "external",
7803 "url": "https://example.com/image.png",
7804 "alt": "example",
7805 "width": 400,
7806 "height": 300
7807 }),
7808 )])],
7809 };
7810 let md = adf_to_markdown(&doc).unwrap();
7811 let rt = markdown_to_adf(&md).unwrap();
7812
7813 let content = rt.content[0].content.as_ref().unwrap();
7814 assert_eq!(content[0].node_type, "mediaInline");
7815 let attrs = content[0].attrs.as_ref().unwrap();
7816 assert_eq!(attrs["type"], "external");
7817 assert_eq!(attrs["url"], "https://example.com/image.png");
7818 assert_eq!(attrs["alt"], "example");
7819 assert_eq!(attrs["width"], 400);
7820 assert_eq!(attrs["height"], 300);
7821 }
7822
7823 #[test]
7824 fn media_inline_minimal_attrs() {
7825 let doc = AdfDocument {
7826 version: 1,
7827 doc_type: "doc".to_string(),
7828 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7829 serde_json::json!({"type": "file", "id": "abc-123"}),
7830 )])],
7831 };
7832 let md = adf_to_markdown(&doc).unwrap();
7833 let rt = markdown_to_adf(&md).unwrap();
7834
7835 let content = rt.content[0].content.as_ref().unwrap();
7836 assert_eq!(content[0].node_type, "mediaInline");
7837 let attrs = content[0].attrs.as_ref().unwrap();
7838 assert_eq!(attrs["type"], "file");
7839 assert_eq!(attrs["id"], "abc-123");
7840 }
7841
7842 #[test]
7843 fn media_inline_from_issue_476_reproducer() {
7844 let adf_json: serde_json::Value = serde_json::json!({
7846 "type": "doc",
7847 "version": 1,
7848 "content": [
7849 {
7850 "type": "paragraph",
7851 "content": [
7852 {"type": "text", "text": "see "},
7853 {
7854 "type": "mediaInline",
7855 "attrs": {
7856 "collection": "contentId-111111",
7857 "height": 100,
7858 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7859 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7860 "type": "image",
7861 "width": 200
7862 }
7863 },
7864 {"type": "text", "text": " for details"}
7865 ]
7866 }
7867 ]
7868 });
7869 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7870 let md = adf_to_markdown(&doc).unwrap();
7871 assert!(
7872 !md.contains("<!-- unsupported inline"),
7873 "mediaInline should not be unsupported; got: {md}"
7874 );
7875
7876 let rt = markdown_to_adf(&md).unwrap();
7877 let content = rt.content[0].content.as_ref().unwrap();
7878 assert_eq!(content[1].node_type, "mediaInline");
7879 let attrs = content[1].attrs.as_ref().unwrap();
7880 assert_eq!(attrs["type"], "image");
7881 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7882 assert_eq!(attrs["collection"], "contentId-111111");
7883 assert_eq!(attrs["width"], 200);
7884 assert_eq!(attrs["height"], 100);
7885 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7886 }
7887
7888 #[test]
7889 fn emoji_shortcode() {
7890 let doc = markdown_to_adf("Hello :wave: world").unwrap();
7891 let content = doc.content[0].content.as_ref().unwrap();
7892 assert_eq!(content[0].text.as_deref(), Some("Hello "));
7893 assert_eq!(content[1].node_type, "emoji");
7894 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7895 assert_eq!(content[2].text.as_deref(), Some(" world"));
7896 }
7897
7898 #[test]
7899 fn adf_emoji_to_markdown() {
7900 let doc = AdfDocument {
7901 version: 1,
7902 doc_type: "doc".to_string(),
7903 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7904 };
7905 let md = adf_to_markdown(&doc).unwrap();
7906 assert!(md.contains(":thumbsup:"));
7907 }
7908
7909 #[test]
7910 fn adf_emoji_with_colon_prefix_to_markdown() {
7911 let doc = AdfDocument {
7913 version: 1,
7914 doc_type: "doc".to_string(),
7915 content: vec![AdfNode::paragraph(vec![AdfNode {
7916 node_type: "emoji".to_string(),
7917 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
7918 content: None,
7919 text: None,
7920 marks: None,
7921 local_id: None,
7922 parameters: None,
7923 }])],
7924 };
7925 let md = adf_to_markdown(&doc).unwrap();
7926 assert!(md.contains(":thumbsup:"));
7927 assert!(!md.contains("::thumbsup::"));
7929 }
7930
7931 #[test]
7932 fn round_trip_emoji() {
7933 let md = "Hello :wave: world\n";
7934 let doc = markdown_to_adf(md).unwrap();
7935 let result = adf_to_markdown(&doc).unwrap();
7936 assert!(result.contains(":wave:"));
7937 }
7938
7939 #[test]
7940 fn emoji_with_id_and_text_round_trips() {
7941 let doc = AdfDocument {
7942 version: 1,
7943 doc_type: "doc".to_string(),
7944 content: vec![AdfNode::paragraph(vec![AdfNode {
7945 node_type: "emoji".to_string(),
7946 attrs: Some(
7947 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
7948 ),
7949 content: None,
7950 text: None,
7951 marks: None,
7952 local_id: None,
7953 parameters: None,
7954 }])],
7955 };
7956 let md = adf_to_markdown(&doc).unwrap();
7957 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
7958 assert!(md.contains("id="), "id attr present: {md}");
7959 assert!(md.contains("text="), "text attr present: {md}");
7960
7961 let round_tripped = markdown_to_adf(&md).unwrap();
7963 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7964 let attrs = emoji.attrs.as_ref().unwrap();
7965 assert_eq!(attrs["shortName"], ":check_mark:");
7966 assert_eq!(attrs["id"], "2705");
7967 assert_eq!(attrs["text"], "✅");
7968 }
7969
7970 #[test]
7971 fn emoji_without_extra_attrs_still_works() {
7972 let md = "Hello :wave: world\n";
7973 let doc = markdown_to_adf(md).unwrap();
7974 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
7975 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
7976 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
7978 }
7979
7980 #[test]
7981 fn emoji_shortname_preserves_colons_round_trip() {
7982 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7984 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
7985 ]}]}"#;
7986 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7987
7988 let md = adf_to_markdown(&doc).unwrap();
7990 let round_tripped = markdown_to_adf(&md).unwrap();
7991 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7992 let attrs = emoji.attrs.as_ref().unwrap();
7993 assert_eq!(
7994 attrs["shortName"], ":cross_mark:",
7995 "shortName should preserve colons, got: {}",
7996 attrs["shortName"]
7997 );
7998 assert_eq!(attrs["id"], "atlassian-cross_mark");
7999 assert_eq!(attrs["text"], "❌");
8000 }
8001
8002 #[test]
8003 fn emoji_shortname_without_colons_preserved() {
8004 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8006 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8007 ]}]}"#;
8008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8009 let md = adf_to_markdown(&doc).unwrap();
8010 let round_tripped = markdown_to_adf(&md).unwrap();
8011 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8012 let attrs = emoji.attrs.as_ref().unwrap();
8013 assert_eq!(
8014 attrs["shortName"], "white_check_mark",
8015 "shortName without colons should stay without colons, got: {}",
8016 attrs["shortName"]
8017 );
8018 }
8019
8020 #[test]
8021 fn colon_in_text_not_emoji() {
8022 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8024 let content = doc.content[0].content.as_ref().unwrap();
8025 assert_eq!(content.len(), 1);
8026 assert_eq!(content[0].node_type, "text");
8027 }
8028
8029 #[test]
8030 fn text_with_shortcode_pattern_round_trips_as_text() {
8031 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8033 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8034
8035 let md = adf_to_markdown(&doc).unwrap();
8036 let round_tripped = markdown_to_adf(&md).unwrap();
8037 let content = round_tripped.content[0].content.as_ref().unwrap();
8038
8039 assert_eq!(
8040 content.len(),
8041 1,
8042 "should be a single text node, got: {content:?}"
8043 );
8044 assert_eq!(content[0].node_type, "text");
8045 assert_eq!(
8046 content[0].text.as_deref().unwrap(),
8047 "Alert :fire: triggered on pod:pod42"
8048 );
8049 }
8050
8051 #[test]
8052 fn double_colon_pattern_round_trips_as_text() {
8053 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8055 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8056
8057 let md = adf_to_markdown(&doc).unwrap();
8058 let round_tripped = markdown_to_adf(&md).unwrap();
8059 let content = round_tripped.content[0].content.as_ref().unwrap();
8060
8061 assert_eq!(
8062 content.len(),
8063 1,
8064 "should be a single text node, got: {content:?}"
8065 );
8066 assert_eq!(content[0].node_type, "text");
8067 assert_eq!(
8068 content[0].text.as_deref().unwrap(),
8069 "Status::Active::Running"
8070 );
8071 }
8072
8073 #[test]
8074 fn real_emoji_node_still_round_trips() {
8075 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8077 {"type":"text","text":"Hello "},
8078 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8079 {"type":"text","text":" world"}
8080 ]}]}"#;
8081 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8082
8083 let md = adf_to_markdown(&doc).unwrap();
8084 let round_tripped = markdown_to_adf(&md).unwrap();
8085 let content = round_tripped.content[0].content.as_ref().unwrap();
8086
8087 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8089 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8090 assert_eq!(content[1].node_type, "emoji");
8091 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8092 assert_eq!(content[2].text.as_deref(), Some(" world"));
8093 }
8094
8095 #[test]
8096 fn combined_emoji_shortname_round_trips_as_single_node() {
8097 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8102 {"type":"text","text":"Thanks for the help "},
8103 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8104 ]}]}"#;
8105 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8106
8107 let md = adf_to_markdown(&doc).unwrap();
8108 let round_tripped = markdown_to_adf(&md).unwrap();
8109 let content = round_tripped.content[0].content.as_ref().unwrap();
8110
8111 assert_eq!(
8112 content.len(),
8113 2,
8114 "should have text + single combined emoji: {content:?}"
8115 );
8116 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8117 assert_eq!(content[1].node_type, "emoji");
8118 let attrs = content[1].attrs.as_ref().unwrap();
8119 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8120 assert_eq!(attrs["id"], "");
8121 assert_eq!(attrs["text"], "");
8122 }
8123
8124 #[test]
8125 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8126 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8128 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8129 ]}]}"#;
8130 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8131
8132 let md = adf_to_markdown(&doc).unwrap();
8133 let round_tripped = markdown_to_adf(&md).unwrap();
8134 let content = round_tripped.content[0].content.as_ref().unwrap();
8135
8136 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8137 assert_eq!(content[0].node_type, "emoji");
8138 let attrs = content[0].attrs.as_ref().unwrap();
8139 assert_eq!(attrs["shortName"], ":a::b::c:");
8140 assert_eq!(attrs["id"], "x");
8141 }
8142
8143 #[test]
8144 fn consecutive_emojis_remain_separate_nodes() {
8145 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8149 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8150 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8151 ]}]}"#;
8152 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8153
8154 let md = adf_to_markdown(&doc).unwrap();
8155 let round_tripped = markdown_to_adf(&md).unwrap();
8156 let content = round_tripped.content[0].content.as_ref().unwrap();
8157
8158 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8159 assert_eq!(content[0].node_type, "emoji");
8160 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8161 assert_eq!(content[1].node_type, "emoji");
8162 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8163 }
8164
8165 #[test]
8166 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8167 let md = ":fire::water:";
8170 let doc = markdown_to_adf(md).unwrap();
8171 let content = doc.content[0].content.as_ref().unwrap();
8172
8173 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8174 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8175 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8176 }
8177
8178 #[test]
8179 fn combined_emoji_shortname_preserves_local_id() {
8180 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8183 let doc = markdown_to_adf(md).unwrap();
8184 let content = doc.content[0].content.as_ref().unwrap();
8185
8186 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8187 let attrs = content[0].attrs.as_ref().unwrap();
8188 assert_eq!(attrs["shortName"], ":a::b:");
8189 assert_eq!(attrs["id"], "x");
8190 assert_eq!(attrs["text"], "y");
8191 assert_eq!(attrs["localId"], "abc");
8192 }
8193
8194 #[test]
8195 fn text_shortcode_with_marks_round_trips() {
8196 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8198 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8199 ]}]}"#;
8200 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8201
8202 let md = adf_to_markdown(&doc).unwrap();
8203 let round_tripped = markdown_to_adf(&md).unwrap();
8204 let content = round_tripped.content[0].content.as_ref().unwrap();
8205
8206 assert_eq!(
8207 content.len(),
8208 1,
8209 "should be single bold text node: {content:?}"
8210 );
8211 assert_eq!(content[0].node_type, "text");
8212 assert_eq!(
8213 content[0].text.as_deref().unwrap(),
8214 "Alert :fire: triggered"
8215 );
8216 assert!(content[0]
8217 .marks
8218 .as_ref()
8219 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8220 }
8221
8222 #[test]
8223 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8224 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8226 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8227 {"type":"text","text":" says :hello: to you"}
8228 ]}]}"#;
8229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8230
8231 let md = adf_to_markdown(&doc).unwrap();
8232 let round_tripped = markdown_to_adf(&md).unwrap();
8233 let content = round_tripped.content[0].content.as_ref().unwrap();
8234
8235 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8237 assert_eq!(content[0].node_type, "emoji");
8238 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8239 assert_eq!(content[1].node_type, "text");
8240 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8241 }
8242
8243 #[test]
8244 fn code_block_with_shortcode_pattern_round_trips() {
8245 let adf_json = r#"{"version":1,"type":"doc","content":[
8248 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8249 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8250 ]}
8251 ]}"#;
8252 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8253
8254 let md = adf_to_markdown(&doc).unwrap();
8255 let round_tripped = markdown_to_adf(&md).unwrap();
8256
8257 assert_eq!(
8258 round_tripped.content.len(),
8259 1,
8260 "should be a single codeBlock"
8261 );
8262 let cb = &round_tripped.content[0];
8263 assert_eq!(cb.node_type, "codeBlock");
8264 let content = cb.content.as_ref().expect("codeBlock content");
8265 assert_eq!(
8266 content.len(),
8267 1,
8268 "should be a single text node: {content:?}"
8269 );
8270 assert_eq!(content[0].node_type, "text");
8271 assert_eq!(
8272 content[0].text.as_deref().unwrap(),
8273 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8274 );
8275 assert!(
8276 content.iter().all(|n| n.node_type != "emoji"),
8277 "no emoji nodes should be present, got: {content:?}"
8278 );
8279 }
8280
8281 #[test]
8282 fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8283 let adf_json = r#"{"version":1,"type":"doc","content":[
8285 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8286 {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8287 ]}
8288 ]}"#;
8289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8290
8291 let md = adf_to_markdown(&doc).unwrap();
8292 let round_tripped = markdown_to_adf(&md).unwrap();
8293
8294 let cb = &round_tripped.content[0];
8295 assert_eq!(cb.node_type, "codeBlock");
8296 let content = cb.content.as_ref().expect("codeBlock content");
8297 assert_eq!(content.len(), 1, "should be a single text node");
8298 assert_eq!(
8299 content[0].text.as_deref().unwrap(),
8300 "class ZBC::Zendesk::PlanType::Professional < Base"
8301 );
8302 }
8303
8304 #[test]
8305 fn code_block_with_literal_shortcode_round_trips() {
8306 let adf_json = r#"{"version":1,"type":"doc","content":[
8309 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8310 {"type":"text","text":":fire: :wave: :thumbsup:"}
8311 ]}
8312 ]}"#;
8313 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8314
8315 let md = adf_to_markdown(&doc).unwrap();
8316 let round_tripped = markdown_to_adf(&md).unwrap();
8317
8318 let cb = &round_tripped.content[0];
8319 assert_eq!(cb.node_type, "codeBlock");
8320 let content = cb.content.as_ref().expect("codeBlock content");
8321 assert_eq!(
8322 content.len(),
8323 1,
8324 "should be a single text node: {content:?}"
8325 );
8326 assert_eq!(content[0].node_type, "text");
8327 assert_eq!(
8328 content[0].text.as_deref().unwrap(),
8329 ":fire: :wave: :thumbsup:"
8330 );
8331 }
8332
8333 #[test]
8334 fn inline_code_with_shortcode_pattern_round_trips() {
8335 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8338 {"type":"text","text":"See "},
8339 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8340 {"type":"text","text":" for details"}
8341 ]}]}"#;
8342 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8343
8344 let md = adf_to_markdown(&doc).unwrap();
8345 let round_tripped = markdown_to_adf(&md).unwrap();
8346 let content = round_tripped.content[0].content.as_ref().unwrap();
8347
8348 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8349 assert_eq!(content[0].text.as_deref(), Some("See "));
8350 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8351 assert!(content[1]
8352 .marks
8353 .as_ref()
8354 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8355 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8356 assert!(
8357 content.iter().all(|n| n.node_type != "emoji"),
8358 "no emoji nodes should be present"
8359 );
8360 }
8361
8362 #[test]
8363 fn inline_code_with_literal_shortcode_round_trips() {
8364 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8367 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8368 ]}]}"#;
8369 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8370
8371 let md = adf_to_markdown(&doc).unwrap();
8372 let round_tripped = markdown_to_adf(&md).unwrap();
8373 let content = round_tripped.content[0].content.as_ref().unwrap();
8374
8375 assert_eq!(
8376 content.len(),
8377 1,
8378 "should be a single code node: {content:?}"
8379 );
8380 assert_eq!(content[0].node_type, "text");
8381 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8382 assert!(content[0]
8383 .marks
8384 .as_ref()
8385 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8386 }
8387
8388 #[test]
8389 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8390 let adf_json = r#"{"version":1,"type":"doc","content":[
8393 {"type":"bulletList","content":[
8394 {"type":"listItem","content":[
8395 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8396 {"type":"text","text":"Foo::Bar::Baz"}
8397 ]}
8398 ]}
8399 ]}
8400 ]}"#;
8401 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8402
8403 let md = adf_to_markdown(&doc).unwrap();
8404 let round_tripped = markdown_to_adf(&md).unwrap();
8405
8406 let list = &round_tripped.content[0];
8407 assert_eq!(list.node_type, "bulletList");
8408 let item = &list.content.as_ref().unwrap()[0];
8409 assert_eq!(item.node_type, "listItem");
8410 let cb = &item.content.as_ref().unwrap()[0];
8411 assert_eq!(cb.node_type, "codeBlock");
8412 let cb_content = cb.content.as_ref().unwrap();
8413 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8414 assert_eq!(cb_content[0].node_type, "text");
8415 }
8416
8417 #[test]
8418 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8419 let adf_json = r#"{"version":1,"type":"doc","content":[
8423 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8424 {"type":"text","text":"class ZBC::配置::Production < Base"}
8425 ]}
8426 ]}"#;
8427 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8428
8429 let md = adf_to_markdown(&doc).unwrap();
8430 let round_tripped = markdown_to_adf(&md).unwrap();
8431
8432 let cb = &round_tripped.content[0];
8433 assert_eq!(cb.node_type, "codeBlock");
8434 let content = cb.content.as_ref().expect("codeBlock content");
8435 assert_eq!(content.len(), 1);
8436 assert_eq!(
8437 content[0].text.as_deref().unwrap(),
8438 "class ZBC::配置::Production < Base"
8439 );
8440 }
8441
8442 #[test]
8443 fn list_item_hardbreak_then_code_block_round_trips() {
8444 let adf_json = r#"{"version":1,"type":"doc","content":[
8450 {"type":"bulletList","content":[
8451 {"type":"listItem","content":[
8452 {"type":"paragraph","content":[
8453 {"type":"text","text":"Consider removing this check."},
8454 {"type":"hardBreak"}
8455 ]},
8456 {"type":"codeBlock","content":[
8457 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8458 ]}
8459 ]}
8460 ]}
8461 ]}"#;
8462 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8463
8464 let md = adf_to_markdown(&doc).unwrap();
8465 let round_tripped = markdown_to_adf(&md).unwrap();
8466
8467 let list = &round_tripped.content[0];
8468 assert_eq!(list.node_type, "bulletList");
8469 let item = &list.content.as_ref().unwrap()[0];
8470 assert_eq!(item.node_type, "listItem");
8471 let item_content = item.content.as_ref().unwrap();
8472 assert_eq!(
8473 item_content.len(),
8474 2,
8475 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8476 );
8477 assert_eq!(item_content[0].node_type, "paragraph");
8478 assert_eq!(item_content[1].node_type, "codeBlock");
8479
8480 let cb_content = item_content[1].content.as_ref().unwrap();
8482 assert_eq!(cb_content[0].node_type, "text");
8483 assert_eq!(
8484 cb_content[0].text.as_deref().unwrap(),
8485 "x = Foo::Bar::Baz.new"
8486 );
8487
8488 assert!(
8490 item_content
8491 .iter()
8492 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8493 .all(|n| n.node_type != "emoji"),
8494 "no emoji nodes should be present, got: {item_content:?}"
8495 );
8496 }
8497
8498 #[test]
8499 fn list_item_hardbreak_then_nested_list_still_works() {
8500 let md = "- first\\\n continuation text\\\n - nested item\n";
8504 let doc = markdown_to_adf(md).unwrap();
8505 let list = &doc.content[0];
8506 assert_eq!(list.node_type, "bulletList");
8507 let item = &list.content.as_ref().unwrap()[0];
8508 let item_content = item.content.as_ref().unwrap();
8510 let para = &item_content[0];
8511 assert_eq!(para.node_type, "paragraph");
8512 let para_nodes = para.content.as_ref().unwrap();
8513 assert!(
8514 para_nodes
8515 .iter()
8516 .any(|n| n.text.as_deref() == Some("continuation text")),
8517 "continuation text should survive: {para_nodes:?}"
8518 );
8519 }
8520
8521 #[test]
8522 fn list_item_hardbreak_then_image_still_works() {
8523 let md = "- first\\\n {type=file id=x}\n";
8528 let doc = markdown_to_adf(md).unwrap();
8529 let list = &doc.content[0];
8530 let item = &list.content.as_ref().unwrap()[0];
8531 let item_content = item.content.as_ref().unwrap();
8532 assert!(
8534 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8535 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8536 );
8537 }
8538
8539 #[test]
8540 fn list_item_hardbreak_then_container_directive_round_trips() {
8541 let adf_json = r#"{"version":1,"type":"doc","content":[
8545 {"type":"bulletList","content":[
8546 {"type":"listItem","content":[
8547 {"type":"paragraph","content":[
8548 {"type":"text","text":"intro"},
8549 {"type":"hardBreak"}
8550 ]},
8551 {"type":"panel","attrs":{"panelType":"info"},"content":[
8552 {"type":"paragraph","content":[
8553 {"type":"text","text":"panel body"}
8554 ]}
8555 ]}
8556 ]}
8557 ]}
8558 ]}"#;
8559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8560 let md = adf_to_markdown(&doc).unwrap();
8561 let round_tripped = markdown_to_adf(&md).unwrap();
8562
8563 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8564 let item_content = item.content.as_ref().unwrap();
8565 assert!(
8566 item_content.iter().any(|n| n.node_type == "panel"),
8567 "panel should survive as block-level sibling, got: {item_content:?}"
8568 );
8569 }
8570
8571 #[test]
8572 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8573 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8576 {"type":"text","text":"See "},
8577 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8578 {"type":"text","text":" for prod"}
8579 ]}]}"#;
8580 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8581
8582 let md = adf_to_markdown(&doc).unwrap();
8583 let round_tripped = markdown_to_adf(&md).unwrap();
8584 let content = round_tripped.content[0].content.as_ref().unwrap();
8585
8586 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8587 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8588 assert!(content[1]
8589 .marks
8590 .as_ref()
8591 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8592 }
8593
8594 #[test]
8595 fn code_block_followed_by_shortcode_text_round_trips() {
8596 let adf_json = r#"{"version":1,"type":"doc","content":[
8599 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8600 {"type":"text","text":"Foo::Bar::Baz"}
8601 ]},
8602 {"type":"paragraph","content":[
8603 {"type":"text","text":"Status::Active::Running"}
8604 ]}
8605 ]}"#;
8606 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8607
8608 let md = adf_to_markdown(&doc).unwrap();
8609 let round_tripped = markdown_to_adf(&md).unwrap();
8610
8611 assert_eq!(round_tripped.content.len(), 2);
8612 let cb = &round_tripped.content[0];
8613 assert_eq!(cb.node_type, "codeBlock");
8614 let cb_content = cb.content.as_ref().unwrap();
8615 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8616
8617 let para = &round_tripped.content[1];
8618 assert_eq!(para.node_type, "paragraph");
8619 let para_content = para.content.as_ref().unwrap();
8620 assert_eq!(para_content.len(), 1);
8621 assert_eq!(para_content[0].node_type, "text");
8622 assert_eq!(
8623 para_content[0].text.as_deref(),
8624 Some("Status::Active::Running")
8625 );
8626 }
8627
8628 #[test]
8629 fn adf_inline_card_to_markdown() {
8630 let doc = AdfDocument {
8631 version: 1,
8632 doc_type: "doc".to_string(),
8633 content: vec![AdfNode::paragraph(vec![AdfNode {
8634 node_type: "inlineCard".to_string(),
8635 attrs: Some(
8636 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8637 ),
8638 content: None,
8639 text: None,
8640 marks: None,
8641 local_id: None,
8642 parameters: None,
8643 }])],
8644 };
8645 let md = adf_to_markdown(&doc).unwrap();
8646 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8647 assert!(!md.contains("<!-- unsupported inline"));
8648 }
8649
8650 #[test]
8651 fn inline_card_directive_round_trips() {
8652 let original = AdfDocument {
8654 version: 1,
8655 doc_type: "doc".to_string(),
8656 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8657 "https://org.atlassian.net/browse/ACCS-4382",
8658 )])],
8659 };
8660 let md = adf_to_markdown(&original).unwrap();
8661 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8662 let restored = markdown_to_adf(&md).unwrap();
8663 let node = &restored.content[0].content.as_ref().unwrap()[0];
8664 assert_eq!(node.node_type, "inlineCard");
8665 assert_eq!(
8666 node.attrs.as_ref().unwrap()["url"],
8667 "https://org.atlassian.net/browse/ACCS-4382"
8668 );
8669 }
8670
8671 #[test]
8672 fn inline_card_directive_parsed_from_jfm() {
8673 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8675 let nodes = doc.content[0].content.as_ref().unwrap();
8676 assert_eq!(nodes[0].node_type, "text");
8677 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8678 assert_eq!(nodes[1].node_type, "inlineCard");
8679 assert_eq!(
8680 nodes[1].attrs.as_ref().unwrap()["url"],
8681 "https://example.com/issue/123"
8682 );
8683 assert_eq!(nodes[2].node_type, "text");
8684 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8685 }
8686
8687 #[test]
8688 fn self_link_becomes_link_mark_not_inline_card() {
8689 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8692 let node = &doc.content[0].content.as_ref().unwrap()[0];
8693 assert_eq!(node.node_type, "text");
8694 assert_eq!(node.text.as_deref(), Some("https://example.com"));
8695 let mark = &node.marks.as_ref().unwrap()[0];
8696 assert_eq!(mark.mark_type, "link");
8697 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8698 }
8699
8700 #[test]
8701 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8702 let doc =
8705 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8706 let node = &doc.content[0].content.as_ref().unwrap()[0];
8707 assert_eq!(node.node_type, "text");
8708 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8709 let mark = &node.marks.as_ref().unwrap()[0];
8710 assert_eq!(mark.mark_type, "link");
8711 assert_eq!(
8712 mark.attrs.as_ref().unwrap()["href"],
8713 "https://octopz.example.com/"
8714 );
8715 }
8716
8717 #[test]
8718 fn named_link_does_not_become_inline_card() {
8719 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8721 let node = &doc.content[0].content.as_ref().unwrap()[0];
8722 assert_eq!(node.node_type, "text");
8723 assert_eq!(node.text.as_deref(), Some("#4668"));
8724 let mark = &node.marks.as_ref().unwrap()[0];
8725 assert_eq!(mark.mark_type, "link");
8726 }
8727
8728 #[test]
8729 fn adf_inline_card_no_url_to_markdown() {
8730 let doc = AdfDocument {
8731 version: 1,
8732 doc_type: "doc".to_string(),
8733 content: vec![AdfNode::paragraph(vec![AdfNode {
8734 node_type: "inlineCard".to_string(),
8735 attrs: Some(serde_json::json!({})),
8736 content: None,
8737 text: None,
8738 marks: None,
8739 local_id: None,
8740 parameters: None,
8741 }])],
8742 };
8743 let md = adf_to_markdown(&doc).unwrap();
8744 assert!(!md.contains("<!-- unsupported inline"));
8746 }
8747
8748 #[test]
8749 fn adf_code_block_no_language_to_markdown() {
8750 let doc = AdfDocument {
8751 version: 1,
8752 doc_type: "doc".to_string(),
8753 content: vec![AdfNode::code_block(None, "plain code")],
8754 };
8755 let md = adf_to_markdown(&doc).unwrap();
8756 assert!(md.contains("```\n"));
8757 assert!(md.contains("plain code"));
8758 }
8759
8760 #[test]
8761 fn adf_code_block_empty_language_to_markdown() {
8762 let doc = AdfDocument {
8763 version: 1,
8764 doc_type: "doc".to_string(),
8765 content: vec![AdfNode::code_block(Some(""), "plain code")],
8766 };
8767 let md = adf_to_markdown(&doc).unwrap();
8768 assert!(md.contains("```\"\"\n"));
8769 assert!(md.contains("plain code"));
8770 }
8771
8772 #[test]
8775 fn round_trip_table() {
8776 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8777 let adf = markdown_to_adf(md).unwrap();
8778 let restored = adf_to_markdown(&adf).unwrap();
8779 assert!(restored.contains("| A | B |"));
8780 assert!(restored.contains("| 1 | 2 |"));
8781 }
8782
8783 #[test]
8784 fn round_trip_blockquote() {
8785 let md = "> This is quoted\n";
8786 let adf = markdown_to_adf(md).unwrap();
8787 let restored = adf_to_markdown(&adf).unwrap();
8788 assert!(restored.contains("> This is quoted"));
8789 }
8790
8791 #[test]
8792 fn round_trip_image() {
8793 let md = "\n";
8794 let adf = markdown_to_adf(md).unwrap();
8795 let restored = adf_to_markdown(&adf).unwrap();
8796 assert!(restored.contains(""));
8797 }
8798
8799 #[test]
8800 fn round_trip_ordered_list() {
8801 let md = "1. A\n2. B\n3. C\n";
8802 let adf = markdown_to_adf(md).unwrap();
8803 let restored = adf_to_markdown(&adf).unwrap();
8804 assert!(restored.contains("1. A"));
8805 assert!(restored.contains("2. B"));
8806 assert!(restored.contains("3. C"));
8807 }
8808
8809 #[test]
8810 fn round_trip_inline_marks() {
8811 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8812 let adf = markdown_to_adf(md).unwrap();
8813 let restored = adf_to_markdown(&adf).unwrap();
8814 assert!(restored.contains("`code`"));
8815 assert!(restored.contains("~~strike~~"));
8816 assert!(restored.contains("[link](https://x.com)"));
8817 }
8818
8819 #[test]
8822 fn panel_info() {
8823 let md = ":::panel{type=info}\nThis is informational.\n:::";
8824 let doc = markdown_to_adf(md).unwrap();
8825 assert_eq!(doc.content[0].node_type, "panel");
8826 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8827 let inner = doc.content[0].content.as_ref().unwrap();
8828 assert_eq!(inner[0].node_type, "paragraph");
8829 }
8830
8831 #[test]
8832 fn adf_panel_to_markdown() {
8833 let doc = AdfDocument {
8834 version: 1,
8835 doc_type: "doc".to_string(),
8836 content: vec![AdfNode::panel(
8837 "warning",
8838 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8839 )],
8840 };
8841 let md = adf_to_markdown(&doc).unwrap();
8842 assert!(md.contains(":::panel{type=warning}"));
8843 assert!(md.contains("Be careful."));
8844 assert!(md.contains(":::"));
8845 }
8846
8847 #[test]
8848 fn round_trip_panel() {
8849 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8850 let doc = markdown_to_adf(md).unwrap();
8851 let result = adf_to_markdown(&doc).unwrap();
8852 assert!(result.contains(":::panel{type=info}"));
8853 assert!(result.contains("This is informational."));
8854 }
8855
8856 #[test]
8857 fn expand_with_title() {
8858 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8859 let doc = markdown_to_adf(md).unwrap();
8860 assert_eq!(doc.content[0].node_type, "expand");
8861 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8862 }
8863
8864 #[test]
8865 fn adf_expand_to_markdown() {
8866 let doc = AdfDocument {
8867 version: 1,
8868 doc_type: "doc".to_string(),
8869 content: vec![AdfNode::expand(
8870 Some("Details"),
8871 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8872 )],
8873 };
8874 let md = adf_to_markdown(&doc).unwrap();
8875 assert!(md.contains(":::expand{title=\"Details\"}"));
8876 assert!(md.contains("Inner."));
8877 }
8878
8879 #[test]
8880 fn round_trip_expand() {
8881 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8882 let doc = markdown_to_adf(md).unwrap();
8883 let result = adf_to_markdown(&doc).unwrap();
8884 assert!(result.contains(":::expand{title=\"Details\"}"));
8885 assert!(result.contains("Inner content."));
8886 }
8887
8888 #[test]
8889 fn layout_two_columns() {
8890 let md =
8891 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8892 let doc = markdown_to_adf(md).unwrap();
8893 assert_eq!(doc.content[0].node_type, "layoutSection");
8894 let columns = doc.content[0].content.as_ref().unwrap();
8895 assert_eq!(columns.len(), 2);
8896 assert_eq!(columns[0].node_type, "layoutColumn");
8897 assert_eq!(columns[1].node_type, "layoutColumn");
8898 }
8899
8900 #[test]
8901 fn adf_layout_to_markdown() {
8902 let doc = AdfDocument {
8903 version: 1,
8904 doc_type: "doc".to_string(),
8905 content: vec![AdfNode::layout_section(vec![
8906 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
8907 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
8908 ])],
8909 };
8910 let md = adf_to_markdown(&doc).unwrap();
8911 assert!(md.contains("::::layout"));
8912 assert!(md.contains(":::column{width=50}"));
8913 assert!(md.contains("Left."));
8914 assert!(md.contains("Right."));
8915 }
8916
8917 #[test]
8918 fn layout_column_localid_roundtrip() {
8919 let adf_json = r#"{
8920 "version": 1,
8921 "type": "doc",
8922 "content": [{
8923 "type": "layoutSection",
8924 "content": [
8925 {
8926 "type": "layoutColumn",
8927 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8928 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
8929 },
8930 {
8931 "type": "layoutColumn",
8932 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
8933 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
8934 }
8935 ]
8936 }]
8937 }"#;
8938 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8939 let md = adf_to_markdown(&doc).unwrap();
8940 assert!(
8941 md.contains("localId=aabb112233cc"),
8942 "first column localId should appear in markdown: {md}"
8943 );
8944 assert!(
8945 md.contains("localId=ddeeff445566"),
8946 "second column localId should appear in markdown: {md}"
8947 );
8948 let rt = markdown_to_adf(&md).unwrap();
8949 let cols = rt.content[0].content.as_ref().unwrap();
8950 assert_eq!(
8951 cols[0].attrs.as_ref().unwrap()["localId"],
8952 "aabb112233cc",
8953 "first column localId should round-trip"
8954 );
8955 assert_eq!(
8956 cols[1].attrs.as_ref().unwrap()["localId"],
8957 "ddeeff445566",
8958 "second column localId should round-trip"
8959 );
8960 }
8961
8962 #[test]
8963 fn layout_column_without_localid() {
8964 let md =
8965 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8966 let doc = markdown_to_adf(md).unwrap();
8967 let cols = doc.content[0].content.as_ref().unwrap();
8968 assert!(
8969 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
8970 "column without localId should not gain one"
8971 );
8972 let md2 = adf_to_markdown(&doc).unwrap();
8973 assert!(
8974 !md2.contains("localId"),
8975 "no localId should appear in output: {md2}"
8976 );
8977 }
8978
8979 #[test]
8980 fn layout_column_localid_stripped_when_option_set() {
8981 let adf_json = r#"{
8982 "version": 1,
8983 "type": "doc",
8984 "content": [{
8985 "type": "layoutSection",
8986 "content": [{
8987 "type": "layoutColumn",
8988 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8989 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
8990 }]
8991 }]
8992 }"#;
8993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8994 let opts = RenderOptions {
8995 strip_local_ids: true,
8996 ..Default::default()
8997 };
8998 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
8999 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9000 }
9001
9002 #[test]
9003 fn layout_column_localid_flush_previous() {
9004 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9006 let doc = markdown_to_adf(md).unwrap();
9007 let cols = doc.content[0].content.as_ref().unwrap();
9008 assert_eq!(
9009 cols[0].attrs.as_ref().unwrap()["localId"],
9010 "aabb112233cc",
9011 "flush-previous column should preserve localId"
9012 );
9013 assert_eq!(
9014 cols[1].attrs.as_ref().unwrap()["localId"],
9015 "ddeeff445566",
9016 "second column localId should be preserved"
9017 );
9018 }
9019
9020 #[test]
9021 fn layout_column_localid_flush_last() {
9022 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9024 let doc = markdown_to_adf(md).unwrap();
9025 let cols = doc.content[0].content.as_ref().unwrap();
9026 assert_eq!(
9027 cols[0].attrs.as_ref().unwrap()["localId"],
9028 "aabb112233cc",
9029 "flush-last column should preserve localId"
9030 );
9031 }
9032
9033 #[test]
9035 fn issue_555_layout_column_fractional_width_roundtrip() {
9036 let adf_json = r#"{
9037 "version": 1,
9038 "type": "doc",
9039 "content": [{
9040 "type": "layoutSection",
9041 "content": [
9042 {
9043 "type": "layoutColumn",
9044 "attrs": {"width": 66.66},
9045 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9046 },
9047 {
9048 "type": "layoutColumn",
9049 "attrs": {"width": 33.34},
9050 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9051 }
9052 ]
9053 }]
9054 }"#;
9055 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9056 let md = adf_to_markdown(&doc).unwrap();
9057 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9058 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9059 let rt = markdown_to_adf(&md).unwrap();
9060 let cols = rt.content[0].content.as_ref().unwrap();
9061 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9062 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9063 }
9064
9065 #[test]
9067 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9068 let adf_json = r#"{
9069 "version": 1,
9070 "type": "doc",
9071 "content": [{
9072 "type": "layoutSection",
9073 "content": [
9074 {
9075 "type": "layoutColumn",
9076 "attrs": {"width": 83.33},
9077 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9078 },
9079 {
9080 "type": "layoutColumn",
9081 "attrs": {"width": 16.67},
9082 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9083 }
9084 ]
9085 }]
9086 }"#;
9087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9088 let md = adf_to_markdown(&doc).unwrap();
9089 let rt = markdown_to_adf(&md).unwrap();
9090 let cols = rt.content[0].content.as_ref().unwrap();
9091 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9092 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9093 }
9094
9095 #[test]
9097 fn issue_555_layout_column_integer_width_preserved() {
9098 let adf_json = r#"{
9099 "version": 1,
9100 "type": "doc",
9101 "content": [{
9102 "type": "layoutSection",
9103 "content": [
9104 {
9105 "type": "layoutColumn",
9106 "attrs": {"width": 50},
9107 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9108 },
9109 {
9110 "type": "layoutColumn",
9111 "attrs": {"width": 50},
9112 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9113 }
9114 ]
9115 }]
9116 }"#;
9117 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9118 let md = adf_to_markdown(&doc).unwrap();
9119 assert!(
9120 md.contains("width=50") && !md.contains("width=50."),
9121 "integer width should render without decimal: {md}"
9122 );
9123 let rt = markdown_to_adf(&md).unwrap();
9124 let cols = rt.content[0].content.as_ref().unwrap();
9125 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9126 assert!(
9127 w0.is_i64() || w0.is_u64(),
9128 "width should remain a JSON integer, got: {w0}"
9129 );
9130 assert_eq!(w0.as_i64(), Some(50));
9131 }
9132
9133 #[test]
9135 fn issue_555_media_single_integer_width_preserved() {
9136 let adf_json = r#"{
9137 "version": 1,
9138 "type": "doc",
9139 "content": [{
9140 "type": "mediaSingle",
9141 "attrs": {"layout": "center", "width": 800},
9142 "content": [
9143 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9144 ]
9145 }]
9146 }"#;
9147 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9148 let md = adf_to_markdown(&doc).unwrap();
9149 assert!(
9150 md.contains("width=800") && !md.contains("width=800."),
9151 "integer width should render without decimal: {md}"
9152 );
9153 let rt = markdown_to_adf(&md).unwrap();
9154 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9155 let w = &ms_attrs["width"];
9156 assert!(
9157 w.is_i64() || w.is_u64(),
9158 "mediaSingle width should remain JSON integer, got: {w}"
9159 );
9160 assert_eq!(w.as_i64(), Some(800));
9161 }
9162
9163 #[test]
9167 fn issue_555_media_single_fractional_width_preserved() {
9168 let adf_json = r#"{
9169 "version": 1,
9170 "type": "doc",
9171 "content": [{
9172 "type": "mediaSingle",
9173 "attrs": {"layout": "center", "width": 66.5},
9174 "content": [
9175 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9176 ]
9177 }]
9178 }"#;
9179 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9180 let md = adf_to_markdown(&doc).unwrap();
9181 assert!(
9182 md.contains("width=66.5"),
9183 "fractional width must appear in JFM: {md}"
9184 );
9185 let rt = markdown_to_adf(&md).unwrap();
9186 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9187 assert_eq!(ms_attrs["width"], 66.5);
9188 }
9189
9190 #[test]
9192 fn issue_555_media_single_float_width_preserved() {
9193 let adf_json = r#"{
9194 "version": 1,
9195 "type": "doc",
9196 "content": [{
9197 "type": "mediaSingle",
9198 "attrs": {"layout": "center", "width": 800.0},
9199 "content": [
9200 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9201 ]
9202 }]
9203 }"#;
9204 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9205 let md = adf_to_markdown(&doc).unwrap();
9206 assert!(
9207 md.contains("width=800.0"),
9208 "float width should render with decimal: {md}"
9209 );
9210 let rt = markdown_to_adf(&md).unwrap();
9211 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9212 let w = &ms_attrs["width"];
9213 assert!(
9214 w.is_f64(),
9215 "mediaSingle float width should stay a JSON float, got: {w}"
9216 );
9217 assert_eq!(w.as_f64(), Some(800.0));
9218 }
9219
9220 #[test]
9222 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9223 let adf_json = r#"{
9224 "version": 1,
9225 "type": "doc",
9226 "content": [{
9227 "type": "mediaSingle",
9228 "attrs": {"layout": "wide", "width": 1420},
9229 "content": [
9230 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9231 ]
9232 }]
9233 }"#;
9234 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9235 let md = adf_to_markdown(&doc).unwrap();
9236 let rt = markdown_to_adf(&md).unwrap();
9237 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9238 assert_eq!(ms_attrs["layout"], "wide");
9239 let w = &ms_attrs["width"];
9240 assert!(
9241 w.is_i64() || w.is_u64(),
9242 "mediaSingle width should remain JSON integer, got: {w}"
9243 );
9244 assert_eq!(w.as_i64(), Some(1420));
9245 }
9246
9247 #[test]
9250 fn issue_555_file_media_single_integer_width_preserved() {
9251 let adf_json = r#"{
9252 "version": 1,
9253 "type": "doc",
9254 "content": [{
9255 "type": "mediaSingle",
9256 "attrs": {"layout": "wide", "width": 1420},
9257 "content": [
9258 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9259 ]
9260 }]
9261 }"#;
9262 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9263 let md = adf_to_markdown(&doc).unwrap();
9264 let rt = markdown_to_adf(&md).unwrap();
9265 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9266 let ms_w = &ms_attrs["width"];
9267 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9268 assert_eq!(ms_w.as_i64(), Some(1420));
9269 let media = &rt.content[0].content.as_ref().unwrap()[0];
9270 let media_attrs = media.attrs.as_ref().unwrap();
9271 let mw = &media_attrs["width"];
9272 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9273 assert_eq!(mw.as_i64(), Some(1200));
9274 let mh = &media_attrs["height"];
9275 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9276 assert_eq!(mh.as_i64(), Some(800));
9277 }
9278
9279 #[test]
9281 fn issue_555_fmt_numeric_attr_preserves_type() {
9282 assert_eq!(
9283 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9284 Some("50")
9285 );
9286 assert_eq!(
9287 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9288 Some("50.0")
9289 );
9290 assert_eq!(
9291 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9292 Some("66.66")
9293 );
9294 assert_eq!(
9295 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9296 Some("-5")
9297 );
9298 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9299 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9301 assert_eq!(
9302 fmt_numeric_attr(&big).as_deref(),
9303 Some("18446744073709551615")
9304 );
9305 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9307 }
9308
9309 #[test]
9311 fn issue_555_parse_numeric_attr_detects_type() {
9312 let v = parse_numeric_attr("800").unwrap();
9313 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9314 assert_eq!(v.as_i64(), Some(800));
9315
9316 let v = parse_numeric_attr("800.0").unwrap();
9317 assert!(v.is_f64(), "'800.0' should parse as float");
9318 assert_eq!(v.as_f64(), Some(800.0));
9319
9320 let v = parse_numeric_attr("66.66").unwrap();
9321 assert!(v.is_f64());
9322 assert_eq!(v.as_f64(), Some(66.66));
9323
9324 let v = parse_numeric_attr("1e2").unwrap();
9326 assert!(v.is_f64());
9327 assert_eq!(v.as_f64(), Some(100.0));
9328 let v = parse_numeric_attr("1E2").unwrap();
9329 assert!(v.is_f64());
9330 assert_eq!(v.as_f64(), Some(100.0));
9331
9332 let v = parse_numeric_attr("-42").unwrap();
9334 assert!(v.is_i64());
9335 assert_eq!(v.as_i64(), Some(-42));
9336
9337 assert!(parse_numeric_attr("not-a-number").is_none());
9338 assert!(parse_numeric_attr("").is_none());
9339 assert!(parse_numeric_attr("1.2.3").is_none());
9340 }
9341
9342 #[test]
9345 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9346 let adf_json = r#"{
9347 "version": 1,
9348 "type": "doc",
9349 "content": [{
9350 "type": "mediaSingle",
9351 "attrs": {"layout": "wide", "width": 83.33},
9352 "content": [
9353 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9354 ]
9355 }]
9356 }"#;
9357 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9358 let md = adf_to_markdown(&doc).unwrap();
9359 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9360 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9361 let rt = markdown_to_adf(&md).unwrap();
9362 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9363 assert_eq!(ms_attrs["layout"], "wide");
9364 assert_eq!(ms_attrs["width"], 83.33);
9365 }
9366
9367 #[test]
9371 fn issue_555_file_media_single_fractional_media_width_preserved() {
9372 let adf_json = r#"{
9373 "version": 1,
9374 "type": "doc",
9375 "content": [{
9376 "type": "mediaSingle",
9377 "attrs": {"layout": "wide", "width": 66.5},
9378 "content": [
9379 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9380 ]
9381 }]
9382 }"#;
9383 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9384 let md = adf_to_markdown(&doc).unwrap();
9385 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9386 let rt = markdown_to_adf(&md).unwrap();
9387 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9388 assert_eq!(ms_attrs["width"], 66.5);
9389 }
9390
9391 #[test]
9395 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9396 let adf_json = r#"{
9397 "version": 1,
9398 "type": "doc",
9399 "content": [{
9400 "type": "mediaSingle",
9401 "attrs": {"layout": "center"},
9402 "content": [
9403 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9404 ]
9405 }]
9406 }"#;
9407 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9408 let md = adf_to_markdown(&doc).unwrap();
9409 assert!(md.contains("width=1200.5"), "width in md: {md}");
9410 assert!(md.contains("height=800.25"), "height in md: {md}");
9411 let rt = markdown_to_adf(&md).unwrap();
9412 let media = &rt.content[0].content.as_ref().unwrap()[0];
9413 let attrs = media.attrs.as_ref().unwrap();
9414 assert_eq!(attrs["width"], 1200.5);
9415 assert_eq!(attrs["height"], 800.25);
9416 }
9417
9418 #[test]
9419 fn decisions_list() {
9420 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9421 let doc = markdown_to_adf(md).unwrap();
9422 assert_eq!(doc.content[0].node_type, "decisionList");
9423 let items = doc.content[0].content.as_ref().unwrap();
9424 assert_eq!(items.len(), 2);
9425 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9426 }
9427
9428 #[test]
9431 fn decision_item_content_is_inline_not_paragraph() {
9432 let md = ":::decisions\n- <> Use Rust\n:::";
9433 let doc = markdown_to_adf(md).unwrap();
9434 let items = doc.content[0].content.as_ref().unwrap();
9435 let first_child = &items[0].content.as_ref().unwrap()[0];
9436 assert_eq!(
9437 first_child.node_type, "text",
9438 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9439 );
9440 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9441 }
9442
9443 #[test]
9444 fn adf_decisions_to_markdown() {
9445 let doc = AdfDocument {
9446 version: 1,
9447 doc_type: "doc".to_string(),
9448 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9449 "DECIDED",
9450 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9451 )])],
9452 };
9453 let md = adf_to_markdown(&doc).unwrap();
9454 assert!(md.contains(":::decisions"));
9455 assert!(md.contains("- <> Use PostgreSQL"));
9456 }
9457
9458 #[test]
9459 fn bodied_extension_container() {
9460 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9461 let doc = markdown_to_adf(md).unwrap();
9462 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9463 assert_eq!(
9464 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9465 "com.forge"
9466 );
9467 }
9468
9469 #[test]
9470 fn adf_bodied_extension_to_markdown() {
9471 let doc = AdfDocument {
9472 version: 1,
9473 doc_type: "doc".to_string(),
9474 content: vec![AdfNode::bodied_extension(
9475 "com.forge",
9476 "my-macro",
9477 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9478 )],
9479 };
9480 let md = adf_to_markdown(&doc).unwrap();
9481 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9482 assert!(md.contains("Content."));
9483 }
9484
9485 #[test]
9488 fn leaf_block_card() {
9489 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9490 assert_eq!(doc.content[0].node_type, "blockCard");
9491 assert_eq!(
9492 doc.content[0].attrs.as_ref().unwrap()["url"],
9493 "https://example.com/browse/PROJ-123"
9494 );
9495 }
9496
9497 #[test]
9498 fn adf_block_card_to_markdown() {
9499 let doc = AdfDocument {
9500 version: 1,
9501 doc_type: "doc".to_string(),
9502 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9503 };
9504 let md = adf_to_markdown(&doc).unwrap();
9505 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9506 }
9507
9508 #[test]
9509 fn round_trip_block_card() {
9510 let md = "::card[https://example.com/browse/PROJ-123]\n";
9511 let doc = markdown_to_adf(md).unwrap();
9512 let result = adf_to_markdown(&doc).unwrap();
9513 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9514 }
9515
9516 #[test]
9517 fn leaf_embed_card() {
9518 let doc =
9519 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9520 assert_eq!(doc.content[0].node_type, "embedCard");
9521 let attrs = doc.content[0].attrs.as_ref().unwrap();
9522 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9523 assert_eq!(attrs["layout"], "wide");
9524 assert_eq!(attrs["width"], 80.0);
9525 }
9526
9527 #[test]
9528 fn leaf_embed_card_with_original_height() {
9529 let doc = markdown_to_adf(
9530 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9531 )
9532 .unwrap();
9533 assert_eq!(doc.content[0].node_type, "embedCard");
9534 let attrs = doc.content[0].attrs.as_ref().unwrap();
9535 assert_eq!(attrs["url"], "https://example.com");
9536 assert_eq!(attrs["layout"], "center");
9537 assert_eq!(attrs["originalHeight"], 732.0);
9538 assert_eq!(attrs["width"], 100.0);
9539 }
9540
9541 #[test]
9542 fn adf_embed_card_to_markdown() {
9543 let doc = AdfDocument {
9544 version: 1,
9545 doc_type: "doc".to_string(),
9546 content: vec![AdfNode::embed_card(
9547 "https://figma.com/file/abc",
9548 Some("wide"),
9549 None,
9550 Some(80.0),
9551 )],
9552 };
9553 let md = adf_to_markdown(&doc).unwrap();
9554 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9555 }
9556
9557 #[test]
9558 fn adf_embed_card_original_height_to_markdown() {
9559 let doc = AdfDocument {
9560 version: 1,
9561 doc_type: "doc".to_string(),
9562 content: vec![AdfNode::embed_card(
9563 "https://example.com",
9564 Some("center"),
9565 Some(732.0),
9566 Some(100.0),
9567 )],
9568 };
9569 let md = adf_to_markdown(&doc).unwrap();
9570 assert!(
9571 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9572 "expected originalHeight and width in md: {md}"
9573 );
9574 }
9575
9576 #[test]
9577 fn embed_card_roundtrip_with_all_attrs() {
9578 let adf_json = r#"{"version":1,"type":"doc","content":[{
9579 "type":"embedCard",
9580 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9581 }]}"#;
9582 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9583 let md = adf_to_markdown(&doc).unwrap();
9584 assert!(
9585 md.contains("originalHeight=732"),
9586 "originalHeight missing from md: {md}"
9587 );
9588 assert!(md.contains("width=100"), "width missing from md: {md}");
9589 let rt = markdown_to_adf(&md).unwrap();
9590 let attrs = rt.content[0].attrs.as_ref().unwrap();
9591 assert_eq!(attrs["originalHeight"], 732.0);
9592 assert_eq!(attrs["width"], 100.0);
9593 assert_eq!(attrs["layout"], "center");
9594 assert_eq!(attrs["url"], "https://example.com");
9595 }
9596
9597 #[test]
9598 fn embed_card_fractional_dimensions() {
9599 let doc = AdfDocument {
9600 version: 1,
9601 doc_type: "doc".to_string(),
9602 content: vec![AdfNode::embed_card(
9603 "https://example.com",
9604 Some("center"),
9605 Some(732.5),
9606 Some(99.9),
9607 )],
9608 };
9609 let md = adf_to_markdown(&doc).unwrap();
9610 assert!(
9611 md.contains("originalHeight=732.5"),
9612 "fractional originalHeight missing: {md}"
9613 );
9614 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9615 let rt = markdown_to_adf(&md).unwrap();
9616 let attrs = rt.content[0].attrs.as_ref().unwrap();
9617 assert_eq!(attrs["originalHeight"], 732.5);
9618 assert_eq!(attrs["width"], 99.9);
9619 }
9620
9621 #[test]
9622 fn embed_card_integer_width_in_json() {
9623 let adf_json = r#"{"version":1,"type":"doc","content":[{
9625 "type":"embedCard",
9626 "attrs":{"url":"https://example.com","width":100}
9627 }]}"#;
9628 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9629 let md = adf_to_markdown(&doc).unwrap();
9630 assert!(
9631 md.contains("width=100"),
9632 "integer width missing from md: {md}"
9633 );
9634 let rt = markdown_to_adf(&md).unwrap();
9635 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9636 }
9637
9638 #[test]
9639 fn embed_card_only_original_height() {
9640 let adf_json = r#"{"version":1,"type":"doc","content":[{
9642 "type":"embedCard",
9643 "attrs":{"url":"https://example.com","originalHeight":500.0}
9644 }]}"#;
9645 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9646 let md = adf_to_markdown(&doc).unwrap();
9647 assert!(
9648 md.contains("originalHeight=500"),
9649 "originalHeight missing: {md}"
9650 );
9651 assert!(!md.contains("width="), "width should not appear: {md}");
9652 let rt = markdown_to_adf(&md).unwrap();
9653 let attrs = rt.content[0].attrs.as_ref().unwrap();
9654 assert_eq!(attrs["originalHeight"], 500.0);
9655 assert!(attrs.get("width").is_none());
9656 }
9657
9658 #[test]
9659 fn leaf_void_extension() {
9660 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9661 assert_eq!(doc.content[0].node_type, "extension");
9662 assert_eq!(
9663 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9664 "com.atlassian.macro"
9665 );
9666 assert_eq!(
9667 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9668 "jira-chart"
9669 );
9670 }
9671
9672 #[test]
9673 fn adf_void_extension_to_markdown() {
9674 let doc = AdfDocument {
9675 version: 1,
9676 doc_type: "doc".to_string(),
9677 content: vec![AdfNode::extension(
9678 "com.atlassian.macro",
9679 "jira-chart",
9680 None,
9681 )],
9682 };
9683 let md = adf_to_markdown(&doc).unwrap();
9684 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9685 }
9686
9687 #[test]
9690 fn bare_url_autolink() {
9691 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9692 let content = doc.content[0].content.as_ref().unwrap();
9693 assert_eq!(content[0].text.as_deref(), Some("Visit "));
9694 assert_eq!(content[1].node_type, "inlineCard");
9695 assert_eq!(
9696 content[1].attrs.as_ref().unwrap()["url"],
9697 "https://example.com"
9698 );
9699 assert_eq!(content[2].text.as_deref(), Some(" today"));
9700 }
9701
9702 #[test]
9703 fn bare_url_strips_trailing_punctuation() {
9704 let doc = markdown_to_adf("See https://example.com.").unwrap();
9705 let content = doc.content[0].content.as_ref().unwrap();
9706 assert_eq!(
9707 content[1].attrs.as_ref().unwrap()["url"],
9708 "https://example.com"
9709 );
9710 }
9711
9712 #[test]
9713 fn bare_url_round_trip() {
9714 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9715 let md = adf_to_markdown(&doc).unwrap();
9716 assert!(md.contains(":card[https://example.com/path]"));
9717 }
9718
9719 #[test]
9722 fn plain_text_url_round_trips_as_text() {
9723 let adf_json = r#"{
9726 "version": 1,
9727 "type": "doc",
9728 "content": [{
9729 "type": "paragraph",
9730 "content": [
9731 {"type": "text", "text": "https://example.com/some/path/to/resource"}
9732 ]
9733 }]
9734 }"#;
9735 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9736 let jfm = adf_to_markdown(&adf).unwrap();
9737 let roundtripped = markdown_to_adf(&jfm).unwrap();
9738 let content = roundtripped.content[0].content.as_ref().unwrap();
9739 assert_eq!(content.len(), 1, "should be a single node");
9740 assert_eq!(content[0].node_type, "text");
9741 assert_eq!(
9742 content[0].text.as_deref(),
9743 Some("https://example.com/some/path/to/resource")
9744 );
9745 }
9746
9747 #[test]
9748 fn url_text_with_link_mark_round_trips_as_text_node() {
9749 let adf_json = r#"{
9753 "version": 1,
9754 "type": "doc",
9755 "content": [{
9756 "type": "paragraph",
9757 "content": [{
9758 "type": "text",
9759 "text": "https://octopz.example.com",
9760 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9761 }]
9762 }]
9763 }"#;
9764 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9765 let jfm = adf_to_markdown(&adf).unwrap();
9766 let roundtripped = markdown_to_adf(&jfm).unwrap();
9767 let content = roundtripped.content[0].content.as_ref().unwrap();
9768 assert_eq!(content.len(), 1, "should be a single node");
9769 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9770 assert_eq!(
9771 content[0].text.as_deref(),
9772 Some("https://octopz.example.com")
9773 );
9774 let mark = &content[0].marks.as_ref().unwrap()[0];
9775 assert_eq!(mark.mark_type, "link");
9776 assert_eq!(
9777 mark.attrs.as_ref().unwrap()["href"],
9778 "https://octopz.example.com/"
9779 );
9780 }
9781
9782 #[test]
9783 fn url_text_with_exact_link_mark_round_trips() {
9784 let adf_json = r#"{
9786 "version": 1,
9787 "type": "doc",
9788 "content": [{
9789 "type": "paragraph",
9790 "content": [{
9791 "type": "text",
9792 "text": "https://example.com/path",
9793 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9794 }]
9795 }]
9796 }"#;
9797 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9798 let jfm = adf_to_markdown(&adf).unwrap();
9799 let roundtripped = markdown_to_adf(&jfm).unwrap();
9800 let content = roundtripped.content[0].content.as_ref().unwrap();
9801 assert_eq!(content.len(), 1, "should be a single node");
9802 assert_eq!(content[0].node_type, "text");
9803 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9804 let mark = &content[0].marks.as_ref().unwrap()[0];
9805 assert_eq!(mark.mark_type, "link");
9806 }
9807
9808 #[test]
9809 fn plain_text_url_amid_text_round_trips() {
9810 let adf_json = r#"{
9812 "version": 1,
9813 "type": "doc",
9814 "content": [{
9815 "type": "paragraph",
9816 "content": [
9817 {"type": "text", "text": "see https://example.com for info"}
9818 ]
9819 }]
9820 }"#;
9821 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9822 let jfm = adf_to_markdown(&adf).unwrap();
9823 let roundtripped = markdown_to_adf(&jfm).unwrap();
9824 let content = roundtripped.content[0].content.as_ref().unwrap();
9825 assert_eq!(content.len(), 1);
9826 assert_eq!(content[0].node_type, "text");
9827 assert_eq!(
9828 content[0].text.as_deref(),
9829 Some("see https://example.com for info")
9830 );
9831 }
9832
9833 #[test]
9834 fn plain_text_multiple_urls_round_trips() {
9835 let adf_json = r#"{
9836 "version": 1,
9837 "type": "doc",
9838 "content": [{
9839 "type": "paragraph",
9840 "content": [
9841 {"type": "text", "text": "http://a.com and https://b.com"}
9842 ]
9843 }]
9844 }"#;
9845 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9846 let jfm = adf_to_markdown(&adf).unwrap();
9847 let roundtripped = markdown_to_adf(&jfm).unwrap();
9848 let content = roundtripped.content[0].content.as_ref().unwrap();
9849 assert_eq!(content.len(), 1);
9850 assert_eq!(content[0].node_type, "text");
9851 assert_eq!(
9852 content[0].text.as_deref(),
9853 Some("http://a.com and https://b.com")
9854 );
9855 }
9856
9857 #[test]
9858 fn plain_text_http_prefix_no_url_unchanged() {
9859 let adf_json = r#"{
9861 "version": 1,
9862 "type": "doc",
9863 "content": [{
9864 "type": "paragraph",
9865 "content": [
9866 {"type": "text", "text": "the http header is important"}
9867 ]
9868 }]
9869 }"#;
9870 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9871 let jfm = adf_to_markdown(&adf).unwrap();
9872 let roundtripped = markdown_to_adf(&jfm).unwrap();
9873 let content = roundtripped.content[0].content.as_ref().unwrap();
9874 assert_eq!(
9875 content[0].text.as_deref(),
9876 Some("the http header is important")
9877 );
9878 }
9879
9880 #[test]
9881 fn linked_url_text_round_trips() {
9882 let adf_json = r#"{
9886 "version": 1,
9887 "type": "doc",
9888 "content": [{
9889 "type": "paragraph",
9890 "content": [{
9891 "type": "text",
9892 "text": "https://example.com",
9893 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9894 }]
9895 }]
9896 }"#;
9897 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9898 let jfm = adf_to_markdown(&adf).unwrap();
9899 let roundtripped = markdown_to_adf(&jfm).unwrap();
9900 let content = roundtripped.content[0].content.as_ref().unwrap();
9901 assert_eq!(content.len(), 1);
9902 assert_eq!(content[0].node_type, "text");
9903 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9904 let mark = &content[0].marks.as_ref().unwrap()[0];
9905 assert_eq!(mark.mark_type, "link");
9906 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9907 }
9908
9909 #[test]
9912 fn escape_link_brackets_unit() {
9913 assert_eq!(escape_link_brackets("hello"), "hello");
9914 assert_eq!(escape_link_brackets("["), "\\[");
9915 assert_eq!(escape_link_brackets("]"), "\\]");
9916 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
9917 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
9918 }
9919
9920 #[test]
9921 fn bracket_text_with_link_mark_escapes_brackets() {
9922 let doc = AdfDocument {
9925 version: 1,
9926 doc_type: "doc".to_string(),
9927 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9928 "[",
9929 vec![AdfMark::link("https://example.com")],
9930 )])],
9931 };
9932 let md = adf_to_markdown(&doc).unwrap();
9933 assert_eq!(md.trim(), "[\\[](https://example.com)");
9934 }
9935
9936 #[test]
9937 fn bracket_text_with_link_mark_round_trips() {
9938 let adf_json = r#"{
9941 "type": "doc",
9942 "version": 1,
9943 "content": [{
9944 "type": "paragraph",
9945 "content": [
9946 {
9947 "type": "text",
9948 "text": "[",
9949 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
9950 },
9951 {
9952 "type": "text",
9953 "text": "PROJ-456] Fix the auth bug",
9954 "marks": [
9955 {"type": "underline"},
9956 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
9957 ]
9958 }
9959 ]
9960 }]
9961 }"#;
9962 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9963 let jfm = adf_to_markdown(&adf).unwrap();
9964
9965 assert!(jfm.contains("\\["), "opening bracket should be escaped");
9967
9968 let rt = markdown_to_adf(&jfm).unwrap();
9970 let content = rt.content[0].content.as_ref().unwrap();
9971
9972 let link_nodes: Vec<_> = content
9974 .iter()
9975 .filter(|n| {
9976 n.marks
9977 .as_ref()
9978 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
9979 })
9980 .collect();
9981 assert!(
9982 !link_nodes.is_empty(),
9983 "link mark must be preserved on round-trip"
9984 );
9985
9986 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
9988 assert!(
9989 all_text.contains('['),
9990 "literal '[' must survive round-trip"
9991 );
9992 assert!(
9993 all_text.contains("PROJ-456]"),
9994 "continuation text must survive round-trip"
9995 );
9996 }
9997
9998 #[test]
9999 fn closing_bracket_in_link_text_round_trips() {
10000 let doc = AdfDocument {
10003 version: 1,
10004 doc_type: "doc".to_string(),
10005 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10006 "item]",
10007 vec![AdfMark::link("https://example.com")],
10008 )])],
10009 };
10010 let md = adf_to_markdown(&doc).unwrap();
10011 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10012
10013 let rt = markdown_to_adf(&md).unwrap();
10014 let content = rt.content[0].content.as_ref().unwrap();
10015 assert_eq!(content[0].text.as_deref(), Some("item]"));
10016 assert!(content[0]
10017 .marks
10018 .as_ref()
10019 .unwrap()
10020 .iter()
10021 .any(|m| m.mark_type == "link"));
10022 }
10023
10024 #[test]
10025 fn brackets_in_link_text_round_trip() {
10026 let doc = AdfDocument {
10028 version: 1,
10029 doc_type: "doc".to_string(),
10030 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10031 "[PROJ-123]",
10032 vec![AdfMark::link("https://example.com")],
10033 )])],
10034 };
10035 let md = adf_to_markdown(&doc).unwrap();
10036 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10037
10038 let rt = markdown_to_adf(&md).unwrap();
10039 let content = rt.content[0].content.as_ref().unwrap();
10040 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10041 assert!(content[0]
10042 .marks
10043 .as_ref()
10044 .unwrap()
10045 .iter()
10046 .any(|m| m.mark_type == "link"));
10047 }
10048
10049 #[test]
10050 fn plain_text_brackets_not_escaped() {
10051 let doc = AdfDocument {
10053 version: 1,
10054 doc_type: "doc".to_string(),
10055 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10056 "see [PROJ-123] for details",
10057 )])],
10058 };
10059 let md = adf_to_markdown(&doc).unwrap();
10060 assert_eq!(md.trim(), "see [PROJ-123] for details");
10061 }
10062
10063 #[test]
10064 fn link_with_no_brackets_unchanged() {
10065 let doc = AdfDocument {
10067 version: 1,
10068 doc_type: "doc".to_string(),
10069 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10070 "click here",
10071 vec![AdfMark::link("https://example.com")],
10072 )])],
10073 };
10074 let md = adf_to_markdown(&doc).unwrap();
10075 assert_eq!(md.trim(), "[click here](https://example.com)");
10076 }
10077
10078 #[test]
10081 fn url_with_brackets_as_link_text_round_trips() {
10082 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10087 let doc = AdfDocument {
10088 version: 1,
10089 doc_type: "doc".to_string(),
10090 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10091 href,
10092 vec![AdfMark::link(href)],
10093 )])],
10094 };
10095 let md = adf_to_markdown(&doc).unwrap();
10096 let rt = markdown_to_adf(&md).unwrap();
10097 let content = rt.content[0].content.as_ref().unwrap();
10098 assert_eq!(content.len(), 1);
10099 assert_eq!(content[0].node_type, "text");
10100 assert_eq!(content[0].text.as_deref(), Some(href));
10101 let mark = &content[0].marks.as_ref().unwrap()[0];
10102 assert_eq!(mark.mark_type, "link");
10103 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10104 }
10105
10106 #[test]
10107 fn url_with_brackets_embedded_in_link_text_round_trips() {
10108 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10115 let text =
10116 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10117 let doc = AdfDocument {
10118 version: 1,
10119 doc_type: "doc".to_string(),
10120 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10121 text,
10122 vec![AdfMark::link(href)],
10123 )])],
10124 };
10125 let md = adf_to_markdown(&doc).unwrap();
10126 let rt = markdown_to_adf(&md).unwrap();
10127 let content = rt.content[0].content.as_ref().unwrap();
10128 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10129 assert_eq!(content[0].node_type, "text");
10130 assert_eq!(content[0].text.as_deref(), Some(text));
10131 let mark = &content[0].marks.as_ref().unwrap()[0];
10132 assert_eq!(mark.mark_type, "link");
10133 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10134 }
10135
10136 #[test]
10137 fn url_with_brackets_plain_text_round_trips() {
10138 let text =
10141 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10142 let doc = AdfDocument {
10143 version: 1,
10144 doc_type: "doc".to_string(),
10145 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10146 };
10147 let md = adf_to_markdown(&doc).unwrap();
10148 let rt = markdown_to_adf(&md).unwrap();
10149 let content = rt.content[0].content.as_ref().unwrap();
10150 assert_eq!(content.len(), 1);
10151 assert_eq!(content[0].node_type, "text");
10152 assert_eq!(content[0].text.as_deref(), Some(text));
10153 assert!(content[0].marks.is_none());
10154 }
10155
10156 #[test]
10157 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10158 let href = "https://example.com/";
10161 let text = "See https://example.com/ for more";
10162 let doc = AdfDocument {
10163 version: 1,
10164 doc_type: "doc".to_string(),
10165 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10166 text,
10167 vec![AdfMark::link(href)],
10168 )])],
10169 };
10170 let md = adf_to_markdown(&doc).unwrap();
10171 let rt = markdown_to_adf(&md).unwrap();
10172 let content = rt.content[0].content.as_ref().unwrap();
10173 assert_eq!(content.len(), 1);
10174 assert_eq!(content[0].node_type, "text");
10175 assert_eq!(content[0].text.as_deref(), Some(text));
10176 let mark = &content[0].marks.as_ref().unwrap()[0];
10177 assert_eq!(mark.mark_type, "link");
10178 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10179 }
10180
10181 #[test]
10182 fn nested_brackets_in_link_text_round_trip() {
10183 let href = "https://x.com";
10186 let text = "foo [a[b]c] bar";
10187 let doc = AdfDocument {
10188 version: 1,
10189 doc_type: "doc".to_string(),
10190 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10191 text,
10192 vec![AdfMark::link(href)],
10193 )])],
10194 };
10195 let md = adf_to_markdown(&doc).unwrap();
10196 let rt = markdown_to_adf(&md).unwrap();
10197 let content = rt.content[0].content.as_ref().unwrap();
10198 assert_eq!(content.len(), 1);
10199 assert_eq!(content[0].node_type, "text");
10200 assert_eq!(content[0].text.as_deref(), Some(text));
10201 }
10202
10203 #[test]
10204 fn bracket_url_bracket_in_link_text_round_trips() {
10205 let href = "https://y.com";
10211 let text = "[see https://x.com/a[0]=1 here]";
10212 let doc = AdfDocument {
10213 version: 1,
10214 doc_type: "doc".to_string(),
10215 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10216 text,
10217 vec![AdfMark::link(href)],
10218 )])],
10219 };
10220 let md = adf_to_markdown(&doc).unwrap();
10221 let rt = markdown_to_adf(&md).unwrap();
10222 let content = rt.content[0].content.as_ref().unwrap();
10223 assert_eq!(content.len(), 1);
10224 assert_eq!(content[0].node_type, "text");
10225 assert_eq!(content[0].text.as_deref(), Some(text));
10226 let mark = &content[0].marks.as_ref().unwrap()[0];
10227 assert_eq!(mark.mark_type, "link");
10228 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10229 }
10230
10231 #[test]
10232 fn escape_bare_urls_applied_inside_link_text() {
10233 let doc = AdfDocument {
10239 version: 1,
10240 doc_type: "doc".to_string(),
10241 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10242 "See https://example.com/",
10243 vec![AdfMark::link("https://target.example.com/")],
10244 )])],
10245 };
10246 let md = adf_to_markdown(&doc).unwrap();
10247 assert!(
10248 md.contains(r"\https://example.com/"),
10249 "bare URL inside link text must be escaped, got: {md}"
10250 );
10251 }
10252
10253 #[test]
10254 fn inline_card_still_round_trips() {
10255 let adf_json = r#"{
10258 "version": 1,
10259 "type": "doc",
10260 "content": [{
10261 "type": "paragraph",
10262 "content": [
10263 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10264 ]
10265 }]
10266 }"#;
10267 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10268 let jfm = adf_to_markdown(&adf).unwrap();
10269 assert!(jfm.contains(":card[https://example.com/page]"));
10270 let roundtripped = markdown_to_adf(&jfm).unwrap();
10271 let content = roundtripped.content[0].content.as_ref().unwrap();
10272 assert_eq!(content[0].node_type, "inlineCard");
10273 assert_eq!(
10274 content[0].attrs.as_ref().unwrap()["url"],
10275 "https://example.com/page"
10276 );
10277 }
10278
10279 #[test]
10282 fn url_safe_in_bracket_content_balanced() {
10283 assert!(url_safe_in_bracket_content("https://example.com"));
10285 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10286 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10287 assert!(url_safe_in_bracket_content(""));
10288 }
10289
10290 #[test]
10291 fn url_safe_in_bracket_content_unbalanced() {
10292 assert!(!url_safe_in_bracket_content("a]b"));
10294 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10295 assert!(!url_safe_in_bracket_content("a\nb"));
10297 }
10298
10299 #[test]
10300 fn inline_card_url_with_closing_bracket_round_trip() {
10301 let adf_json = r#"{
10306 "version": 1,
10307 "type": "doc",
10308 "content": [{
10309 "type": "paragraph",
10310 "content": [
10311 {"type": "text", "text": "See: "},
10312 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10313 ]
10314 }]
10315 }"#;
10316 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10317 let jfm = adf_to_markdown(&adf).unwrap();
10318 assert!(
10319 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10320 "expected attr-form for URL with `]`, got: {jfm}"
10321 );
10322 let rt = markdown_to_adf(&jfm).unwrap();
10323 let content = rt.content[0].content.as_ref().unwrap();
10324 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10325 assert_eq!(content[0].node_type, "text");
10326 assert_eq!(content[0].text.as_deref(), Some("See: "));
10327 assert_eq!(content[1].node_type, "inlineCard");
10328 assert_eq!(
10329 content[1].attrs.as_ref().unwrap()["url"],
10330 "https://example.com/path]end/?q=1"
10331 );
10332 }
10333
10334 #[test]
10335 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10336 let adf_json = r#"{
10338 "version": 1,
10339 "type": "doc",
10340 "content": [{
10341 "type": "paragraph",
10342 "content": [
10343 {"type": "inlineCard", "attrs": {
10344 "url": "https://example.com/a]b",
10345 "localId": "c-77"
10346 }}
10347 ]
10348 }]
10349 }"#;
10350 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10351 let jfm = adf_to_markdown(&adf).unwrap();
10352 assert!(
10353 jfm.contains(r#"url="https://example.com/a]b""#),
10354 "jfm: {jfm}"
10355 );
10356 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10357 let rt = markdown_to_adf(&jfm).unwrap();
10358 let card = &rt.content[0].content.as_ref().unwrap()[0];
10359 assert_eq!(card.node_type, "inlineCard");
10360 assert_eq!(
10361 card.attrs.as_ref().unwrap()["url"],
10362 "https://example.com/a]b"
10363 );
10364 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10365 }
10366
10367 #[test]
10368 fn block_card_url_with_closing_bracket_round_trip() {
10369 let adf_json = r#"{
10371 "version": 1,
10372 "type": "doc",
10373 "content": [
10374 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10375 ]
10376 }"#;
10377 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10378 let jfm = adf_to_markdown(&adf).unwrap();
10379 assert!(
10380 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10381 "expected attr-form for blockCard with `]`, got: {jfm}"
10382 );
10383 let rt = markdown_to_adf(&jfm).unwrap();
10384 assert_eq!(rt.content[0].node_type, "blockCard");
10385 assert_eq!(
10386 rt.content[0].attrs.as_ref().unwrap()["url"],
10387 "https://example.com/path]end"
10388 );
10389 }
10390
10391 #[test]
10392 fn block_card_attr_form_parses_without_renderer() {
10393 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10397 assert_eq!(doc.content[0].node_type, "blockCard");
10398 assert_eq!(
10399 doc.content[0].attrs.as_ref().unwrap()["url"],
10400 "https://example.com/a"
10401 );
10402 }
10403
10404 #[test]
10405 fn block_card_attr_form_url_overrides_content() {
10406 let doc =
10410 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10411 .unwrap();
10412 assert_eq!(doc.content[0].node_type, "blockCard");
10413 assert_eq!(
10414 doc.content[0].attrs.as_ref().unwrap()["url"],
10415 "https://new.example.com"
10416 );
10417 }
10418
10419 #[test]
10420 fn block_card_attr_form_with_layout_and_width() {
10421 let doc =
10424 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10425 .unwrap();
10426 let attrs = doc.content[0].attrs.as_ref().unwrap();
10427 assert_eq!(attrs["url"], "https://example.com/a]b");
10428 assert_eq!(attrs["layout"], "wide");
10429 assert_eq!(attrs["width"], 80);
10430 }
10431
10432 #[test]
10433 fn inline_card_issue_553_reproducer() {
10434 let adf_json = r#"{
10438 "version": 1,
10439 "type": "doc",
10440 "content": [{
10441 "type": "paragraph",
10442 "content": [
10443 {"type": "text", "text": "See the related page: "},
10444 {"type": "inlineCard", "attrs": {
10445 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10446 }}
10447 ]
10448 }]
10449 }"#;
10450 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10451 let jfm = adf_to_markdown(&adf).unwrap();
10452 let rt = markdown_to_adf(&jfm).unwrap();
10453 let content = rt.content[0].content.as_ref().unwrap();
10454 assert_eq!(content.len(), 2);
10455 assert_eq!(content[0].node_type, "text");
10456 assert_eq!(content[1].node_type, "inlineCard");
10457 assert_eq!(
10458 content[1].attrs.as_ref().unwrap()["url"],
10459 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10460 );
10461 }
10462
10463 #[test]
10464 fn inline_card_attr_form_parses_even_without_renderer() {
10465 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10467 let node = &doc.content[0].content.as_ref().unwrap()[0];
10468 assert_eq!(node.node_type, "inlineCard");
10469 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10470 }
10471
10472 #[test]
10473 fn inline_card_attr_form_url_overrides_content() {
10474 let doc =
10478 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10479 .unwrap();
10480 let node = &doc.content[0].content.as_ref().unwrap()[0];
10481 assert_eq!(node.node_type, "inlineCard");
10482 assert_eq!(
10483 node.attrs.as_ref().unwrap()["url"],
10484 "https://new.example.com"
10485 );
10486 }
10487
10488 #[test]
10491 fn url_with_link_and_underline_marks_round_trip() {
10492 let adf_json = r#"{
10496 "version": 1,
10497 "type": "doc",
10498 "content": [{
10499 "type": "paragraph",
10500 "content": [
10501 {"type": "text", "text": "See results at: "},
10502 {"type": "text",
10503 "text": "https://example.com/projects/abc123/analytics",
10504 "marks": [
10505 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10506 {"type": "underline"}
10507 ]},
10508 {"type": "text", "text": " for details."}
10509 ]
10510 }]
10511 }"#;
10512 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10513 let jfm = adf_to_markdown(&adf).unwrap();
10514 let rt = markdown_to_adf(&jfm).unwrap();
10515 let content = rt.content[0].content.as_ref().unwrap();
10516 assert_eq!(
10517 content.len(),
10518 3,
10519 "expected 3 inline nodes, got: {content:?}"
10520 );
10521 assert_eq!(content[0].node_type, "text");
10522 assert_eq!(
10523 content[1].node_type, "text",
10524 "must stay text, not inlineCard"
10525 );
10526 assert_eq!(
10527 content[1].text.as_deref(),
10528 Some("https://example.com/projects/abc123/analytics")
10529 );
10530 let mark_types: Vec<&str> = content[1]
10531 .marks
10532 .as_deref()
10533 .unwrap_or(&[])
10534 .iter()
10535 .map(|m| m.mark_type.as_str())
10536 .collect();
10537 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10538 assert_eq!(content[2].node_type, "text");
10539 }
10540
10541 #[test]
10542 fn url_inside_bracketed_span_stays_text() {
10543 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10547 let node = &doc.content[0].content.as_ref().unwrap()[0];
10548 assert_eq!(node.node_type, "text");
10549 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10550 let mark_types: Vec<&str> = node
10551 .marks
10552 .as_deref()
10553 .unwrap_or(&[])
10554 .iter()
10555 .map(|m| m.mark_type.as_str())
10556 .collect();
10557 assert_eq!(mark_types, vec!["underline"]);
10558 }
10559
10560 #[test]
10561 fn url_inside_emphasis_stays_text() {
10562 for (md, mark) in [
10565 ("**https://example.com**", "strong"),
10566 ("*https://example.com*", "em"),
10567 ("~~https://example.com~~", "strike"),
10568 ] {
10569 let doc = markdown_to_adf(md).unwrap();
10570 let node = &doc.content[0].content.as_ref().unwrap()[0];
10571 assert_eq!(node.node_type, "text", "md={md}: must be text");
10572 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10573 let mark_types: Vec<&str> = node
10574 .marks
10575 .as_deref()
10576 .unwrap_or(&[])
10577 .iter()
10578 .map(|m| m.mark_type.as_str())
10579 .collect();
10580 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10581 }
10582 }
10583
10584 #[test]
10585 fn url_inside_span_directive_stays_text() {
10586 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10588 let node = &doc.content[0].content.as_ref().unwrap()[0];
10589 assert_eq!(node.node_type, "text");
10590 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10591 let mark = &node.marks.as_ref().unwrap()[0];
10592 assert_eq!(mark.mark_type, "textColor");
10593 }
10594
10595 #[test]
10596 fn url_as_link_text_with_underline_after_link_mark_order() {
10597 let adf_json = r#"{
10601 "version": 1,
10602 "type": "doc",
10603 "content": [{
10604 "type": "paragraph",
10605 "content": [
10606 {"type": "text",
10607 "text": "https://example.com",
10608 "marks": [
10609 {"type": "underline"},
10610 {"type": "link", "attrs": {"href": "https://example.com"}}
10611 ]}
10612 ]
10613 }]
10614 }"#;
10615 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10616 let jfm = adf_to_markdown(&adf).unwrap();
10617 let rt = markdown_to_adf(&jfm).unwrap();
10618 let node = &rt.content[0].content.as_ref().unwrap()[0];
10619 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10620 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10621 let mark_types: Vec<&str> = node
10622 .marks
10623 .as_deref()
10624 .unwrap_or(&[])
10625 .iter()
10626 .map(|m| m.mark_type.as_str())
10627 .collect();
10628 assert_eq!(mark_types, vec!["underline", "link"]);
10629 }
10630
10631 #[test]
10632 fn bare_url_at_top_level_still_becomes_inline_card() {
10633 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10637 let content = doc.content[0].content.as_ref().unwrap();
10638 assert_eq!(content.len(), 3);
10639 assert_eq!(content[0].node_type, "text");
10640 assert_eq!(content[1].node_type, "inlineCard");
10641 assert_eq!(
10642 content[1].attrs.as_ref().unwrap()["url"],
10643 "https://example.com"
10644 );
10645 assert_eq!(content[2].node_type, "text");
10646 }
10647
10648 #[test]
10651 fn paragraph_align_center() {
10652 let md = "Centered text.\n{align=center}";
10653 let doc = markdown_to_adf(md).unwrap();
10654 let marks = doc.content[0].marks.as_ref().unwrap();
10655 assert_eq!(marks[0].mark_type, "alignment");
10656 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10657 }
10658
10659 #[test]
10660 fn adf_alignment_to_markdown() {
10661 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10662 node.marks = Some(vec![AdfMark::alignment("center")]);
10663 let doc = AdfDocument {
10664 version: 1,
10665 doc_type: "doc".to_string(),
10666 content: vec![node],
10667 };
10668 let md = adf_to_markdown(&doc).unwrap();
10669 assert!(md.contains("Centered."));
10670 assert!(md.contains("{align=center}"));
10671 }
10672
10673 #[test]
10674 fn round_trip_alignment() {
10675 let md = "Centered.\n{align=center}\n";
10676 let doc = markdown_to_adf(md).unwrap();
10677 let result = adf_to_markdown(&doc).unwrap();
10678 assert!(result.contains("{align=center}"));
10679 }
10680
10681 #[test]
10682 fn paragraph_indent() {
10683 let md = "Indented.\n{indent=2}";
10684 let doc = markdown_to_adf(md).unwrap();
10685 let marks = doc.content[0].marks.as_ref().unwrap();
10686 assert_eq!(marks[0].mark_type, "indentation");
10687 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10688 }
10689
10690 #[test]
10691 fn code_block_breakout() {
10692 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10693 let doc = markdown_to_adf(md).unwrap();
10694 let marks = doc.content[0].marks.as_ref().unwrap();
10695 assert_eq!(marks[0].mark_type, "breakout");
10696 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10697 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10698 }
10699
10700 #[test]
10701 fn code_block_breakout_with_width() {
10702 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10703 let doc = markdown_to_adf(md).unwrap();
10704 let marks = doc.content[0].marks.as_ref().unwrap();
10705 assert_eq!(marks[0].mark_type, "breakout");
10706 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10707 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10708 }
10709
10710 #[test]
10711 fn adf_breakout_to_markdown() {
10712 let mut node = AdfNode::code_block(Some("python"), "pass");
10713 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10714 let doc = AdfDocument {
10715 version: 1,
10716 doc_type: "doc".to_string(),
10717 content: vec![node],
10718 };
10719 let md = adf_to_markdown(&doc).unwrap();
10720 assert!(md.contains("{breakout=wide}"));
10721 assert!(!md.contains("breakoutWidth"));
10722 }
10723
10724 #[test]
10725 fn adf_breakout_with_width_to_markdown() {
10726 let mut node = AdfNode::code_block(Some("python"), "pass");
10727 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10728 let doc = AdfDocument {
10729 version: 1,
10730 doc_type: "doc".to_string(),
10731 content: vec![node],
10732 };
10733 let md = adf_to_markdown(&doc).unwrap();
10734 assert!(md.contains("breakout=wide"));
10735 assert!(md.contains("breakoutWidth=1200"));
10736 }
10737
10738 #[test]
10739 fn breakout_width_round_trip() {
10740 let adf_json = r#"{"version":1,"type":"doc","content":[{
10741 "type":"codeBlock",
10742 "attrs":{"language":"text"},
10743 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10744 "content":[{"type":"text","text":"some code"}]
10745 }]}"#;
10746 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10747 let md = adf_to_markdown(&doc).unwrap();
10748 assert!(md.contains("breakout=wide"));
10749 assert!(md.contains("breakoutWidth=1200"));
10750 let round_tripped = markdown_to_adf(&md).unwrap();
10751 let marks = round_tripped.content[0].marks.as_ref().unwrap();
10752 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10753 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10754 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10755 }
10756
10757 #[test]
10760 fn image_with_layout_attrs() {
10761 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
10762 let node = &doc.content[0];
10763 assert_eq!(node.node_type, "mediaSingle");
10764 let attrs = node.attrs.as_ref().unwrap();
10765 assert_eq!(attrs["layout"], "wide");
10766 assert_eq!(attrs["width"], 80);
10767 }
10768
10769 #[test]
10770 fn adf_image_with_layout_to_markdown() {
10771 let mut node = AdfNode::media_single("url", Some("alt"));
10772 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10773 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10774 let doc = AdfDocument {
10775 version: 1,
10776 doc_type: "doc".to_string(),
10777 content: vec![node],
10778 };
10779 let md = adf_to_markdown(&doc).unwrap();
10780 assert!(md.contains("{layout=wide width=80}"));
10781 }
10782
10783 #[test]
10784 fn table_with_layout_attrs() {
10785 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10786 let doc = markdown_to_adf(md).unwrap();
10787 let table = &doc.content[0];
10788 assert_eq!(table.node_type, "table");
10789 let attrs = table.attrs.as_ref().unwrap();
10790 assert_eq!(attrs["layout"], "wide");
10791 assert_eq!(attrs["isNumberColumnEnabled"], true);
10792 }
10793
10794 #[test]
10795 fn adf_table_with_attrs_to_markdown() {
10796 let mut table = AdfNode::table(vec![
10797 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10798 AdfNode::text("H"),
10799 ])])]),
10800 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10801 AdfNode::text("C"),
10802 ])])]),
10803 ]);
10804 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10805 let doc = AdfDocument {
10806 version: 1,
10807 doc_type: "doc".to_string(),
10808 content: vec![table],
10809 };
10810 let md = adf_to_markdown(&doc).unwrap();
10811 assert!(md.contains("{layout=wide numbered}"));
10812 }
10813
10814 #[test]
10817 fn underline_bracketed_span() {
10818 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10819 let content = doc.content[0].content.as_ref().unwrap();
10820 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10821 let marks = content[1].marks.as_ref().unwrap();
10822 assert_eq!(marks[0].mark_type, "underline");
10823 }
10824
10825 #[test]
10826 fn adf_underline_to_markdown() {
10827 let doc = AdfDocument {
10828 version: 1,
10829 doc_type: "doc".to_string(),
10830 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10831 "underlined",
10832 vec![AdfMark::underline()],
10833 )])],
10834 };
10835 let md = adf_to_markdown(&doc).unwrap();
10836 assert!(md.contains("[underlined]{underline}"));
10837 }
10838
10839 #[test]
10840 fn round_trip_underline() {
10841 let md = "This is [underlined text]{underline} here.\n";
10842 let doc = markdown_to_adf(md).unwrap();
10843 let result = adf_to_markdown(&doc).unwrap();
10844 assert!(result.contains("[underlined text]{underline}"));
10845 }
10846
10847 #[test]
10848 fn mark_ordering_underline_strong_preserved() {
10849 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10851 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10852 ]}]}"#;
10853 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10854 let md = adf_to_markdown(&doc).unwrap();
10855 let round_tripped = markdown_to_adf(&md).unwrap();
10856 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10857 let mark_types: Vec<&str> = node
10858 .marks
10859 .as_ref()
10860 .unwrap()
10861 .iter()
10862 .map(|m| m.mark_type.as_str())
10863 .collect();
10864 assert_eq!(
10865 mark_types,
10866 vec!["underline", "strong"],
10867 "mark order should be preserved, got: {mark_types:?}"
10868 );
10869 }
10870
10871 #[test]
10872 fn mark_ordering_link_strong_preserved() {
10873 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10875 {"type":"text","text":"bold link","marks":[
10876 {"type":"link","attrs":{"href":"https://example.com"}},
10877 {"type":"strong"}
10878 ]}
10879 ]}]}"#;
10880 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10881 let md = adf_to_markdown(&doc).unwrap();
10882 let round_tripped = markdown_to_adf(&md).unwrap();
10883 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10884 let mark_types: Vec<&str> = node
10885 .marks
10886 .as_ref()
10887 .unwrap()
10888 .iter()
10889 .map(|m| m.mark_type.as_str())
10890 .collect();
10891 assert_eq!(
10892 mark_types,
10893 vec!["link", "strong"],
10894 "mark order should be preserved, got: {mark_types:?}"
10895 );
10896 }
10897
10898 #[test]
10899 fn mark_ordering_link_textcolor_preserved() {
10900 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10902 {"type":"text","text":"red link","marks":[
10903 {"type":"link","attrs":{"href":"https://example.com"}},
10904 {"type":"textColor","attrs":{"color":"#ff0000"}}
10905 ]}
10906 ]}]}"##;
10907 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10908 let md = adf_to_markdown(&doc).unwrap();
10909 let round_tripped = markdown_to_adf(&md).unwrap();
10910 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10911 let mark_types: Vec<&str> = node
10912 .marks
10913 .as_ref()
10914 .unwrap()
10915 .iter()
10916 .map(|m| m.mark_type.as_str())
10917 .collect();
10918 assert_eq!(
10919 mark_types,
10920 vec!["link", "textColor"],
10921 "mark order should be preserved, got: {mark_types:?}"
10922 );
10923 }
10924
10925 #[test]
10926 fn mark_ordering_link_em_preserved() {
10927 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10929 {"type":"text","text":"italic link","marks":[
10930 {"type":"link","attrs":{"href":"https://example.com"}},
10931 {"type":"em"}
10932 ]}
10933 ]}]}"#;
10934 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10935 let md = adf_to_markdown(&doc).unwrap();
10936 let round_tripped = markdown_to_adf(&md).unwrap();
10937 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10938 let mark_types: Vec<&str> = node
10939 .marks
10940 .as_ref()
10941 .unwrap()
10942 .iter()
10943 .map(|m| m.mark_type.as_str())
10944 .collect();
10945 assert_eq!(
10946 mark_types,
10947 vec!["link", "em"],
10948 "mark order should be preserved, got: {mark_types:?}"
10949 );
10950 }
10951
10952 #[test]
10953 fn mark_ordering_link_strike_preserved() {
10954 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10956 {"type":"text","text":"struck link","marks":[
10957 {"type":"link","attrs":{"href":"https://example.com"}},
10958 {"type":"strike"}
10959 ]}
10960 ]}]}"#;
10961 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10962 let md = adf_to_markdown(&doc).unwrap();
10963 let round_tripped = markdown_to_adf(&md).unwrap();
10964 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10965 let mark_types: Vec<&str> = node
10966 .marks
10967 .as_ref()
10968 .unwrap()
10969 .iter()
10970 .map(|m| m.mark_type.as_str())
10971 .collect();
10972 assert_eq!(
10973 mark_types,
10974 vec!["link", "strike"],
10975 "mark order should be preserved, got: {mark_types:?}"
10976 );
10977 }
10978
10979 #[test]
10980 fn mark_ordering_strong_link_preserved() {
10981 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10983 {"type":"text","text":"bold link","marks":[
10984 {"type":"strong"},
10985 {"type":"link","attrs":{"href":"https://example.com"}}
10986 ]}
10987 ]}]}"#;
10988 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10989 let md = adf_to_markdown(&doc).unwrap();
10990 let round_tripped = markdown_to_adf(&md).unwrap();
10991 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10992 let mark_types: Vec<&str> = node
10993 .marks
10994 .as_ref()
10995 .unwrap()
10996 .iter()
10997 .map(|m| m.mark_type.as_str())
10998 .collect();
10999 assert_eq!(
11000 mark_types,
11001 vec!["strong", "link"],
11002 "mark order should be preserved, got: {mark_types:?}"
11003 );
11004 }
11005
11006 #[test]
11007 fn mark_ordering_em_link_preserved() {
11008 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11010 {"type":"text","text":"italic link","marks":[
11011 {"type":"em"},
11012 {"type":"link","attrs":{"href":"https://example.com"}}
11013 ]}
11014 ]}]}"#;
11015 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11016 let md = adf_to_markdown(&doc).unwrap();
11017 let round_tripped = markdown_to_adf(&md).unwrap();
11018 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11019 let mark_types: Vec<&str> = node
11020 .marks
11021 .as_ref()
11022 .unwrap()
11023 .iter()
11024 .map(|m| m.mark_type.as_str())
11025 .collect();
11026 assert_eq!(
11027 mark_types,
11028 vec!["em", "link"],
11029 "mark order should be preserved, got: {mark_types:?}"
11030 );
11031 }
11032
11033 #[test]
11034 fn mark_ordering_strike_link_preserved() {
11035 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11037 {"type":"text","text":"struck link","marks":[
11038 {"type":"strike"},
11039 {"type":"link","attrs":{"href":"https://example.com"}}
11040 ]}
11041 ]}]}"#;
11042 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11043 let md = adf_to_markdown(&doc).unwrap();
11044 let round_tripped = markdown_to_adf(&md).unwrap();
11045 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11046 let mark_types: Vec<&str> = node
11047 .marks
11048 .as_ref()
11049 .unwrap()
11050 .iter()
11051 .map(|m| m.mark_type.as_str())
11052 .collect();
11053 assert_eq!(
11054 mark_types,
11055 vec!["strike", "link"],
11056 "mark order should be preserved, got: {mark_types:?}"
11057 );
11058 }
11059
11060 #[test]
11061 fn mark_ordering_underline_link_preserved() {
11062 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11064 {"type":"text","text":"click here","marks":[
11065 {"type":"underline"},
11066 {"type":"link","attrs":{"href":"https://example.com"}}
11067 ]}
11068 ]}]}"#;
11069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11070 let md = adf_to_markdown(&doc).unwrap();
11071 let round_tripped = markdown_to_adf(&md).unwrap();
11072 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11073 let mark_types: Vec<&str> = node
11074 .marks
11075 .as_ref()
11076 .unwrap()
11077 .iter()
11078 .map(|m| m.mark_type.as_str())
11079 .collect();
11080 assert_eq!(
11081 mark_types,
11082 vec!["underline", "link"],
11083 "mark order should be preserved, got: {mark_types:?}"
11084 );
11085 }
11086
11087 #[test]
11088 fn mark_ordering_textcolor_link_preserved() {
11089 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11091 {"type":"text","text":"red link","marks":[
11092 {"type":"textColor","attrs":{"color":"#ff0000"}},
11093 {"type":"link","attrs":{"href":"https://example.com"}}
11094 ]}
11095 ]}]}"##;
11096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11097 let md = adf_to_markdown(&doc).unwrap();
11098 let round_tripped = markdown_to_adf(&md).unwrap();
11099 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11100 let mark_types: Vec<&str> = node
11101 .marks
11102 .as_ref()
11103 .unwrap()
11104 .iter()
11105 .map(|m| m.mark_type.as_str())
11106 .collect();
11107 assert_eq!(
11108 mark_types,
11109 vec!["textColor", "link"],
11110 "mark order should be preserved, got: {mark_types:?}"
11111 );
11112 }
11113
11114 #[test]
11115 fn mark_ordering_link_underline_preserved() {
11116 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11118 {"type":"text","text":"click here","marks":[
11119 {"type":"link","attrs":{"href":"https://example.com"}},
11120 {"type":"underline"}
11121 ]}
11122 ]}]}"#;
11123 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11124 let md = adf_to_markdown(&doc).unwrap();
11125 assert!(
11127 md.contains("](https://example.com)"),
11128 "should have link: {md}"
11129 );
11130 assert!(md.contains("underline"), "should have underline: {md}");
11131 let round_tripped = markdown_to_adf(&md).unwrap();
11132 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11133 let mark_types: Vec<&str> = node
11134 .marks
11135 .as_ref()
11136 .unwrap()
11137 .iter()
11138 .map(|m| m.mark_type.as_str())
11139 .collect();
11140 assert_eq!(
11141 mark_types,
11142 vec!["link", "underline"],
11143 "mark order should be preserved, got: {mark_types:?}"
11144 );
11145 }
11146
11147 #[test]
11148 fn mark_ordering_underline_strong_link_preserved() {
11149 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11151 {"type":"text","text":"bold underlined link","marks":[
11152 {"type":"underline"},
11153 {"type":"strong"},
11154 {"type":"link","attrs":{"href":"https://example.com/page"}}
11155 ]}
11156 ]}]}"#;
11157 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11158 let md = adf_to_markdown(&doc).unwrap();
11159 let round_tripped = markdown_to_adf(&md).unwrap();
11160 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11161 let mark_types: Vec<&str> = node
11162 .marks
11163 .as_ref()
11164 .unwrap()
11165 .iter()
11166 .map(|m| m.mark_type.as_str())
11167 .collect();
11168 assert_eq!(
11169 mark_types,
11170 vec!["underline", "strong", "link"],
11171 "mark order should be preserved, got: {mark_types:?}"
11172 );
11173 }
11174
11175 #[test]
11176 fn mark_ordering_strong_underline_link_preserved() {
11177 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11179 {"type":"text","text":"bold underlined link","marks":[
11180 {"type":"strong"},
11181 {"type":"underline"},
11182 {"type":"link","attrs":{"href":"https://example.com/page"}}
11183 ]}
11184 ]}]}"#;
11185 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11186 let md = adf_to_markdown(&doc).unwrap();
11187 let round_tripped = markdown_to_adf(&md).unwrap();
11188 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11189 let mark_types: Vec<&str> = node
11190 .marks
11191 .as_ref()
11192 .unwrap()
11193 .iter()
11194 .map(|m| m.mark_type.as_str())
11195 .collect();
11196 assert_eq!(
11197 mark_types,
11198 vec!["strong", "underline", "link"],
11199 "mark order should be preserved, got: {mark_types:?}"
11200 );
11201 }
11202
11203 #[test]
11204 fn mark_ordering_underline_em_link_preserved() {
11205 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11207 {"type":"text","text":"italic underlined link","marks":[
11208 {"type":"underline"},
11209 {"type":"em"},
11210 {"type":"link","attrs":{"href":"https://example.com/page"}}
11211 ]}
11212 ]}]}"#;
11213 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11214 let md = adf_to_markdown(&doc).unwrap();
11215 let round_tripped = markdown_to_adf(&md).unwrap();
11216 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11217 let mark_types: Vec<&str> = node
11218 .marks
11219 .as_ref()
11220 .unwrap()
11221 .iter()
11222 .map(|m| m.mark_type.as_str())
11223 .collect();
11224 assert_eq!(
11225 mark_types,
11226 vec!["underline", "em", "link"],
11227 "mark order should be preserved, got: {mark_types:?}"
11228 );
11229 }
11230
11231 #[test]
11232 fn mark_ordering_underline_strike_link_preserved() {
11233 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11235 {"type":"text","text":"struck underlined link","marks":[
11236 {"type":"underline"},
11237 {"type":"strike"},
11238 {"type":"link","attrs":{"href":"https://example.com/page"}}
11239 ]}
11240 ]}]}"#;
11241 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11242 let md = adf_to_markdown(&doc).unwrap();
11243 let round_tripped = markdown_to_adf(&md).unwrap();
11244 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11245 let mark_types: Vec<&str> = node
11246 .marks
11247 .as_ref()
11248 .unwrap()
11249 .iter()
11250 .map(|m| m.mark_type.as_str())
11251 .collect();
11252 assert_eq!(
11253 mark_types,
11254 vec!["underline", "strike", "link"],
11255 "mark order should be preserved, got: {mark_types:?}"
11256 );
11257 }
11258
11259 #[test]
11260 fn mark_ordering_underline_strong_em_link_preserved() {
11261 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11263 {"type":"text","text":"all the marks","marks":[
11264 {"type":"underline"},
11265 {"type":"strong"},
11266 {"type":"em"},
11267 {"type":"link","attrs":{"href":"https://example.com/page"}}
11268 ]}
11269 ]}]}"#;
11270 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11271 let md = adf_to_markdown(&doc).unwrap();
11272 let round_tripped = markdown_to_adf(&md).unwrap();
11273 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11274 let mark_types: Vec<&str> = node
11275 .marks
11276 .as_ref()
11277 .unwrap()
11278 .iter()
11279 .map(|m| m.mark_type.as_str())
11280 .collect();
11281 assert_eq!(
11282 mark_types,
11283 vec!["underline", "strong", "em", "link"],
11284 "mark order should be preserved, got: {mark_types:?}"
11285 );
11286 }
11287
11288 #[test]
11289 fn em_strong_round_trip() {
11290 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11292 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11293 ]}]}"#;
11294 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11295 let md = adf_to_markdown(&doc).unwrap();
11296 assert_eq!(md.trim(), "***bold and italic***");
11297 let round_tripped = markdown_to_adf(&md).unwrap();
11298 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11299 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11300 let mark_types: Vec<&str> = node
11301 .marks
11302 .as_ref()
11303 .unwrap()
11304 .iter()
11305 .map(|m| m.mark_type.as_str())
11306 .collect();
11307 assert_eq!(
11308 mark_types,
11309 vec!["strong", "em"],
11310 "both strong and em marks should be preserved, got: {mark_types:?}"
11311 );
11312 }
11313
11314 #[test]
11315 fn em_strong_round_trip_em_first() {
11316 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11318 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11319 ]}]}"#;
11320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11321 let md = adf_to_markdown(&doc).unwrap();
11322 let round_tripped = markdown_to_adf(&md).unwrap();
11323 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11324 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11325 let mark_types: Vec<&str> = node
11326 .marks
11327 .as_ref()
11328 .unwrap()
11329 .iter()
11330 .map(|m| m.mark_type.as_str())
11331 .collect();
11332 assert_eq!(
11333 mark_types,
11334 vec!["em", "strong"],
11335 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11336 );
11337 }
11338
11339 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11342 let doc = AdfDocument {
11343 version: 1,
11344 doc_type: "doc".to_string(),
11345 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11346 "text", marks,
11347 )])],
11348 };
11349 let md = adf_to_markdown(&doc).unwrap();
11350 let round_tripped = markdown_to_adf(&md).unwrap();
11351 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11352 let mark_types: Vec<&str> = node
11353 .marks
11354 .as_ref()
11355 .expect("round-tripped node should have marks")
11356 .iter()
11357 .map(|m| m.mark_type.as_str())
11358 .collect();
11359 assert_eq!(
11360 mark_types, expected,
11361 "marks should round-trip in order via {md:?}"
11362 );
11363 }
11364
11365 #[test]
11366 fn round_trip_em_strong_mark_order() {
11367 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11369 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11370 }
11371
11372 #[test]
11373 fn round_trip_strong_underline_mark_order() {
11374 assert_mark_order_round_trip(
11376 vec![AdfMark::strong(), AdfMark::underline()],
11377 &["strong", "underline"],
11378 );
11379 assert_mark_order_round_trip(
11380 vec![AdfMark::underline(), AdfMark::strong()],
11381 &["underline", "strong"],
11382 );
11383 }
11384
11385 #[test]
11386 fn round_trip_em_underline_mark_order() {
11387 assert_mark_order_round_trip(
11388 vec![AdfMark::em(), AdfMark::underline()],
11389 &["em", "underline"],
11390 );
11391 assert_mark_order_round_trip(
11392 vec![AdfMark::underline(), AdfMark::em()],
11393 &["underline", "em"],
11394 );
11395 }
11396
11397 #[test]
11398 fn round_trip_strike_strong_em_permutations() {
11399 assert_mark_order_round_trip(
11403 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11404 &["strike", "strong", "em"],
11405 );
11406 assert_mark_order_round_trip(
11407 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11408 &["strike", "em", "strong"],
11409 );
11410 assert_mark_order_round_trip(
11411 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11412 &["strong", "strike", "em"],
11413 );
11414 assert_mark_order_round_trip(
11415 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11416 &["strong", "em", "strike"],
11417 );
11418 assert_mark_order_round_trip(
11419 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11420 &["em", "strike", "strong"],
11421 );
11422 assert_mark_order_round_trip(
11423 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11424 &["em", "strong", "strike"],
11425 );
11426 }
11427
11428 #[test]
11429 fn round_trip_underline_nested_with_strong_em() {
11430 assert_mark_order_round_trip(
11433 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11434 &["underline", "strong", "em"],
11435 );
11436 assert_mark_order_round_trip(
11437 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11438 &["strong", "underline", "em"],
11439 );
11440 assert_mark_order_round_trip(
11441 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11442 &["strong", "em", "underline"],
11443 );
11444 }
11445
11446 #[test]
11447 fn round_trip_span_attr_order_preserved() {
11448 assert_mark_order_round_trip(
11452 vec![
11453 AdfMark::background_color("#ffff00"),
11454 AdfMark::text_color("#ff0000"),
11455 ],
11456 &["backgroundColor", "textColor"],
11457 );
11458 assert_mark_order_round_trip(
11459 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11460 &["subsup", "textColor"],
11461 );
11462 assert_mark_order_round_trip(
11463 vec![
11464 AdfMark::text_color("#ff0000"),
11465 AdfMark::background_color("#ffff00"),
11466 ],
11467 &["textColor", "backgroundColor"],
11468 );
11469 }
11470
11471 #[test]
11472 fn round_trip_annotation_before_underline() {
11473 assert_mark_order_round_trip(
11477 vec![
11478 AdfMark::annotation("ann-1", "inlineComment"),
11479 AdfMark::underline(),
11480 ],
11481 &["annotation", "underline"],
11482 );
11483 assert_mark_order_round_trip(
11484 vec![
11485 AdfMark::annotation("ann-1", "inlineComment"),
11486 AdfMark::underline(),
11487 AdfMark::annotation("ann-2", "inlineComment"),
11488 ],
11489 &["annotation", "underline", "annotation"],
11490 );
11491 }
11492
11493 #[test]
11494 fn round_trip_em_content_with_underscores() {
11495 let doc = AdfDocument {
11500 version: 1,
11501 doc_type: "doc".to_string(),
11502 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11503 "foo _bar_ baz",
11504 vec![AdfMark::em(), AdfMark::strong()],
11505 )])],
11506 };
11507 let md = adf_to_markdown(&doc).unwrap();
11508 let round_tripped = markdown_to_adf(&md).unwrap();
11509 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11510 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11511 let mark_types: Vec<&str> = node
11512 .marks
11513 .as_ref()
11514 .unwrap()
11515 .iter()
11516 .map(|m| m.mark_type.as_str())
11517 .collect();
11518 assert_eq!(mark_types, vec!["em", "strong"]);
11519 }
11520
11521 #[test]
11522 fn round_trip_link_nested_with_formatting_marks() {
11523 assert_mark_order_round_trip(
11526 vec![
11527 AdfMark::link("https://example.com"),
11528 AdfMark::strong(),
11529 AdfMark::em(),
11530 ],
11531 &["link", "strong", "em"],
11532 );
11533 assert_mark_order_round_trip(
11534 vec![
11535 AdfMark::em(),
11536 AdfMark::strong(),
11537 AdfMark::link("https://example.com"),
11538 ],
11539 &["em", "strong", "link"],
11540 );
11541 assert_mark_order_round_trip(
11542 vec![
11543 AdfMark::underline(),
11544 AdfMark::link("https://example.com"),
11545 AdfMark::strong(),
11546 ],
11547 &["underline", "link", "strong"],
11548 );
11549 }
11550
11551 fn bare_mark(mark_type: &str) -> AdfMark {
11555 AdfMark {
11556 mark_type: mark_type.to_string(),
11557 attrs: None,
11558 }
11559 }
11560
11561 #[test]
11562 fn collect_span_attr_handles_missing_attrs() {
11563 let mut attrs = Vec::new();
11568 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11569 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11570 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11571 collect_span_attr(&bare_mark("link"), &mut attrs);
11572 assert!(attrs.is_empty(), "got: {attrs:?}");
11573 }
11574
11575 #[test]
11576 fn collect_bracketed_attr_handles_missing_attrs() {
11577 let mut attrs = Vec::new();
11580 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11581 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11582 assert!(attrs.is_empty(), "got: {attrs:?}");
11583 }
11584
11585 #[test]
11586 fn collect_bracketed_attr_handles_annotation_without_id() {
11587 let mark = AdfMark {
11591 mark_type: "annotation".to_string(),
11592 attrs: Some(serde_json::json!({})),
11593 };
11594 let mut attrs = Vec::new();
11595 collect_bracketed_attr(&mark, &mut attrs);
11596 assert!(attrs.is_empty(), "got: {attrs:?}");
11597 }
11598
11599 #[test]
11600 fn span_attr_order_rejects_unknown_types() {
11601 assert_eq!(span_attr_order("textColor"), 0);
11605 assert_eq!(span_attr_order("backgroundColor"), 1);
11606 assert_eq!(span_attr_order("subsup"), 2);
11607 assert_eq!(span_attr_order("strong"), u8::MAX);
11608 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11609 }
11610
11611 #[test]
11612 fn bracketed_run_rejects_unknown_types() {
11613 assert!(bracketed_run_is_canonical(&[
11617 AdfMark::underline(),
11618 AdfMark::annotation("x", "inlineComment")
11619 ]));
11620 assert!(!bracketed_run_is_canonical(&[
11621 AdfMark::annotation("x", "inlineComment"),
11622 AdfMark::underline()
11623 ]));
11624 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11625 }
11626
11627 #[test]
11628 fn render_marked_text_ignores_unknown_mark_types() {
11629 let doc = AdfDocument {
11633 version: 1,
11634 doc_type: "doc".to_string(),
11635 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11636 "hello",
11637 vec![bare_mark("futureMark"), AdfMark::strong()],
11638 )])],
11639 };
11640 let md = adf_to_markdown(&doc).unwrap();
11641 assert_eq!(md.trim(), "**hello**");
11642 let rt = markdown_to_adf(&md).unwrap();
11643 let node = &rt.content[0].content.as_ref().unwrap()[0];
11644 assert_eq!(node.text.as_deref(), Some("hello"));
11645 let mark_types: Vec<&str> = node
11646 .marks
11647 .as_ref()
11648 .unwrap()
11649 .iter()
11650 .map(|m| m.mark_type.as_str())
11651 .collect();
11652 assert_eq!(mark_types, vec!["strong"]);
11653 }
11654
11655 #[test]
11656 fn triple_asterisk_parse_to_adf() {
11657 let md = "***bold and italic***\n";
11659 let doc = markdown_to_adf(md).unwrap();
11660 let node = &doc.content[0].content.as_ref().unwrap()[0];
11661 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11662 let mark_types: Vec<&str> = node
11663 .marks
11664 .as_ref()
11665 .unwrap()
11666 .iter()
11667 .map(|m| m.mark_type.as_str())
11668 .collect();
11669 assert!(
11670 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11671 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11672 );
11673 }
11674
11675 #[test]
11676 fn triple_asterisk_with_surrounding_text() {
11677 let md = "before ***bold italic*** after\n";
11679 let doc = markdown_to_adf(md).unwrap();
11680 let nodes = doc.content[0].content.as_ref().unwrap();
11681 assert!(
11683 nodes.len() >= 3,
11684 "expected at least 3 nodes, got {}",
11685 nodes.len()
11686 );
11687 assert_eq!(nodes[0].text.as_deref(), Some("before "));
11688 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11689 let mark_types: Vec<&str> = nodes[1]
11690 .marks
11691 .as_ref()
11692 .unwrap()
11693 .iter()
11694 .map(|m| m.mark_type.as_str())
11695 .collect();
11696 assert!(
11697 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11698 "middle node should have strong+em, got: {mark_types:?}"
11699 );
11700 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11701 }
11702
11703 #[test]
11704 fn annotation_mark_round_trip() {
11705 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11707 {"type":"text","text":"highlighted text","marks":[
11708 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11709 ]}
11710 ]}]}"#;
11711 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11712
11713 let md = adf_to_markdown(&doc).unwrap();
11714 assert!(
11715 md.contains("annotation-id="),
11716 "JFM should contain annotation-id, got: {md}"
11717 );
11718
11719 let round_tripped = markdown_to_adf(&md).unwrap();
11720 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11721 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11722 let marks = text_node.marks.as_ref().expect("should have marks");
11723 let ann = marks
11724 .iter()
11725 .find(|m| m.mark_type == "annotation")
11726 .expect("should have annotation mark");
11727 let attrs = ann.attrs.as_ref().unwrap();
11728 assert_eq!(attrs["id"], "abc123");
11729 assert_eq!(attrs["annotationType"], "inlineComment");
11730 }
11731
11732 #[test]
11733 fn annotation_mark_with_bold() {
11734 let doc = AdfDocument {
11736 version: 1,
11737 doc_type: "doc".to_string(),
11738 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11739 "bold comment",
11740 vec![
11741 AdfMark::strong(),
11742 AdfMark::annotation("def456", "inlineComment"),
11743 ],
11744 )])],
11745 };
11746 let md = adf_to_markdown(&doc).unwrap();
11747 let round_tripped = markdown_to_adf(&md).unwrap();
11748 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11749 let marks = text_node.marks.as_ref().expect("should have marks");
11750 assert!(
11751 marks.iter().any(|m| m.mark_type == "strong"),
11752 "should have strong mark"
11753 );
11754 assert!(
11755 marks.iter().any(|m| m.mark_type == "annotation"),
11756 "should have annotation mark"
11757 );
11758 }
11759
11760 #[test]
11761 fn annotation_and_link_marks_both_preserved() {
11762 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11764 {"type":"text","text":"HANGUL-8","marks":[
11765 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11766 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11767 ]}
11768 ]}]}"#;
11769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11770 let md = adf_to_markdown(&doc).unwrap();
11771 assert!(
11773 md.contains("annotation-id="),
11774 "JFM should contain annotation-id, got: {md}"
11775 );
11776 assert!(
11777 md.contains("](https://"),
11778 "JFM should contain link href, got: {md}"
11779 );
11780 let round_tripped = markdown_to_adf(&md).unwrap();
11781 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11782 let marks = text_node.marks.as_ref().expect("should have marks");
11783 assert!(
11784 marks.iter().any(|m| m.mark_type == "annotation"),
11785 "should have annotation mark, got: {:?}",
11786 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11787 );
11788 assert!(
11789 marks.iter().any(|m| m.mark_type == "link"),
11790 "should have link mark, got: {:?}",
11791 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11792 );
11793 }
11794
11795 #[test]
11796 fn annotation_and_code_marks_both_preserved() {
11797 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11799 {"type":"text","text":"some text with "},
11800 {"type":"text","text":"annotated code","marks":[
11801 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11802 {"type":"code"}
11803 ]},
11804 {"type":"text","text":" remaining text"}
11805 ]}]}"#;
11806 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11807 let md = adf_to_markdown(&doc).unwrap();
11808 assert!(
11809 md.contains("annotation-id="),
11810 "JFM should contain annotation-id, got: {md}"
11811 );
11812 assert!(
11813 md.contains('`'),
11814 "JFM should contain backticks for code, got: {md}"
11815 );
11816
11817 let round_tripped = markdown_to_adf(&md).unwrap();
11818 let nodes = round_tripped.content[0].content.as_ref().unwrap();
11819 let code_node = nodes
11821 .iter()
11822 .find(|n| n.text.as_deref() == Some("annotated code"))
11823 .expect("should have 'annotated code' text node");
11824 let marks = code_node.marks.as_ref().expect("should have marks");
11825 assert!(
11826 marks.iter().any(|m| m.mark_type == "annotation"),
11827 "should have annotation mark, got: {:?}",
11828 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11829 );
11830 assert!(
11831 marks.iter().any(|m| m.mark_type == "code"),
11832 "should have code mark, got: {:?}",
11833 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11834 );
11835 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11836 let attrs = ann.attrs.as_ref().unwrap();
11837 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11838 assert_eq!(attrs["annotationType"], "inlineComment");
11839 }
11840
11841 #[test]
11842 fn annotation_and_code_and_link_marks_all_preserved() {
11843 let doc = AdfDocument {
11845 version: 1,
11846 doc_type: "doc".to_string(),
11847 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11848 "linked code",
11849 vec![
11850 AdfMark::annotation("ann-001", "inlineComment"),
11851 AdfMark::code(),
11852 AdfMark::link("https://example.com"),
11853 ],
11854 )])],
11855 };
11856 let md = adf_to_markdown(&doc).unwrap();
11857 assert!(
11858 md.contains("annotation-id="),
11859 "JFM should contain annotation-id, got: {md}"
11860 );
11861 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11862 assert!(
11863 md.contains("](https://example.com)"),
11864 "JFM should contain link, got: {md}"
11865 );
11866
11867 let round_tripped = markdown_to_adf(&md).unwrap();
11868 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11869 let marks = text_node.marks.as_ref().expect("should have marks");
11870 assert!(
11871 marks.iter().any(|m| m.mark_type == "annotation"),
11872 "should have annotation mark, got: {:?}",
11873 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11874 );
11875 assert!(
11876 marks.iter().any(|m| m.mark_type == "code"),
11877 "should have code mark, got: {:?}",
11878 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11879 );
11880 assert!(
11881 marks.iter().any(|m| m.mark_type == "link"),
11882 "should have link mark, got: {:?}",
11883 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11884 );
11885 }
11886
11887 #[test]
11888 fn multiple_annotations_and_code_mark_preserved() {
11889 let doc = AdfDocument {
11891 version: 1,
11892 doc_type: "doc".to_string(),
11893 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11894 "doubly annotated",
11895 vec![
11896 AdfMark::annotation("ann-aaa", "inlineComment"),
11897 AdfMark::annotation("ann-bbb", "inlineComment"),
11898 AdfMark::code(),
11899 ],
11900 )])],
11901 };
11902 let md = adf_to_markdown(&doc).unwrap();
11903 assert!(
11904 md.contains("ann-aaa"),
11905 "JFM should contain first annotation id, got: {md}"
11906 );
11907 assert!(
11908 md.contains("ann-bbb"),
11909 "JFM should contain second annotation id, got: {md}"
11910 );
11911
11912 let round_tripped = markdown_to_adf(&md).unwrap();
11913 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11914 let marks = text_node.marks.as_ref().expect("should have marks");
11915 let ann_marks: Vec<_> = marks
11916 .iter()
11917 .filter(|m| m.mark_type == "annotation")
11918 .collect();
11919 assert_eq!(
11920 ann_marks.len(),
11921 2,
11922 "should have 2 annotation marks, got: {}",
11923 ann_marks.len()
11924 );
11925 assert!(
11926 marks.iter().any(|m| m.mark_type == "code"),
11927 "should have code mark"
11928 );
11929 }
11930
11931 #[test]
11932 fn underline_and_link_marks_both_preserved() {
11933 let doc = AdfDocument {
11935 version: 1,
11936 doc_type: "doc".to_string(),
11937 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11938 "click here",
11939 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
11940 )])],
11941 };
11942 let md = adf_to_markdown(&doc).unwrap();
11943 assert!(md.contains("underline"), "should have underline attr: {md}");
11944 assert!(
11945 md.contains("](https://example.com)"),
11946 "should have link: {md}"
11947 );
11948 let round_tripped = markdown_to_adf(&md).unwrap();
11949 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11950 let marks = text_node.marks.as_ref().expect("should have marks");
11951 assert!(marks.iter().any(|m| m.mark_type == "underline"));
11952 assert!(marks.iter().any(|m| m.mark_type == "link"));
11953 }
11954
11955 #[test]
11956 fn annotation_link_and_bold_all_preserved() {
11957 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11959 {"type":"text","text":"important","marks":[
11960 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
11961 {"type":"link","attrs":{"href":"https://example.com"}},
11962 {"type":"strong"}
11963 ]}
11964 ]}]}"#;
11965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11966 let md = adf_to_markdown(&doc).unwrap();
11967 let round_tripped = markdown_to_adf(&md).unwrap();
11968 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11969 let marks = text_node.marks.as_ref().expect("should have marks");
11970 assert!(
11971 marks.iter().any(|m| m.mark_type == "annotation"),
11972 "should have annotation"
11973 );
11974 assert!(
11975 marks.iter().any(|m| m.mark_type == "link"),
11976 "should have link"
11977 );
11978 assert!(
11979 marks.iter().any(|m| m.mark_type == "strong"),
11980 "should have strong"
11981 );
11982 }
11983
11984 #[test]
11985 fn multiple_annotation_marks_round_trip() {
11986 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11988 {"type":"text","text":"some annotated text","marks":[
11989 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
11990 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
11991 ]}
11992 ]}]}"#;
11993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11994
11995 let md = adf_to_markdown(&doc).unwrap();
11996 assert!(
11997 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
11998 "JFM should contain first annotation id, got: {md}"
11999 );
12000 assert!(
12001 md.contains("ffffffff-1111-2222-3333-444444444444"),
12002 "JFM should contain second annotation id, got: {md}"
12003 );
12004
12005 let round_tripped = markdown_to_adf(&md).unwrap();
12006 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12007 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12008 let marks = text_node.marks.as_ref().expect("should have marks");
12009 let annotations: Vec<_> = marks
12010 .iter()
12011 .filter(|m| m.mark_type == "annotation")
12012 .collect();
12013 assert_eq!(
12014 annotations.len(),
12015 2,
12016 "should have 2 annotation marks, got: {annotations:?}"
12017 );
12018 let ids: Vec<_> = annotations
12019 .iter()
12020 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12021 .collect();
12022 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12023 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12024 }
12025
12026 #[test]
12027 fn three_annotation_marks_round_trip() {
12028 let doc = AdfDocument {
12030 version: 1,
12031 doc_type: "doc".to_string(),
12032 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12033 "triple annotated",
12034 vec![
12035 AdfMark::annotation("id-1", "inlineComment"),
12036 AdfMark::annotation("id-2", "inlineComment"),
12037 AdfMark::annotation("id-3", "inlineComment"),
12038 ],
12039 )])],
12040 };
12041 let md = adf_to_markdown(&doc).unwrap();
12042 let round_tripped = markdown_to_adf(&md).unwrap();
12043 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12044 let marks = text_node.marks.as_ref().expect("should have marks");
12045 let annotations: Vec<_> = marks
12046 .iter()
12047 .filter(|m| m.mark_type == "annotation")
12048 .collect();
12049 assert_eq!(
12050 annotations.len(),
12051 3,
12052 "should have 3 annotation marks, got: {annotations:?}"
12053 );
12054 }
12055
12056 #[test]
12057 fn multiple_annotations_with_bold_round_trip() {
12058 let doc = AdfDocument {
12060 version: 1,
12061 doc_type: "doc".to_string(),
12062 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12063 "bold double annotated",
12064 vec![
12065 AdfMark::strong(),
12066 AdfMark::annotation("ann-a", "inlineComment"),
12067 AdfMark::annotation("ann-b", "inlineComment"),
12068 ],
12069 )])],
12070 };
12071 let md = adf_to_markdown(&doc).unwrap();
12072 let round_tripped = markdown_to_adf(&md).unwrap();
12073 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12074 let marks = text_node.marks.as_ref().expect("should have marks");
12075 assert!(
12076 marks.iter().any(|m| m.mark_type == "strong"),
12077 "should have strong mark"
12078 );
12079 let annotations: Vec<_> = marks
12080 .iter()
12081 .filter(|m| m.mark_type == "annotation")
12082 .collect();
12083 assert_eq!(
12084 annotations.len(),
12085 2,
12086 "should have 2 annotation marks, got: {annotations:?}"
12087 );
12088 }
12089
12090 #[test]
12091 fn multiple_annotations_with_link_round_trip() {
12092 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12094 {"type":"text","text":"linked text","marks":[
12095 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12096 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12097 {"type":"link","attrs":{"href":"https://example.com"}}
12098 ]}
12099 ]}]}"#;
12100 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12101 let md = adf_to_markdown(&doc).unwrap();
12102 let round_tripped = markdown_to_adf(&md).unwrap();
12103 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12104 let marks = text_node.marks.as_ref().expect("should have marks");
12105 assert!(
12106 marks.iter().any(|m| m.mark_type == "link"),
12107 "should have link mark"
12108 );
12109 let annotations: Vec<_> = marks
12110 .iter()
12111 .filter(|m| m.mark_type == "annotation")
12112 .collect();
12113 assert_eq!(
12114 annotations.len(),
12115 2,
12116 "should have 2 annotation marks, got: {annotations:?}"
12117 );
12118 }
12119
12120 #[test]
12123 fn annotation_on_emoji_round_trip() {
12124 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12126 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12127 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12128 ]},
12129 {"type":"text","text":" annotated text","marks":[
12130 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12131 ]}
12132 ]}]}"#;
12133 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12134 let md = adf_to_markdown(&doc).unwrap();
12135 assert!(
12136 md.contains("annotation-id="),
12137 "JFM should contain annotation-id for emoji, got: {md}"
12138 );
12139
12140 let round_tripped = markdown_to_adf(&md).unwrap();
12141 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12142
12143 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12145 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12146 assert!(
12147 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12148 "emoji should have annotation mark, got: {emoji_marks:?}"
12149 );
12150 let ann = emoji_marks
12151 .iter()
12152 .find(|m| m.mark_type == "annotation")
12153 .unwrap();
12154 assert_eq!(
12155 ann.attrs.as_ref().unwrap()["id"],
12156 "ccddee11-2233-4455-aabb-ccddee112233"
12157 );
12158
12159 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12161 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12162 assert!(
12163 text_marks.iter().any(|m| m.mark_type == "annotation"),
12164 "text should have annotation mark"
12165 );
12166 }
12167
12168 #[test]
12169 fn annotation_on_status_round_trip() {
12170 let mut status = AdfNode::status("In Progress", "blue");
12171 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12172
12173 let doc = AdfDocument {
12174 version: 1,
12175 doc_type: "doc".to_string(),
12176 content: vec![AdfNode::paragraph(vec![status])],
12177 };
12178 let md = adf_to_markdown(&doc).unwrap();
12179 assert!(
12180 md.contains("annotation-id="),
12181 "JFM should contain annotation-id for status, got: {md}"
12182 );
12183
12184 let round_tripped = markdown_to_adf(&md).unwrap();
12185 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12186 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12187 let marks = status_node
12188 .marks
12189 .as_ref()
12190 .expect("status should have marks");
12191 assert!(
12192 marks.iter().any(|m| m.mark_type == "annotation"),
12193 "status should have annotation mark, got: {marks:?}"
12194 );
12195 }
12196
12197 #[test]
12198 fn annotation_on_date_round_trip() {
12199 let mut date = AdfNode::date("1704067200000");
12200 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12201
12202 let doc = AdfDocument {
12203 version: 1,
12204 doc_type: "doc".to_string(),
12205 content: vec![AdfNode::paragraph(vec![date])],
12206 };
12207 let md = adf_to_markdown(&doc).unwrap();
12208 assert!(
12209 md.contains("annotation-id="),
12210 "JFM should contain annotation-id for date, got: {md}"
12211 );
12212
12213 let round_tripped = markdown_to_adf(&md).unwrap();
12214 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12215 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12216 let marks = date_node.marks.as_ref().expect("date should have marks");
12217 assert!(
12218 marks.iter().any(|m| m.mark_type == "annotation"),
12219 "date should have annotation mark, got: {marks:?}"
12220 );
12221 }
12222
12223 #[test]
12224 fn annotation_on_mention_round_trip() {
12225 let mut mention = AdfNode::mention("user-123", "@Alice");
12226 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12227
12228 let doc = AdfDocument {
12229 version: 1,
12230 doc_type: "doc".to_string(),
12231 content: vec![AdfNode::paragraph(vec![mention])],
12232 };
12233 let md = adf_to_markdown(&doc).unwrap();
12234 assert!(
12235 md.contains("annotation-id="),
12236 "JFM should contain annotation-id for mention, got: {md}"
12237 );
12238
12239 let round_tripped = markdown_to_adf(&md).unwrap();
12240 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12241 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12242 let marks = mention_node
12243 .marks
12244 .as_ref()
12245 .expect("mention should have marks");
12246 assert!(
12247 marks.iter().any(|m| m.mark_type == "annotation"),
12248 "mention should have annotation mark, got: {marks:?}"
12249 );
12250 }
12251
12252 #[test]
12253 fn annotation_on_inline_card_round_trip() {
12254 let mut card = AdfNode::inline_card("https://example.com");
12255 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12256
12257 let doc = AdfDocument {
12258 version: 1,
12259 doc_type: "doc".to_string(),
12260 content: vec![AdfNode::paragraph(vec![card])],
12261 };
12262 let md = adf_to_markdown(&doc).unwrap();
12263 assert!(
12264 md.contains("annotation-id="),
12265 "JFM should contain annotation-id for inlineCard, got: {md}"
12266 );
12267
12268 let round_tripped = markdown_to_adf(&md).unwrap();
12269 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12270 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12271 let marks = card_node
12272 .marks
12273 .as_ref()
12274 .expect("inlineCard should have marks");
12275 assert!(
12276 marks.iter().any(|m| m.mark_type == "annotation"),
12277 "inlineCard should have annotation mark, got: {marks:?}"
12278 );
12279 }
12280
12281 #[test]
12282 fn annotation_on_placeholder_round_trip() {
12283 let mut placeholder = AdfNode::placeholder("Enter text here");
12284 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12285
12286 let doc = AdfDocument {
12287 version: 1,
12288 doc_type: "doc".to_string(),
12289 content: vec![AdfNode::paragraph(vec![placeholder])],
12290 };
12291 let md = adf_to_markdown(&doc).unwrap();
12292 assert!(
12293 md.contains("annotation-id="),
12294 "JFM should contain annotation-id for placeholder, got: {md}"
12295 );
12296
12297 let round_tripped = markdown_to_adf(&md).unwrap();
12298 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12299 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12300 let marks = ph_node
12301 .marks
12302 .as_ref()
12303 .expect("placeholder should have marks");
12304 assert!(
12305 marks.iter().any(|m| m.mark_type == "annotation"),
12306 "placeholder should have annotation mark, got: {marks:?}"
12307 );
12308 }
12309
12310 #[test]
12311 fn multiple_annotations_on_emoji_round_trip() {
12312 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12314 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12315 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12316 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12317 ]}
12318 ]}]}"#;
12319 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12320 let md = adf_to_markdown(&doc).unwrap();
12321
12322 let round_tripped = markdown_to_adf(&md).unwrap();
12323 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12324 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12325 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12326 let annotations: Vec<_> = marks
12327 .iter()
12328 .filter(|m| m.mark_type == "annotation")
12329 .collect();
12330 assert_eq!(
12331 annotations.len(),
12332 2,
12333 "emoji should have 2 annotation marks, got: {annotations:?}"
12334 );
12335 }
12336
12337 #[test]
12338 fn emoji_without_annotation_unchanged() {
12339 let doc = AdfDocument {
12341 version: 1,
12342 doc_type: "doc".to_string(),
12343 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12344 };
12345 let md = adf_to_markdown(&doc).unwrap();
12346 assert!(
12348 !md.contains('['),
12349 "emoji without annotation should not be wrapped in brackets, got: {md}"
12350 );
12351 assert!(md.contains(":fire:"));
12352 }
12353
12354 #[test]
12357 fn status_directive() {
12358 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12359 let content = doc.content[0].content.as_ref().unwrap();
12360 assert_eq!(content[1].node_type, "status");
12361 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12362 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12363 }
12364
12365 #[test]
12366 fn adf_status_to_markdown() {
12367 let doc = AdfDocument {
12368 version: 1,
12369 doc_type: "doc".to_string(),
12370 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12371 };
12372 let md = adf_to_markdown(&doc).unwrap();
12373 assert!(md.contains(":status[Done]{color=green}"));
12374 }
12375
12376 #[test]
12377 fn round_trip_status() {
12378 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12379 let doc = markdown_to_adf(md).unwrap();
12380 let result = adf_to_markdown(&doc).unwrap();
12381 assert!(result.contains(":status[In Progress]{color=blue}"));
12382 }
12383
12384 #[test]
12385 fn status_with_style_and_localid_roundtrips() {
12386 let adf = AdfDocument {
12387 version: 1,
12388 doc_type: "doc".to_string(),
12389 content: vec![AdfNode::paragraph(vec![{
12390 let mut node = AdfNode::status("open", "green");
12391 node.attrs.as_mut().unwrap()["style"] =
12392 serde_json::Value::String("bold".to_string());
12393 node.attrs.as_mut().unwrap()["localId"] =
12394 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12395 node
12396 }])],
12397 };
12398
12399 let md = adf_to_markdown(&adf).unwrap();
12400 assert!(
12401 md.contains("style=bold"),
12402 "Markdown should contain style attr: {md}"
12403 );
12404 assert!(
12405 md.contains("localId=d2205ca5"),
12406 "Markdown should contain localId attr: {md}"
12407 );
12408
12409 let rt = markdown_to_adf(&md).unwrap();
12410 let status = &rt.content[0].content.as_ref().unwrap()[0];
12411 let attrs = status.attrs.as_ref().unwrap();
12412 assert_eq!(attrs["text"], "open");
12413 assert_eq!(attrs["color"], "green");
12414 assert_eq!(attrs["style"], "bold");
12415 assert_eq!(
12416 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12417 "localId should be preserved, got: {}",
12418 attrs["localId"]
12419 );
12420 }
12421
12422 #[test]
12423 fn status_without_style_still_works() {
12424 let md = ":status[Done]{color=green}\n";
12425 let doc = markdown_to_adf(md).unwrap();
12426 let status = &doc.content[0].content.as_ref().unwrap()[0];
12427 let attrs = status.attrs.as_ref().unwrap();
12428 assert_eq!(attrs["text"], "Done");
12429 assert_eq!(attrs["color"], "green");
12430 assert!(
12432 attrs.get("style").is_none() || attrs["style"].is_null(),
12433 "style should not be set when not provided"
12434 );
12435 }
12436
12437 #[test]
12438 fn strip_local_ids_removes_localid_from_status() {
12439 let adf = AdfDocument {
12440 version: 1,
12441 doc_type: "doc".to_string(),
12442 content: vec![AdfNode::paragraph(vec![{
12443 let mut node = AdfNode::status("open", "green");
12444 node.attrs.as_mut().unwrap()["localId"] =
12445 serde_json::Value::String("real-uuid-here".to_string());
12446 node
12447 }])],
12448 };
12449 let opts = RenderOptions {
12450 strip_local_ids: true,
12451 };
12452 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12453 assert!(
12454 !md.contains("localId"),
12455 "localId should be stripped, got: {md}"
12456 );
12457 assert!(md.contains("color=green"), "color should be preserved");
12458 }
12459
12460 #[test]
12461 fn strip_local_ids_removes_localid_from_table() {
12462 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"}]}]}]}]}]}"#;
12463 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12464 let opts = RenderOptions {
12465 strip_local_ids: true,
12466 };
12467 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12468 assert!(
12469 !md.contains("localId"),
12470 "localId should be stripped from table, got: {md}"
12471 );
12472 assert!(md.contains("layout=default"), "layout should be preserved");
12473 }
12474
12475 #[test]
12476 fn default_options_preserve_localid() {
12477 let adf = AdfDocument {
12478 version: 1,
12479 doc_type: "doc".to_string(),
12480 content: vec![AdfNode::paragraph(vec![{
12481 let mut node = AdfNode::status("open", "green");
12482 node.attrs.as_mut().unwrap()["localId"] =
12483 serde_json::Value::String("real-uuid-here".to_string());
12484 node
12485 }])],
12486 };
12487 let md = adf_to_markdown(&adf).unwrap();
12488 assert!(
12489 md.contains("localId=real-uuid-here"),
12490 "Default should preserve localId, got: {md}"
12491 );
12492 }
12493
12494 #[test]
12495 fn mention_localid_roundtrip() {
12496 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12497 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12498 let md = adf_to_markdown(&doc).unwrap();
12499 assert!(
12500 md.contains("localId=m-001"),
12501 "mention should have localId in md: {md}"
12502 );
12503 let rt = markdown_to_adf(&md).unwrap();
12504 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12505 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12506 }
12507
12508 #[test]
12509 fn date_localid_roundtrip() {
12510 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12511 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12512 let md = adf_to_markdown(&doc).unwrap();
12513 assert!(
12514 md.contains("localId=d-001"),
12515 "date should have localId in md: {md}"
12516 );
12517 let rt = markdown_to_adf(&md).unwrap();
12518 let date = &rt.content[0].content.as_ref().unwrap()[0];
12519 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12520 }
12521
12522 #[test]
12523 fn emoji_localid_roundtrip() {
12524 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12525 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12526 let md = adf_to_markdown(&doc).unwrap();
12527 assert!(
12528 md.contains("localId=e-001"),
12529 "emoji should have localId in md: {md}"
12530 );
12531 let rt = markdown_to_adf(&md).unwrap();
12532 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12533 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12534 }
12535
12536 #[test]
12537 fn inline_card_localid_roundtrip() {
12538 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12540 let md = adf_to_markdown(&doc).unwrap();
12541 assert!(
12542 md.contains("localId=c-001"),
12543 "inlineCard should have localId in md: {md}"
12544 );
12545 let rt = markdown_to_adf(&md).unwrap();
12546 let card = &rt.content[0].content.as_ref().unwrap()[0];
12547 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12548 }
12549
12550 #[test]
12551 fn strip_local_ids_removes_from_mention() {
12552 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12553 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12554 let opts = RenderOptions {
12555 strip_local_ids: true,
12556 };
12557 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12558 assert!(
12559 !md.contains("localId"),
12560 "localId should be stripped from mention: {md}"
12561 );
12562 assert!(md.contains("id=user123"), "other attrs should be preserved");
12563 }
12564
12565 #[test]
12566 fn strip_local_ids_removes_from_date() {
12567 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12568 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12569 let opts = RenderOptions {
12570 strip_local_ids: true,
12571 };
12572 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12573 assert!(
12574 !md.contains("localId"),
12575 "localId should be stripped from date: {md}"
12576 );
12577 }
12578
12579 #[test]
12580 fn strip_local_ids_removes_from_block_attrs() {
12581 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12582 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583 let opts = RenderOptions {
12584 strip_local_ids: true,
12585 };
12586 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12587 assert!(
12588 !md.contains("localId"),
12589 "localId should be stripped from block attrs: {md}"
12590 );
12591 }
12592
12593 #[test]
12594 fn table_cell_localid_roundtrip() {
12595 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"}]}]}]}]}]}"#;
12596 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12597 let md = adf_to_markdown(&doc).unwrap();
12598 assert!(
12599 md.contains("localId=tc-001"),
12600 "tableCell should have localId in md: {md}"
12601 );
12602 let rt = markdown_to_adf(&md).unwrap();
12603 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12604 .content
12605 .as_ref()
12606 .unwrap()[0];
12607 assert_eq!(
12608 cell.attrs.as_ref().unwrap()["localId"],
12609 "tc-001",
12610 "tableCell localId should round-trip"
12611 );
12612 }
12613
12614 #[test]
12615 fn table_cell_border_mark_roundtrip() {
12616 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"}]}]}]}]}]}"##;
12617 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12618 let md = adf_to_markdown(&doc).unwrap();
12619 assert!(
12620 md.contains("border-color=#ff000033"),
12621 "tableCell should have border-color in md: {md}"
12622 );
12623 assert!(
12624 md.contains("border-size=2"),
12625 "tableCell should have border-size in md: {md}"
12626 );
12627 let rt = markdown_to_adf(&md).unwrap();
12628 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12629 .content
12630 .as_ref()
12631 .unwrap()[0];
12632 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12633 assert_eq!(marks.len(), 1);
12634 assert_eq!(marks[0].mark_type, "border");
12635 let attrs = marks[0].attrs.as_ref().unwrap();
12636 assert_eq!(attrs["color"], "#ff000033");
12637 assert_eq!(attrs["size"], 2);
12638 }
12639
12640 #[test]
12641 fn table_header_border_mark_roundtrip() {
12642 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"}]}]}]}]}]}"##;
12643 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12644 let md = adf_to_markdown(&doc).unwrap();
12645 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12646 assert!(md.contains("border-size=3"), "md: {md}");
12647 let rt = markdown_to_adf(&md).unwrap();
12648 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12649 .content
12650 .as_ref()
12651 .unwrap()[0];
12652 assert_eq!(cell.node_type, "tableHeader");
12653 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12654 assert_eq!(marks[0].mark_type, "border");
12655 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12656 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12657 }
12658
12659 #[test]
12660 fn table_cell_border_mark_with_attrs_roundtrip() {
12661 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"}]}]}]}]}]}"##;
12662 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12663 let md = adf_to_markdown(&doc).unwrap();
12664 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12665 assert!(md.contains("colspan=2"), "md: {md}");
12666 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12667 let rt = markdown_to_adf(&md).unwrap();
12668 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12669 .content
12670 .as_ref()
12671 .unwrap()[0];
12672 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12673 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12674 let marks = cell.marks.as_ref().expect("should have marks");
12675 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12676 }
12677
12678 #[test]
12679 fn table_cell_no_border_mark_unchanged() {
12680 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"}]}]}]}]}]}"#;
12681 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12682 let md = adf_to_markdown(&doc).unwrap();
12683 assert!(
12684 !md.contains("border-color"),
12685 "no border attrs expected: {md}"
12686 );
12687 let rt = markdown_to_adf(&md).unwrap();
12688 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12689 .content
12690 .as_ref()
12691 .unwrap()[0];
12692 assert!(cell.marks.is_none(), "no marks expected on plain cell");
12693 }
12694
12695 #[test]
12696 fn table_cell_border_size_only_defaults_color() {
12697 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12700 let doc = markdown_to_adf(md).unwrap();
12701 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12702 .content
12703 .as_ref()
12704 .unwrap()[0];
12705 let marks = cell.marks.as_ref().expect("should have border mark");
12706 assert_eq!(marks[0].mark_type, "border");
12707 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12708 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12709 }
12710
12711 #[test]
12712 fn table_cell_border_color_only_defaults_size() {
12713 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12715 let doc = markdown_to_adf(md).unwrap();
12716 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12717 .content
12718 .as_ref()
12719 .unwrap()[0];
12720 let marks = cell.marks.as_ref().expect("should have border mark");
12721 assert_eq!(marks[0].mark_type, "border");
12722 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12723 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12724 }
12725
12726 #[test]
12727 fn media_file_border_mark_roundtrip() {
12728 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}}]}]}]}"##;
12729 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12730 let md = adf_to_markdown(&doc).unwrap();
12731 assert!(
12732 md.contains("border-color=#091e4224"),
12733 "media should have border-color in md: {md}"
12734 );
12735 assert!(
12736 md.contains("border-size=2"),
12737 "media should have border-size in md: {md}"
12738 );
12739 let rt = markdown_to_adf(&md).unwrap();
12740 let media_single = &rt.content[0];
12741 let media = &media_single.content.as_ref().unwrap()[0];
12742 assert_eq!(media.node_type, "media");
12743 let marks = media.marks.as_ref().expect("media should have marks");
12744 assert_eq!(marks.len(), 1);
12745 assert_eq!(marks[0].mark_type, "border");
12746 let attrs = marks[0].attrs.as_ref().unwrap();
12747 assert_eq!(attrs["color"], "#091e4224");
12748 assert_eq!(attrs["size"], 2);
12749 }
12750
12751 #[test]
12752 fn media_external_border_mark_roundtrip() {
12753 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}}]}]}]}"##;
12754 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12755 let md = adf_to_markdown(&doc).unwrap();
12756 assert!(
12757 md.contains("border-color=#ff0000"),
12758 "external media should have border-color in md: {md}"
12759 );
12760 assert!(
12761 md.contains("border-size=3"),
12762 "external media should have border-size in md: {md}"
12763 );
12764 let rt = markdown_to_adf(&md).unwrap();
12765 let media = &rt.content[0].content.as_ref().unwrap()[0];
12766 let marks = media.marks.as_ref().expect("media should have marks");
12767 assert_eq!(marks[0].mark_type, "border");
12768 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12769 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12770 }
12771
12772 #[test]
12773 fn media_file_no_border_mark_unchanged() {
12774 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}}]}]}"#;
12775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776 let md = adf_to_markdown(&doc).unwrap();
12777 assert!(
12778 !md.contains("border-color"),
12779 "no border attrs expected: {md}"
12780 );
12781 let rt = markdown_to_adf(&md).unwrap();
12782 let media = &rt.content[0].content.as_ref().unwrap()[0];
12783 assert!(media.marks.is_none(), "no marks expected on plain media");
12784 }
12785
12786 #[test]
12787 fn media_border_size_only_defaults_color() {
12788 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}}]}]}]}"#;
12789 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12790 let md = adf_to_markdown(&doc).unwrap();
12791 assert!(md.contains("border-size=4"), "md: {md}");
12792 let rt = markdown_to_adf(&md).unwrap();
12793 let media = &rt.content[0].content.as_ref().unwrap()[0];
12794 let marks = media.marks.as_ref().expect("should have border mark");
12795 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12796 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12797 }
12798
12799 #[test]
12800 fn media_border_color_only_defaults_size() {
12801 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"}}]}]}]}"##;
12802 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12803 let md = adf_to_markdown(&doc).unwrap();
12804 assert!(md.contains("border-color=#00ff00"), "md: {md}");
12805 let rt = markdown_to_adf(&md).unwrap();
12806 let media = &rt.content[0].content.as_ref().unwrap()[0];
12807 let marks = media.marks.as_ref().expect("should have border mark");
12808 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12809 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12810 }
12811
12812 #[test]
12813 fn media_border_with_other_attrs_roundtrip() {
12814 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}}]}]}]}"##;
12815 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12816 let md = adf_to_markdown(&doc).unwrap();
12817 assert!(md.contains("layout=wide"), "md: {md}");
12818 assert!(md.contains("mediaWidth=600"), "md: {md}");
12819 assert!(md.contains("border-color=#091e4224"), "md: {md}");
12820 assert!(md.contains("border-size=2"), "md: {md}");
12821 let rt = markdown_to_adf(&md).unwrap();
12822 let ms = &rt.content[0];
12823 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12824 let media = &ms.content.as_ref().unwrap()[0];
12825 let marks = media.marks.as_ref().expect("should have marks");
12826 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12827 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12828 }
12829
12830 #[test]
12831 fn table_row_localid_roundtrip() {
12832 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"}]}]}]}]}]}"#;
12833 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12834 let md = adf_to_markdown(&doc).unwrap();
12835 assert!(
12836 md.contains("localId=tr-001"),
12837 "tableRow should have localId in md: {md}"
12838 );
12839 let rt = markdown_to_adf(&md).unwrap();
12840 let row = &rt.content[0].content.as_ref().unwrap()[0];
12841 assert_eq!(
12842 row.attrs.as_ref().unwrap()["localId"],
12843 "tr-001",
12844 "tableRow localId should round-trip"
12845 );
12846 }
12847
12848 #[test]
12849 fn list_item_localid_roundtrip() {
12850 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"}]}]}]}]}"#;
12852 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12853 let md = adf_to_markdown(&doc).unwrap();
12854 assert!(
12855 md.contains("localId=li-001"),
12856 "listItem should have localId in md: {md}"
12857 );
12858 let rt = markdown_to_adf(&md).unwrap();
12860 let list = &rt.content[0];
12861 assert!(
12862 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12863 "bulletList should NOT have localId: {:?}",
12864 list.attrs
12865 );
12866 let item = &list.content.as_ref().unwrap()[0];
12867 assert_eq!(
12868 item.attrs.as_ref().unwrap()["localId"],
12869 "li-001",
12870 "listItem should have localId=li-001"
12871 );
12872 }
12873
12874 #[test]
12875 fn list_item_localid_not_promoted_to_parent() {
12876 let md = "- item {localId=li-002}\n";
12878 let doc = markdown_to_adf(md).unwrap();
12879 let list = &doc.content[0];
12880 assert!(
12881 list.attrs.is_none(),
12882 "bulletList should have no attrs: {:?}",
12883 list.attrs
12884 );
12885 let item = &list.content.as_ref().unwrap()[0];
12886 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12887 }
12888
12889 #[test]
12890 fn ordered_list_item_localid_roundtrip() {
12891 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"}]}]}]}]}"#;
12892 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12893 let md = adf_to_markdown(&doc).unwrap();
12894 assert!(md.contains("localId=oli-001"), "md: {md}");
12895 let rt = markdown_to_adf(&md).unwrap();
12896 let item = &rt.content[0].content.as_ref().unwrap()[0];
12897 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12898 }
12899
12900 #[test]
12901 fn task_item_localid_roundtrip() {
12902 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"}]}]}]}]}"#;
12903 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12904 let md = adf_to_markdown(&doc).unwrap();
12905 assert!(md.contains("localId=ti-001"), "md: {md}");
12906 let rt = markdown_to_adf(&md).unwrap();
12907 let item = &rt.content[0].content.as_ref().unwrap()[0];
12908 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
12909 }
12910
12911 #[test]
12914 fn task_list_short_localid_roundtrip() {
12915 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"}]}]}]}"#;
12916 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12917 let md = adf_to_markdown(&doc).unwrap();
12918 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12920 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
12921 assert!(
12923 !md.contains("localId=}"),
12924 "empty localId should not be emitted: {md}"
12925 );
12926 let rt = markdown_to_adf(&md).unwrap();
12927 let task_list = &rt.content[0];
12928 assert_eq!(task_list.node_type, "taskList");
12929 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
12931 let items = task_list.content.as_ref().unwrap();
12932 assert_eq!(items.len(), 2);
12933 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12935 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
12936 assert!(
12937 items[0].content.is_none(),
12938 "empty taskItem should have no content: {:?}",
12939 items[0].content
12940 );
12941 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
12943 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
12944 let content = items[1].content.as_ref().unwrap();
12945 assert_eq!(content.len(), 1);
12946 assert_eq!(content[0].text.as_deref(), Some("done task"));
12947 }
12948
12949 #[test]
12953 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
12954 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!!)"}]}]}]}]}"#;
12955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956 let md = adf_to_markdown(&doc).unwrap();
12957 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12959 let rt = markdown_to_adf(&md).unwrap();
12961 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
12962 let task_list = &rt.content[0];
12963 assert_eq!(task_list.node_type, "taskList");
12964 let items = task_list.content.as_ref().unwrap();
12965 assert_eq!(items.len(), 1);
12966 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12968 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
12969 let para = &items[0].content.as_ref().unwrap()[0];
12971 assert_eq!(para.node_type, "paragraph");
12972 let inlines = para.content.as_ref().unwrap();
12973 assert_eq!(inlines[0].node_type, "text");
12974 assert_eq!(
12975 inlines[0].text.as_deref(),
12976 Some("Engineering Onboarding Link")
12977 );
12978 assert_eq!(inlines[1].node_type, "hardBreak");
12979 assert_eq!(inlines[2].node_type, "text");
12980 assert_eq!(
12981 inlines[2].text.as_deref(),
12982 Some("(This has links to all the various useful tools!!)")
12983 );
12984 let rt_json = serde_json::to_string(&rt).unwrap();
12986 assert!(
12987 !rt_json.contains("{localId="),
12988 "localId attr syntax should not leak into ADF text: {rt_json}"
12989 );
12990 }
12991
12992 #[test]
12994 fn task_item_multiple_hardbreak_localids_roundtrip() {
12995 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"}]}]}]}]}"#;
12996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12997 let md = adf_to_markdown(&doc).unwrap();
12998 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12999 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13000 let rt = markdown_to_adf(&md).unwrap();
13001 let items = rt.content[0].content.as_ref().unwrap();
13002 assert_eq!(items.len(), 2);
13003 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13004 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13005 for item in items {
13007 let para = &item.content.as_ref().unwrap()[0];
13008 assert_eq!(para.node_type, "paragraph");
13009 let inlines = para.content.as_ref().unwrap();
13010 assert_eq!(inlines[1].node_type, "hardBreak");
13011 }
13012 }
13013
13014 #[test]
13019 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13020 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"}]}]}]}"#;
13021 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13022 let md = adf_to_markdown(&doc).unwrap();
13023 assert!(
13025 md.contains(" (parenthetical"),
13026 "continuation line should be 2-space indented: {md}"
13027 );
13028 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13029 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13030 let rt = markdown_to_adf(&md).unwrap();
13031 assert_eq!(
13033 rt.content.len(),
13034 1,
13035 "should be one taskList: {:#?}",
13036 rt.content
13037 );
13038 assert_eq!(rt.content[0].node_type, "taskList");
13039 let items = rt.content[0].content.as_ref().unwrap();
13040 assert_eq!(items.len(), 2, "should have 2 taskItems");
13041 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13042 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13043 let first_content = items[0].content.as_ref().unwrap();
13045 assert!(
13046 first_content.iter().any(|n| n.node_type == "hardBreak"),
13047 "first item should contain hardBreak"
13048 );
13049 let second_content = items[1].content.as_ref().unwrap();
13051 assert_eq!(second_content[0].node_type, "text");
13052 assert_eq!(
13053 second_content[0].text.as_deref().unwrap(),
13054 "second task item"
13055 );
13056 }
13057
13058 #[test]
13061 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13062 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"}]}]}]}]}"#;
13063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13064 let md = adf_to_markdown(&doc).unwrap();
13065 let rt = markdown_to_adf(&md).unwrap();
13066 assert_eq!(
13067 rt.content.len(),
13068 1,
13069 "should be one taskList: {:#?}",
13070 rt.content
13071 );
13072 let items = rt.content[0].content.as_ref().unwrap();
13073 assert_eq!(items.len(), 2);
13074 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13075 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13076 }
13077
13078 #[test]
13081 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13082 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"}]}]}]}"#;
13083 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13084 let md = adf_to_markdown(&doc).unwrap();
13085 let rt = markdown_to_adf(&md).unwrap();
13086 assert_eq!(rt.content.len(), 1);
13087 let items = rt.content[0].content.as_ref().unwrap();
13088 assert_eq!(items.len(), 3);
13089 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13090 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13091 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13092 let mid_content = items[1].content.as_ref().unwrap();
13094 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13095 }
13096
13097 #[test]
13100 fn task_list_empty_localid_no_spurious_paragraph() {
13101 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"}]}]}]}"#;
13102 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13103 let md = adf_to_markdown(&doc).unwrap();
13104 assert!(
13105 !md.contains("{localId=}"),
13106 "empty localId should not be emitted: {md}"
13107 );
13108 let rt = markdown_to_adf(&md).unwrap();
13109 assert_eq!(
13110 rt.content.len(),
13111 1,
13112 "no spurious paragraph: {:#?}",
13113 rt.content
13114 );
13115 assert_eq!(rt.content[0].node_type, "taskList");
13116 }
13117
13118 #[test]
13120 fn task_list_localid_stripped() {
13121 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"}]}]}]}]}"#;
13122 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13123 let opts = RenderOptions {
13124 strip_local_ids: true,
13125 };
13126 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13127 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13128 }
13129
13130 #[test]
13132 fn task_item_no_content_emits_localid() {
13133 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"}}]}]}"#;
13134 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13135 let md = adf_to_markdown(&doc).unwrap();
13136 assert!(
13137 md.contains("localId=abc"),
13138 "localId should be emitted even without content: {md}"
13139 );
13140 let rt = markdown_to_adf(&md).unwrap();
13141 let item = &rt.content[0].content.as_ref().unwrap()[0];
13142 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13143 assert!(item.content.is_none(), "should have no content");
13144 }
13145
13146 #[test]
13148 fn task_list_localid_roundtrip() {
13149 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"}]}]}]}]}"#;
13150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13151 let md = adf_to_markdown(&doc).unwrap();
13152 assert!(
13153 md.contains("localId=tl-xyz"),
13154 "taskList localId missing: {md}"
13155 );
13156 let rt = markdown_to_adf(&md).unwrap();
13157 assert_eq!(
13158 rt.content[0].attrs.as_ref().unwrap()["localId"],
13159 "tl-xyz",
13160 "taskList localId should survive round-trip"
13161 );
13162 }
13163
13164 #[test]
13166 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13167 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"}]}]}]}]}"#;
13168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13169 let md = adf_to_markdown(&doc).unwrap();
13170 assert!(
13171 md.contains("paraLocalId=_"),
13172 "should emit paraLocalId=_ sentinel: {md}"
13173 );
13174 let rt = markdown_to_adf(&md).unwrap();
13175 let item = &rt.content[0].content.as_ref().unwrap()[0];
13176 let content = item.content.as_ref().unwrap();
13177 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13178 assert_eq!(
13179 content[0].node_type, "paragraph",
13180 "child should be a paragraph: {content:#?}"
13181 );
13182 let para_content = content[0].content.as_ref().unwrap();
13183 assert_eq!(
13184 para_content[0].text.as_deref(),
13185 Some("A task with paragraph wrapper")
13186 );
13187 assert!(
13189 content[0].attrs.is_none(),
13190 "paragraph should have no attrs: {:?}",
13191 content[0].attrs
13192 );
13193 }
13194
13195 #[test]
13197 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13198 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"}]}]}]}]}"#;
13199 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13200 let md = adf_to_markdown(&doc).unwrap();
13201 assert!(
13202 md.contains("paraLocalId=p-001"),
13203 "should emit paraLocalId=p-001: {md}"
13204 );
13205 let rt = markdown_to_adf(&md).unwrap();
13206 let item = &rt.content[0].content.as_ref().unwrap()[0];
13207 let content = item.content.as_ref().unwrap();
13208 assert_eq!(content[0].node_type, "paragraph");
13209 assert_eq!(
13210 content[0].attrs.as_ref().unwrap()["localId"],
13211 "p-001",
13212 "paragraph localId should be preserved"
13213 );
13214 }
13215
13216 #[test]
13218 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13219 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"}]}]}]}"#;
13220 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13221 let md = adf_to_markdown(&doc).unwrap();
13222 assert!(
13223 !md.contains("paraLocalId"),
13224 "should NOT emit paraLocalId for unwrapped inline: {md}"
13225 );
13226 let rt = markdown_to_adf(&md).unwrap();
13227 let item = &rt.content[0].content.as_ref().unwrap()[0];
13228 let content = item.content.as_ref().unwrap();
13229 assert_eq!(
13230 content[0].node_type, "text",
13231 "should remain unwrapped: {content:#?}"
13232 );
13233 }
13234
13235 #[test]
13237 fn task_item_done_paragraph_wrapper_roundtrip() {
13238 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"}]}]}]}]}"#;
13239 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13240 let md = adf_to_markdown(&doc).unwrap();
13241 assert!(md.contains("- [x]"), "should render as done: {md}");
13242 let rt = markdown_to_adf(&md).unwrap();
13243 let item = &rt.content[0].content.as_ref().unwrap()[0];
13244 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13245 let content = item.content.as_ref().unwrap();
13246 assert_eq!(content[0].node_type, "paragraph");
13247 }
13248
13249 #[test]
13251 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13252 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"}]}]}]}"#;
13253 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13254 let md = adf_to_markdown(&doc).unwrap();
13255 let rt = markdown_to_adf(&md).unwrap();
13256 let items = rt.content[0].content.as_ref().unwrap();
13257 assert_eq!(items.len(), 2);
13258 let c1 = items[0].content.as_ref().unwrap();
13260 assert_eq!(
13261 c1[0].node_type, "paragraph",
13262 "first item should have paragraph wrapper"
13263 );
13264 let c2 = items[1].content.as_ref().unwrap();
13266 assert_eq!(
13267 c2[0].node_type, "text",
13268 "second item should remain unwrapped"
13269 );
13270 }
13271
13272 #[test]
13274 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13275 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"}]}]}]}]}]}"#;
13276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13277 let md = adf_to_markdown(&doc).unwrap();
13278 let rt = markdown_to_adf(&md).unwrap();
13279 let item = &rt.content[0].content.as_ref().unwrap()[0];
13280 let content = item.content.as_ref().unwrap();
13281 assert_eq!(content[0].node_type, "paragraph");
13282 let para_children = content[0].content.as_ref().unwrap();
13283 assert!(
13284 para_children.len() >= 2,
13285 "paragraph should contain multiple inline nodes"
13286 );
13287 }
13288
13289 #[test]
13291 fn task_item_paragraph_wrapper_stripped_with_option() {
13292 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"}]}]}]}]}"#;
13293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13294 let opts = RenderOptions {
13295 strip_local_ids: true,
13296 };
13297 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13298 assert!(
13299 !md.contains("paraLocalId"),
13300 "paraLocalId should be stripped: {md}"
13301 );
13302 assert!(
13303 !md.contains("localId"),
13304 "all localIds should be stripped: {md}"
13305 );
13306 }
13307
13308 #[test]
13309 fn trailing_space_preserved_with_hex_localid() {
13310 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 "}]}]}]}]}"#;
13313 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13314 let md = adf_to_markdown(&doc).unwrap();
13315 let rt = markdown_to_adf(&md).unwrap();
13316 let item = &rt.content[0].content.as_ref().unwrap()[0];
13317 assert_eq!(
13318 item.attrs.as_ref().unwrap()["localId"],
13319 "aabb112233cc",
13320 "localId should round-trip"
13321 );
13322 let para = &item.content.as_ref().unwrap()[0];
13323 let inlines = para.content.as_ref().unwrap();
13324 let last = inlines.last().unwrap();
13325 assert!(
13326 last.text.as_deref().unwrap_or("").ends_with(' '),
13327 "trailing space should be preserved, got nodes: {:?}",
13328 inlines
13329 .iter()
13330 .map(|n| (&n.node_type, &n.text))
13331 .collect::<Vec<_>>()
13332 );
13333 }
13334
13335 #[test]
13336 fn extract_trailing_local_id_preserves_trailing_space() {
13337 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13339 assert_eq!(before, "trailing space ");
13340 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13341 }
13342
13343 #[test]
13344 fn extract_trailing_local_id_no_trailing_space() {
13345 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13346 assert_eq!(before, "text");
13347 assert_eq!(lid.as_deref(), Some("abc123"));
13348 }
13349
13350 #[test]
13351 fn extract_trailing_local_id_no_attrs() {
13352 let (before, lid, pid) = extract_trailing_local_id("plain text");
13353 assert_eq!(before, "plain text");
13354 assert!(lid.is_none());
13355 assert!(pid.is_none());
13356 }
13357
13358 #[test]
13359 fn list_item_localid_stripped() {
13360 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"}]}]}]}]}"#;
13361 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13362 let opts = RenderOptions {
13363 strip_local_ids: true,
13364 };
13365 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13366 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13367 }
13368
13369 #[test]
13370 fn paragraph_localid_in_list_item_roundtrip() {
13371 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"}]}]}]}]}"#;
13373 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13374 let md = adf_to_markdown(&doc).unwrap();
13375 assert!(
13376 md.contains("paraLocalId=para-001"),
13377 "paragraph localId should be in md: {md}"
13378 );
13379 let rt = markdown_to_adf(&md).unwrap();
13380 let item = &rt.content[0].content.as_ref().unwrap()[0];
13381 assert_eq!(
13382 item.attrs.as_ref().unwrap()["localId"],
13383 "item-001",
13384 "listItem localId should survive"
13385 );
13386 let para = &item.content.as_ref().unwrap()[0];
13387 assert_eq!(
13388 para.attrs.as_ref().unwrap()["localId"],
13389 "para-001",
13390 "paragraph localId should survive round-trip"
13391 );
13392 }
13393
13394 #[test]
13395 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13396 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"}]}]}]}]}"#;
13398 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13399 let md = adf_to_markdown(&doc).unwrap();
13400 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13401 let rt = markdown_to_adf(&md).unwrap();
13402 let item = &rt.content[0].content.as_ref().unwrap()[0];
13403 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13404 let para = &item.content.as_ref().unwrap()[0];
13405 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13406 }
13407
13408 #[test]
13409 fn paragraph_localid_only_in_list_item() {
13410 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"}]}]}]}]}"#;
13412 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13413 let md = adf_to_markdown(&doc).unwrap();
13414 assert!(
13415 md.contains("paraLocalId=para-only"),
13416 "paragraph localId should be emitted: {md}"
13417 );
13418 let rt = markdown_to_adf(&md).unwrap();
13419 let item = &rt.content[0].content.as_ref().unwrap()[0];
13420 assert!(item.attrs.is_none(), "listItem should have no attrs");
13421 let para = &item.content.as_ref().unwrap()[0];
13422 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13423 }
13424
13425 #[test]
13426 fn paragraph_localid_in_table_header_roundtrip() {
13427 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"}]}]}]}]}]}"#;
13429 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13430 let md = adf_to_markdown(&doc).unwrap();
13431 assert!(
13433 md.contains("localId=aaaa-aaaa"),
13434 "paragraph localId should be in md: {md}"
13435 );
13436 let rt = markdown_to_adf(&md).unwrap();
13437 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13438 .content
13439 .as_ref()
13440 .unwrap()[0];
13441 let para = &cell.content.as_ref().unwrap()[0];
13442 assert_eq!(
13443 para.attrs.as_ref().unwrap()["localId"],
13444 "aaaa-aaaa",
13445 "paragraph localId should survive round-trip in tableHeader"
13446 );
13447 }
13448
13449 #[test]
13450 fn paragraph_localid_in_table_cell_roundtrip() {
13451 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"}]}]}]}]}]}"#;
13453 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13454 let md = adf_to_markdown(&doc).unwrap();
13455 assert!(
13456 md.contains("localId=cell-para"),
13457 "paragraph localId should be in md: {md}"
13458 );
13459 let rt = markdown_to_adf(&md).unwrap();
13460 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13462 .content
13463 .as_ref()
13464 .unwrap()[0];
13465 let para = &cell.content.as_ref().unwrap()[0];
13466 assert_eq!(
13467 para.attrs.as_ref().unwrap()["localId"],
13468 "cell-para",
13469 "paragraph localId should survive round-trip in tableCell"
13470 );
13471 }
13472
13473 #[test]
13474 fn nbsp_paragraph_with_localid_roundtrip() {
13475 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13478 let md = adf_to_markdown(&doc).unwrap();
13479 assert!(
13480 md.contains("::paragraph["),
13481 "nbsp should use directive form: {md}"
13482 );
13483 assert!(
13484 md.contains("localId=nbsp-para"),
13485 "localId should be in directive: {md}"
13486 );
13487 let rt = markdown_to_adf(&md).unwrap();
13488 let para = &rt.content[0];
13489 assert_eq!(
13490 para.attrs.as_ref().unwrap()["localId"],
13491 "nbsp-para",
13492 "localId should survive round-trip"
13493 );
13494 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13495 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13496 }
13497
13498 #[test]
13499 fn empty_paragraph_with_localid_roundtrip() {
13500 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13502 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13503 let md = adf_to_markdown(&doc).unwrap();
13504 assert!(
13505 md.contains("::paragraph{localId=empty-para}"),
13506 "empty paragraph should include localId in directive: {md}"
13507 );
13508 let rt = markdown_to_adf(&md).unwrap();
13509 assert_eq!(
13510 rt.content[0].attrs.as_ref().unwrap()["localId"],
13511 "empty-para"
13512 );
13513 }
13514
13515 #[test]
13516 fn paragraph_localid_stripped_from_list_item() {
13517 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"}]}]}]}]}"#;
13519 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13520 let opts = RenderOptions {
13521 strip_local_ids: true,
13522 };
13523 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13524 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13525 assert!(
13526 !md.contains("paraLocalId"),
13527 "paraLocalId should be stripped: {md}"
13528 );
13529 }
13530
13531 #[test]
13532 fn date_directive() {
13533 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13534 let content = doc.content[0].content.as_ref().unwrap();
13535 assert_eq!(content[1].node_type, "date");
13536 assert_eq!(
13538 content[1].attrs.as_ref().unwrap()["timestamp"],
13539 "1776211200000"
13540 );
13541 }
13542
13543 #[test]
13544 fn adf_date_to_markdown() {
13545 let doc = AdfDocument {
13547 version: 1,
13548 doc_type: "doc".to_string(),
13549 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13550 };
13551 let md = adf_to_markdown(&doc).unwrap();
13552 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13553 }
13554
13555 #[test]
13556 fn adf_date_iso_passthrough() {
13557 let doc = AdfDocument {
13559 version: 1,
13560 doc_type: "doc".to_string(),
13561 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13562 };
13563 let md = adf_to_markdown(&doc).unwrap();
13564 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13565 }
13566
13567 #[test]
13568 fn round_trip_date() {
13569 let md = "Due by :date[2026-04-15].\n";
13570 let doc = markdown_to_adf(md).unwrap();
13571 let result = adf_to_markdown(&doc).unwrap();
13572 assert!(result.contains(":date[2026-04-15]"));
13573 }
13574
13575 #[test]
13576 fn round_trip_date_non_midnight_timestamp() {
13577 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13579 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13580 let md = adf_to_markdown(&doc).unwrap();
13581 assert!(
13583 md.contains("timestamp=1700000000000"),
13584 "JFM should preserve original timestamp: {md}"
13585 );
13586 let doc2 = markdown_to_adf(&md).unwrap();
13588 let content = doc2.content[0].content.as_ref().unwrap();
13589 assert_eq!(
13590 content[0].attrs.as_ref().unwrap()["timestamp"],
13591 "1700000000000",
13592 "Round-trip must preserve original non-midnight timestamp"
13593 );
13594 }
13595
13596 #[test]
13597 fn date_epoch_ms_passthrough() {
13598 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13600 let content = doc.content[0].content.as_ref().unwrap();
13601 assert_eq!(
13602 content[1].attrs.as_ref().unwrap()["timestamp"],
13603 "1776211200000"
13604 );
13605 }
13606
13607 #[test]
13608 fn date_timestamp_attr_preferred_over_content() {
13609 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13611 let doc = markdown_to_adf(md).unwrap();
13612 let content = doc.content[0].content.as_ref().unwrap();
13613 assert_eq!(
13614 content[0].attrs.as_ref().unwrap()["timestamp"],
13615 "1700000000000",
13616 "timestamp attr should be used directly"
13617 );
13618 }
13619
13620 #[test]
13621 fn date_without_timestamp_attr_backward_compat() {
13622 let md = ":date[2026-04-15]\n";
13624 let doc = markdown_to_adf(md).unwrap();
13625 let content = doc.content[0].content.as_ref().unwrap();
13626 assert_eq!(
13627 content[0].attrs.as_ref().unwrap()["timestamp"],
13628 "1776211200000",
13629 "Should fall back to computing timestamp from date string"
13630 );
13631 }
13632
13633 #[test]
13634 fn date_with_local_id_and_timestamp() {
13635 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13637 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13638 let md = adf_to_markdown(&doc).unwrap();
13639 assert!(
13640 md.contains("timestamp=1700000000000"),
13641 "Should contain timestamp: {md}"
13642 );
13643 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13644 let doc2 = markdown_to_adf(&md).unwrap();
13646 let content = doc2.content[0].content.as_ref().unwrap();
13647 let attrs = content[0].attrs.as_ref().unwrap();
13648 assert_eq!(attrs["timestamp"], "1700000000000");
13649 assert_eq!(attrs["localId"], "d-001");
13650 }
13651
13652 #[test]
13653 fn mention_directive() {
13654 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13655 let content = doc.content[0].content.as_ref().unwrap();
13656 assert_eq!(content[1].node_type, "mention");
13657 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13658 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13659 }
13660
13661 #[test]
13662 fn adf_mention_to_markdown() {
13663 let doc = AdfDocument {
13664 version: 1,
13665 doc_type: "doc".to_string(),
13666 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13667 "abc123", "Alice",
13668 )])],
13669 };
13670 let md = adf_to_markdown(&doc).unwrap();
13671 assert!(md.contains(":mention[Alice]{id=abc123}"));
13672 }
13673
13674 #[test]
13675 fn round_trip_mention() {
13676 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13677 let doc = markdown_to_adf(md).unwrap();
13678 let result = adf_to_markdown(&doc).unwrap();
13679 assert!(result.contains(":mention[Alice]{id=abc123}"));
13680 }
13681
13682 #[test]
13683 fn mention_with_empty_access_level_round_trips() {
13684 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13686 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13687 ]}]}"#;
13688 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13689
13690 let md = adf_to_markdown(&doc).unwrap();
13691 let round_tripped = markdown_to_adf(&md).unwrap();
13692 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13693 assert_eq!(
13694 mention.node_type, "mention",
13695 "mention with empty accessLevel was not parsed as mention, got: {}",
13696 mention.node_type
13697 );
13698 }
13699
13700 #[test]
13701 fn span_with_color() {
13702 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13703 let content = doc.content[0].content.as_ref().unwrap();
13704 assert_eq!(content[1].node_type, "text");
13705 assert_eq!(content[1].text.as_deref(), Some("red text"));
13706 let marks = content[1].marks.as_ref().unwrap();
13707 assert_eq!(marks[0].mark_type, "textColor");
13708 }
13709
13710 #[test]
13711 fn adf_text_color_to_markdown() {
13712 let doc = AdfDocument {
13713 version: 1,
13714 doc_type: "doc".to_string(),
13715 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13716 "red text",
13717 vec![AdfMark::text_color("#ff5630")],
13718 )])],
13719 };
13720 let md = adf_to_markdown(&doc).unwrap();
13721 assert!(md.contains(":span[red text]{color=#ff5630}"));
13722 }
13723
13724 #[test]
13725 fn round_trip_span_color() {
13726 let md = "This is :span[red text]{color=#ff5630}.\n";
13727 let doc = markdown_to_adf(md).unwrap();
13728 let result = adf_to_markdown(&doc).unwrap();
13729 assert!(result.contains(":span[red text]{color=#ff5630}"));
13730 }
13731
13732 #[test]
13733 fn text_color_and_link_marks_both_preserved() {
13734 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13736 {"type":"text","text":"red link","marks":[
13737 {"type":"link","attrs":{"href":"https://example.com"}},
13738 {"type":"textColor","attrs":{"color":"#ff0000"}}
13739 ]}
13740 ]}]}"##;
13741 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13742 let md = adf_to_markdown(&doc).unwrap();
13743 assert!(
13744 md.contains(":span[red link]{color=#ff0000}"),
13745 "JFM should contain span with color, got: {md}"
13746 );
13747 assert!(
13748 md.contains("](https://example.com)"),
13749 "JFM should contain link href, got: {md}"
13750 );
13751 let rt = markdown_to_adf(&md).unwrap();
13753 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13754 let marks = text_node.marks.as_ref().expect("should have marks");
13755 assert!(
13756 marks.iter().any(|m| m.mark_type == "textColor"),
13757 "should have textColor mark, got: {:?}",
13758 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13759 );
13760 assert!(
13761 marks.iter().any(|m| m.mark_type == "link"),
13762 "should have link mark, got: {:?}",
13763 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13764 );
13765 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13767 assert_eq!(
13768 link_mark.attrs.as_ref().unwrap()["href"],
13769 "https://example.com"
13770 );
13771 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13772 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13773 }
13774
13775 #[test]
13776 fn bg_color_and_link_marks_both_preserved() {
13777 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13778 {"type":"text","text":"highlighted link","marks":[
13779 {"type":"link","attrs":{"href":"https://example.com"}},
13780 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13781 ]}
13782 ]}]}"##;
13783 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13784 let md = adf_to_markdown(&doc).unwrap();
13785 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13786 assert!(
13787 md.contains("](https://example.com)"),
13788 "should have link: {md}"
13789 );
13790 let rt = markdown_to_adf(&md).unwrap();
13791 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13792 let marks = text_node.marks.as_ref().expect("should have marks");
13793 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13794 assert!(marks.iter().any(|m| m.mark_type == "link"));
13795 }
13796
13797 #[test]
13798 fn text_color_link_and_strong_rendering() {
13799 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13801 {"type":"text","text":"bold red link","marks":[
13802 {"type":"strong"},
13803 {"type":"link","attrs":{"href":"https://example.com"}},
13804 {"type":"textColor","attrs":{"color":"#ff0000"}}
13805 ]}
13806 ]}]}"##;
13807 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13808 let md = adf_to_markdown(&doc).unwrap();
13809 assert!(
13810 md.starts_with("**") && md.trim().ends_with("**"),
13811 "should have bold wrapping: {md}"
13812 );
13813 assert!(md.contains("color=#ff0000"), "should have color: {md}");
13814 assert!(
13815 md.contains("](https://example.com)"),
13816 "should have link: {md}"
13817 );
13818 }
13819
13820 #[test]
13821 fn subsup_and_link_marks_both_preserved() {
13822 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13823 {"type":"text","text":"note","marks":[
13824 {"type":"link","attrs":{"href":"https://example.com"}},
13825 {"type":"subsup","attrs":{"type":"sup"}}
13826 ]}
13827 ]}]}"#;
13828 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13829 let md = adf_to_markdown(&doc).unwrap();
13830 assert!(md.contains("sup"), "should have sup: {md}");
13831 assert!(
13832 md.contains("](https://example.com)"),
13833 "should have link: {md}"
13834 );
13835 let rt = markdown_to_adf(&md).unwrap();
13836 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13837 let marks = text_node.marks.as_ref().expect("should have marks");
13838 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13839 assert!(marks.iter().any(|m| m.mark_type == "link"));
13840 }
13841
13842 #[test]
13843 fn text_color_without_link_unchanged() {
13844 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13846 {"type":"text","text":"just red","marks":[
13847 {"type":"textColor","attrs":{"color":"#ff0000"}}
13848 ]}
13849 ]}]}"##;
13850 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13851 let md = adf_to_markdown(&doc).unwrap();
13852 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13853 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13854 }
13855
13856 #[test]
13857 fn inline_extension_directive() {
13858 let doc =
13859 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13860 let content = doc.content[0].content.as_ref().unwrap();
13861 assert_eq!(content[1].node_type, "inlineExtension");
13862 assert_eq!(
13863 content[1].attrs.as_ref().unwrap()["extensionType"],
13864 "com.app"
13865 );
13866 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13867 }
13868
13869 #[test]
13870 fn adf_inline_extension_to_markdown() {
13871 let doc = AdfDocument {
13872 version: 1,
13873 doc_type: "doc".to_string(),
13874 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13875 "com.app",
13876 "widget",
13877 Some("fallback"),
13878 )])],
13879 };
13880 let md = adf_to_markdown(&doc).unwrap();
13881 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13882 }
13883
13884 #[test]
13887 fn parse_ordered_list_marker_valid() {
13888 let result = parse_ordered_list_marker("1. Hello");
13889 assert_eq!(result, Some((1, "Hello")));
13890 }
13891
13892 #[test]
13893 fn parse_ordered_list_marker_high_number() {
13894 let result = parse_ordered_list_marker("42. Item");
13895 assert_eq!(result, Some((42, "Item")));
13896 }
13897
13898 #[test]
13899 fn parse_ordered_list_marker_not_a_list() {
13900 assert!(parse_ordered_list_marker("not a list").is_none());
13901 assert!(parse_ordered_list_marker("1.no space").is_none());
13902 }
13903
13904 #[test]
13905 fn is_list_start_various() {
13906 assert!(is_list_start("- item"));
13907 assert!(is_list_start("* item"));
13908 assert!(is_list_start("+ item"));
13909 assert!(is_list_start("1. item"));
13910 assert!(!is_list_start("not a list"));
13911 }
13912
13913 #[test]
13914 fn is_horizontal_rule_various() {
13915 assert!(is_horizontal_rule("---"));
13916 assert!(is_horizontal_rule("***"));
13917 assert!(is_horizontal_rule("___"));
13918 assert!(is_horizontal_rule("------"));
13919 assert!(!is_horizontal_rule("--"));
13920 assert!(!is_horizontal_rule("abc"));
13921 }
13922
13923 #[test]
13924 fn is_table_separator_valid() {
13925 assert!(is_table_separator("| --- | --- |"));
13926 assert!(is_table_separator("|:---:|:---|"));
13927 assert!(!is_table_separator("no pipes here"));
13928 }
13929
13930 #[test]
13931 fn parse_table_row_cells() {
13932 let cells = parse_table_row("| A | B | C |");
13933 assert_eq!(cells, vec!["A", "B", "C"]);
13934 }
13935
13936 #[test]
13937 fn parse_table_row_escaped_pipe_in_cell() {
13938 let cells = parse_table_row(r"| a\|b | c |");
13940 assert_eq!(cells, vec!["a|b", "c"]);
13941 }
13942
13943 #[test]
13944 fn parse_table_row_escaped_pipe_in_code_span() {
13945 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
13947 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
13948 }
13949
13950 #[test]
13951 fn parse_table_row_preserves_other_backslashes() {
13952 let cells = parse_table_row(r"| a\\b | c\*d |");
13954 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
13955 }
13956
13957 #[test]
13958 fn parse_image_syntax_valid() {
13959 let result = parse_image_syntax("");
13960 assert_eq!(result, Some(("alt", "url")));
13961 }
13962
13963 #[test]
13964 fn parse_image_syntax_not_image() {
13965 assert!(parse_image_syntax("not an image").is_none());
13966 }
13967
13968 #[test]
13971 fn find_closing_paren_simple() {
13972 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
13973 }
13974
13975 #[test]
13976 fn find_closing_paren_nested() {
13977 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
13978 }
13979
13980 #[test]
13981 fn find_closing_paren_unmatched() {
13982 assert_eq!(find_closing_paren("(no close", 0), None);
13983 }
13984
13985 #[test]
13986 fn find_closing_paren_offset() {
13987 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
13989 }
13990
13991 #[test]
13994 fn try_parse_link_url_with_parens() {
13995 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
13996 let result = try_parse_link(input, 0);
13997 assert_eq!(
13998 result,
13999 Some((
14000 input.len(),
14001 "here",
14002 "https://example.com/faq#access-(permissions)-rest"
14003 ))
14004 );
14005 }
14006
14007 #[test]
14008 fn try_parse_link_url_no_parens() {
14009 let input = "[text](https://example.com)";
14010 let result = try_parse_link(input, 0);
14011 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14012 }
14013
14014 #[test]
14015 fn try_parse_link_url_with_multiple_nested_parens() {
14016 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14017 let result = try_parse_link(input, 0);
14018 assert_eq!(
14019 result,
14020 Some((
14021 input.len(),
14022 "x",
14023 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14024 ))
14025 );
14026 }
14027
14028 #[test]
14029 fn parse_image_syntax_url_with_parens() {
14030 let result = parse_image_syntax(")");
14031 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14032 }
14033
14034 #[test]
14035 fn parse_image_syntax_url_no_parens() {
14036 let result = parse_image_syntax("");
14037 assert_eq!(result, Some(("alt", "https://example.com")));
14038 }
14039
14040 #[test]
14041 fn link_with_parens_round_trip() {
14042 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14043 let mut text_node = AdfNode::text("here");
14044 text_node.marks = Some(vec![AdfMark::link(href)]);
14045 let adf_input = AdfDocument {
14046 version: 1,
14047 doc_type: "doc".to_string(),
14048 content: vec![AdfNode::paragraph(vec![text_node])],
14049 };
14050
14051 let jfm = adf_to_markdown(&adf_input).unwrap();
14052 let adf_output = markdown_to_adf(&jfm).unwrap();
14053
14054 let para = &adf_output.content[0];
14056 let text_node = ¶.content.as_ref().unwrap()[0];
14057 let mark = &text_node.marks.as_ref().unwrap()[0];
14058 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14059
14060 assert_eq!(result_href, href);
14061 }
14062
14063 #[test]
14064 fn flush_plain_empty_range() {
14065 let mut nodes = Vec::new();
14066 flush_plain("hello", 3, 3, &mut nodes);
14067 assert!(nodes.is_empty());
14068 }
14069
14070 #[test]
14071 fn add_mark_to_unmarked_node() {
14072 let mut node = AdfNode::text("test");
14073 add_mark(&mut node, AdfMark::strong());
14074 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14075 }
14076
14077 #[test]
14078 fn add_mark_to_marked_node() {
14079 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14080 add_mark(&mut node, AdfMark::em());
14081 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14082 }
14083
14084 #[test]
14087 fn directive_table_basic() {
14088 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";
14089 let doc = markdown_to_adf(md).unwrap();
14090 assert_eq!(doc.content[0].node_type, "table");
14091 let rows = doc.content[0].content.as_ref().unwrap();
14092 assert_eq!(rows.len(), 2);
14093 assert_eq!(
14094 rows[0].content.as_ref().unwrap()[0].node_type,
14095 "tableHeader"
14096 );
14097 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14098 }
14099
14100 #[test]
14101 fn directive_table_with_block_content() {
14102 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";
14103 let doc = markdown_to_adf(md).unwrap();
14104 let rows = doc.content[0].content.as_ref().unwrap();
14105 let cell = &rows[0].content.as_ref().unwrap()[0];
14106 let content = cell.content.as_ref().unwrap();
14108 assert!(content.len() >= 2);
14109 assert_eq!(content[1].node_type, "bulletList");
14110 }
14111
14112 #[test]
14113 fn directive_table_with_cell_attrs() {
14114 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14115 let doc = markdown_to_adf(md).unwrap();
14116 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14117 .content
14118 .as_ref()
14119 .unwrap()[0];
14120 let attrs = cell.attrs.as_ref().unwrap();
14121 assert_eq!(attrs["colspan"], 2);
14122 assert_eq!(attrs["background"], "#DEEBFF");
14123 }
14124
14125 #[test]
14126 fn directive_table_with_css_var_background() {
14127 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14128 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14129 let doc = markdown_to_adf(&md).unwrap();
14130 let row = &doc.content[0].content.as_ref().unwrap()[0];
14131 let cells = row.content.as_ref().unwrap();
14132 assert_eq!(cells.len(), 1, "row must have at least one cell");
14133 let attrs = cells[0].attrs.as_ref().unwrap();
14134 assert_eq!(attrs["background"], bg);
14135 }
14136
14137 #[test]
14138 fn css_var_background_round_trips() {
14139 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14140 let adf = AdfDocument {
14141 version: 1,
14142 doc_type: "doc".to_string(),
14143 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14144 AdfNode::table_header_with_attrs(
14145 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14146 serde_json::json!({"background": bg}),
14147 ),
14148 ])])],
14149 };
14150 let md = adf_to_markdown(&adf).unwrap();
14151 assert!(
14152 md.contains(&format!("bg=\"{bg}\"")),
14153 "bg value must be quoted in markdown: {md}"
14154 );
14155
14156 let round_tripped = markdown_to_adf(&md).unwrap();
14157 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14158 let cells = row.content.as_ref().unwrap();
14159 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14160 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14161 assert_eq!(rt_attrs["background"], bg);
14162 }
14163
14164 #[test]
14165 fn directive_table_with_table_attrs() {
14166 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14167 let doc = markdown_to_adf(md).unwrap();
14168 let attrs = doc.content[0].attrs.as_ref().unwrap();
14169 assert_eq!(attrs["layout"], "wide");
14170 assert_eq!(attrs["isNumberColumnEnabled"], true);
14171 }
14172
14173 #[test]
14174 fn adf_table_with_block_content_renders_directive_form() {
14175 let doc = AdfDocument {
14177 version: 1,
14178 doc_type: "doc".to_string(),
14179 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14180 AdfNode::table_cell(vec![
14181 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14182 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14183 AdfNode::text("Item 1"),
14184 ])])]),
14185 ]),
14186 ])])],
14187 };
14188 let md = adf_to_markdown(&doc).unwrap();
14189 assert!(md.contains("::::table"));
14190 assert!(md.contains(":::td"));
14191 assert!(md.contains("- Item 1"));
14192 }
14193
14194 #[test]
14195 fn adf_table_inline_only_renders_pipe_form() {
14196 let doc = AdfDocument {
14198 version: 1,
14199 doc_type: "doc".to_string(),
14200 content: vec![AdfNode::table(vec![
14201 AdfNode::table_row(vec![
14202 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14203 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14204 ]),
14205 AdfNode::table_row(vec![
14206 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14207 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14208 ]),
14209 ])],
14210 };
14211 let md = adf_to_markdown(&doc).unwrap();
14212 assert!(md.contains("| H1 | H2 |"));
14213 assert!(!md.contains("::::table"));
14214 }
14215
14216 #[test]
14217 fn adf_table_header_outside_first_row_renders_directive() {
14218 let doc = AdfDocument {
14219 version: 1,
14220 doc_type: "doc".to_string(),
14221 content: vec![AdfNode::table(vec![
14222 AdfNode::table_row(vec![
14223 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14224 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14225 ]),
14226 AdfNode::table_row(vec![
14227 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14228 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14229 ]),
14230 ])],
14231 };
14232 let md = adf_to_markdown(&doc).unwrap();
14233 assert!(md.contains("::::table"));
14234 assert!(md.contains(":::th"));
14235 }
14236
14237 #[test]
14238 fn adf_table_cell_attrs_rendered() {
14239 let doc = AdfDocument {
14240 version: 1,
14241 doc_type: "doc".to_string(),
14242 content: vec![AdfNode::table(vec![
14243 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14244 AdfNode::text("H"),
14245 ])])]),
14246 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14247 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14248 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14249 )]),
14250 ])],
14251 };
14252 let md = adf_to_markdown(&doc).unwrap();
14253 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14254 }
14255
14256 #[test]
14259 fn pipe_table_cell_attrs() {
14260 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14261 let doc = markdown_to_adf(md).unwrap();
14262 let rows = doc.content[0].content.as_ref().unwrap();
14263 let cell = &rows[1].content.as_ref().unwrap()[0];
14264 let attrs = cell.attrs.as_ref().unwrap();
14265 assert_eq!(attrs["background"], "#DEEBFF");
14266 }
14267
14268 #[test]
14269 fn pipe_table_cell_colspan() {
14270 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14271 let doc = markdown_to_adf(md).unwrap();
14272 let rows = doc.content[0].content.as_ref().unwrap();
14273 let cell = &rows[1].content.as_ref().unwrap()[0];
14274 let attrs = cell.attrs.as_ref().unwrap();
14275 assert_eq!(attrs["colspan"], 2);
14276 }
14277
14278 #[test]
14279 fn trailing_space_after_mention_in_table_cell_preserved() {
14280 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":[
14282 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14283 {"type":"text","text":" "}
14284 ]}]}]}]}]}"#;
14285 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14286 let md = adf_to_markdown(&doc).unwrap();
14287 let round_tripped = markdown_to_adf(&md).unwrap();
14288 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14289 .content
14290 .as_ref()
14291 .unwrap()[0];
14292 let para = &cell.content.as_ref().unwrap()[0];
14293 let inlines = para.content.as_ref().unwrap();
14294 assert!(
14295 inlines.len() >= 2,
14296 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14297 inlines.len(),
14298 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14299 );
14300 assert_eq!(inlines[0].node_type, "mention");
14301 assert_eq!(inlines[1].node_type, "text");
14302 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14303 }
14304
14305 #[test]
14308 fn pipe_table_column_alignment() {
14309 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14310 let doc = markdown_to_adf(md).unwrap();
14311 let rows = doc.content[0].content.as_ref().unwrap();
14312 let h_cells = rows[0].content.as_ref().unwrap();
14314 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14316 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14318 .marks
14319 .as_ref()
14320 .unwrap();
14321 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14322 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14324 .marks
14325 .as_ref()
14326 .unwrap();
14327 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14328 }
14329
14330 #[test]
14331 fn adf_table_alignment_roundtrip() {
14332 let doc = AdfDocument {
14333 version: 1,
14334 doc_type: "doc".to_string(),
14335 content: vec![AdfNode::table(vec![
14336 AdfNode::table_row(vec![
14337 AdfNode::table_header(vec![{
14338 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14339 p.marks = Some(vec![AdfMark::alignment("center")]);
14340 p
14341 }]),
14342 AdfNode::table_header(vec![{
14343 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14344 p.marks = Some(vec![AdfMark::alignment("end")]);
14345 p
14346 }]),
14347 ]),
14348 AdfNode::table_row(vec![
14349 AdfNode::table_cell(vec![{
14350 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14351 p.marks = Some(vec![AdfMark::alignment("center")]);
14352 p
14353 }]),
14354 AdfNode::table_cell(vec![{
14355 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14356 p.marks = Some(vec![AdfMark::alignment("end")]);
14357 p
14358 }]),
14359 ]),
14360 ])],
14361 };
14362 let md = adf_to_markdown(&doc).unwrap();
14363 assert!(md.contains(":---:"));
14364 assert!(md.contains("---:"));
14365 }
14366
14367 #[test]
14370 fn panel_custom_attrs_round_trip() {
14371 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14372 let doc = markdown_to_adf(md).unwrap();
14373 let panel = &doc.content[0];
14374 let attrs = panel.attrs.as_ref().unwrap();
14375 assert_eq!(attrs["panelType"], "custom");
14376 assert_eq!(attrs["panelIcon"], ":star:");
14377 assert_eq!(attrs["panelColor"], "#DEEBFF");
14378
14379 let result = adf_to_markdown(&doc).unwrap();
14380 assert!(result.contains("type=custom"));
14381 assert!(result.contains("icon="));
14382 assert!(result.contains("color="));
14383 }
14384
14385 #[test]
14388 fn block_card_with_layout() {
14389 let md = "::card[https://example.com]{layout=wide}\n";
14390 let doc = markdown_to_adf(md).unwrap();
14391 let attrs = doc.content[0].attrs.as_ref().unwrap();
14392 assert_eq!(attrs["layout"], "wide");
14393
14394 let result = adf_to_markdown(&doc).unwrap();
14395 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14396 }
14397
14398 #[test]
14401 fn extension_with_params() {
14402 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14403 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14404 let attrs = doc.content[0].attrs.as_ref().unwrap();
14405 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14406 }
14407
14408 #[test]
14409 fn leaf_extension_layout_preserved_in_roundtrip() {
14410 let adf_json = r#"{"version":1,"type":"doc","content":[
14412 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14413 ]}"#;
14414 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14415 let md = adf_to_markdown(&doc).unwrap();
14416 assert!(
14417 md.contains("layout=default"),
14418 "JFM should contain layout=default, got: {md}"
14419 );
14420 let round_tripped = markdown_to_adf(&md).unwrap();
14421 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14422 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14423 assert_eq!(attrs["extensionKey"], "toc");
14424 }
14425
14426 #[test]
14427 fn bodied_extension_layout_preserved_in_roundtrip() {
14428 let adf_json = r#"{"version":1,"type":"doc","content":[
14430 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14431 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14432 ]}"#;
14433 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14434 let md = adf_to_markdown(&doc).unwrap();
14435 assert!(
14436 md.contains("layout=wide"),
14437 "JFM should contain layout=wide, got: {md}"
14438 );
14439 let round_tripped = markdown_to_adf(&md).unwrap();
14440 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14441 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14442 }
14443
14444 #[test]
14445 fn bodied_extension_parameters_preserved_in_roundtrip() {
14446 let adf_json = r#"{"version":1,"type":"doc","content":[
14448 {"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":{}}},
14449 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14450 ]}"#;
14451 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14452 let md = adf_to_markdown(&doc).unwrap();
14453 assert!(
14454 md.contains("params="),
14455 "JFM should contain params attribute, got: {md}"
14456 );
14457 let round_tripped = markdown_to_adf(&md).unwrap();
14458 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14459 assert_eq!(
14460 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14461 "parameters should be preserved in round-trip"
14462 );
14463 assert_eq!(attrs["extensionKey"], "details");
14464 assert_eq!(attrs["layout"], "default");
14465 assert_eq!(attrs["localId"], "aabbccdd-1234");
14466 }
14467
14468 #[test]
14469 fn bodied_extension_malformed_params_ignored() {
14470 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14472 let doc = markdown_to_adf(md).unwrap();
14473 let attrs = doc.content[0].attrs.as_ref().unwrap();
14474 assert_eq!(attrs["extensionKey"], "details");
14475 assert!(attrs.get("parameters").is_none());
14477 }
14478
14479 #[test]
14480 fn leaf_extension_localid_preserved_in_roundtrip() {
14481 let adf_json = r#"{"version":1,"type":"doc","content":[
14483 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14484 ]}"#;
14485 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14486 let md = adf_to_markdown(&doc).unwrap();
14487 let round_tripped = markdown_to_adf(&md).unwrap();
14488 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14489 assert_eq!(attrs["layout"], "default");
14490 assert_eq!(attrs["localId"], "abc-123");
14491 }
14492
14493 #[test]
14496 fn mention_with_user_type() {
14497 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14498 let doc = markdown_to_adf(md).unwrap();
14499 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14500 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14501
14502 let result = adf_to_markdown(&doc).unwrap();
14503 assert!(result.contains("userType=DEFAULT"));
14504 }
14505
14506 #[test]
14509 fn directive_table_colwidth() {
14510 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14511 let doc = markdown_to_adf(md).unwrap();
14512 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14513 .content
14514 .as_ref()
14515 .unwrap()[0];
14516 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14517 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14518 }
14519
14520 #[test]
14521 fn directive_table_colwidth_float_roundtrip() {
14522 let adf_doc = serde_json::json!({
14525 "type": "doc",
14526 "version": 1,
14527 "content": [{
14528 "type": "table",
14529 "content": [{
14530 "type": "tableRow",
14531 "content": [
14532 {
14533 "type": "tableHeader",
14534 "attrs": { "colwidth": [157.0] },
14535 "content": [{ "type": "paragraph" }]
14536 },
14537 {
14538 "type": "tableHeader",
14539 "attrs": { "colwidth": [863.0] },
14540 "content": [{ "type": "paragraph" }]
14541 }
14542 ]
14543 }]
14544 }]
14545 });
14546 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14547 let md = adf_to_markdown(&doc).unwrap();
14548 assert!(
14549 md.contains("colwidth=157.0"),
14550 "expected colwidth=157.0 in markdown, got: {md}"
14551 );
14552 assert!(
14553 md.contains("colwidth=863.0"),
14554 "expected colwidth=863.0 in markdown, got: {md}"
14555 );
14556 let doc2 = markdown_to_adf(&md).unwrap();
14558 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14559 let header1 = &row.content.as_ref().unwrap()[0];
14560 let header2 = &row.content.as_ref().unwrap()[1];
14561 assert_eq!(
14562 header1.attrs.as_ref().unwrap()["colwidth"]
14563 .as_array()
14564 .unwrap(),
14565 &[serde_json::json!(157.0)]
14566 );
14567 assert_eq!(
14568 header2.attrs.as_ref().unwrap()["colwidth"]
14569 .as_array()
14570 .unwrap(),
14571 &[serde_json::json!(863.0)]
14572 );
14573 }
14574
14575 #[test]
14576 fn colwidth_float_preserved_in_roundtrip() {
14577 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":[]}]}]}]}]}"#;
14579 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14580 let md = adf_to_markdown(&doc).unwrap();
14581 let round_tripped = markdown_to_adf(&md).unwrap();
14582 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14583 .content
14584 .as_ref()
14585 .unwrap()[0];
14586 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14587 assert_eq!(
14588 colwidth,
14589 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14590 "colwidth should preserve float values"
14591 );
14592 }
14593
14594 #[test]
14595 fn colwidth_integer_preserved_in_roundtrip() {
14596 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"}]}]}]}]}]}"#;
14598 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14599 let md = adf_to_markdown(&doc).unwrap();
14600 assert!(
14601 md.contains("colwidth=150"),
14602 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14603 );
14604 assert!(
14605 !md.contains("colwidth=150.0"),
14606 "colwidth should not have .0 suffix for integers, got: {md}"
14607 );
14608 let round_tripped = markdown_to_adf(&md).unwrap();
14610 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14611 .content
14612 .as_ref()
14613 .unwrap()[0];
14614 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14615 assert_eq!(
14616 colwidth,
14617 &[serde_json::json!(150)],
14618 "colwidth should preserve integer values"
14619 );
14620 let json_output = serde_json::to_string(&round_tripped).unwrap();
14622 assert!(
14623 json_output.contains(r#""colwidth":[150]"#),
14624 "JSON should contain integer colwidth, got: {json_output}"
14625 );
14626 }
14627
14628 #[test]
14629 fn colwidth_mixed_int_and_float_roundtrip() {
14630 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14633 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14634
14635 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14637 let int_md = adf_to_markdown(&int_doc).unwrap();
14638 assert!(
14639 int_md.contains("colwidth=100,200"),
14640 "integer colwidth in md: {int_md}"
14641 );
14642 let int_rt = markdown_to_adf(&int_md).unwrap();
14643 let int_serial = serde_json::to_string(&int_rt).unwrap();
14644 assert!(
14645 int_serial.contains(r#""colwidth":[100,200]"#),
14646 "integer colwidth in JSON: {int_serial}"
14647 );
14648
14649 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14651 let float_md = adf_to_markdown(&float_doc).unwrap();
14652 assert!(
14653 float_md.contains("colwidth=100.0,200.0"),
14654 "float colwidth in md: {float_md}"
14655 );
14656 let float_rt = markdown_to_adf(&float_md).unwrap();
14657 let float_serial = serde_json::to_string(&float_rt).unwrap();
14658 assert!(
14659 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14660 "float colwidth in JSON: {float_serial}"
14661 );
14662 }
14663
14664 #[test]
14665 fn colwidth_fractional_float_preserved() {
14666 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"}]}]}]}]}]}"#;
14668 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14669 let md = adf_to_markdown(&doc).unwrap();
14670 assert!(
14671 md.contains("colwidth=100.5"),
14672 "expected colwidth=100.5 in markdown, got: {md}"
14673 );
14674 }
14675
14676 #[test]
14677 fn colwidth_non_numeric_values_skipped() {
14678 let adf_doc = serde_json::json!({
14680 "type": "doc",
14681 "version": 1,
14682 "content": [{
14683 "type": "table",
14684 "content": [{
14685 "type": "tableRow",
14686 "content": [{
14687 "type": "tableCell",
14688 "attrs": { "colwidth": ["invalid"] },
14689 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14690 }]
14691 }]
14692 }]
14693 });
14694 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14695 let md = adf_to_markdown(&doc).unwrap();
14696 assert!(
14698 !md.contains("colwidth"),
14699 "non-numeric colwidth should be filtered out, got: {md}"
14700 );
14701 }
14702
14703 #[test]
14704 fn default_rowspan_colspan_preserved_in_roundtrip() {
14705 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"}]}]}]}]}]}"#;
14707 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14708 let md = adf_to_markdown(&doc).unwrap();
14709 let round_tripped = markdown_to_adf(&md).unwrap();
14710 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14711 .content
14712 .as_ref()
14713 .unwrap()[0];
14714 let attrs = cell.attrs.as_ref().unwrap();
14715 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14716 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14717 }
14718
14719 #[test]
14722 fn table_localid_preserved_in_roundtrip() {
14723 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"}]}]}]}]}]}"#;
14725 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14726 let md = adf_to_markdown(&doc).unwrap();
14727 assert!(
14728 md.contains("localId="),
14729 "JFM should contain localId, got: {md}"
14730 );
14731 let round_tripped = markdown_to_adf(&md).unwrap();
14732 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14733 assert_eq!(
14734 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14735 "localId should be preserved"
14736 );
14737 }
14738
14739 #[test]
14740 fn paragraph_localid_preserved_in_roundtrip() {
14741 let adf_json = r#"{"version":1,"type":"doc","content":[
14743 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14744 ]}"#;
14745 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14746 let md = adf_to_markdown(&doc).unwrap();
14747 assert!(
14748 md.contains("localId=abc-123"),
14749 "JFM should contain localId, got: {md}"
14750 );
14751 let round_tripped = markdown_to_adf(&md).unwrap();
14752 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14753 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14754 }
14755
14756 #[test]
14757 fn heading_localid_preserved_in_roundtrip() {
14758 let adf_json = r#"{"version":1,"type":"doc","content":[
14759 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14760 ]}"#;
14761 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762 let md = adf_to_markdown(&doc).unwrap();
14763 let round_tripped = markdown_to_adf(&md).unwrap();
14764 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14765 assert_eq!(attrs["localId"], "h-456");
14766 }
14767
14768 #[test]
14769 fn localid_with_alignment_preserved() {
14770 let adf_json = r#"{"version":1,"type":"doc","content":[
14772 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14773 "content":[{"type":"text","text":"centered"}]}
14774 ]}"#;
14775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14776 let md = adf_to_markdown(&doc).unwrap();
14777 assert!(md.contains("localId=p-789"), "should have localId: {md}");
14778 assert!(md.contains("align=center"), "should have align: {md}");
14779 let round_tripped = markdown_to_adf(&md).unwrap();
14780 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14781 assert_eq!(attrs["localId"], "p-789");
14782 let marks = round_tripped.content[0].marks.as_ref().unwrap();
14783 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14784 }
14785
14786 #[test]
14787 fn table_layout_default_preserved_in_roundtrip() {
14788 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"}]}]}]}]}]}"#;
14790 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14791 let md = adf_to_markdown(&doc).unwrap();
14792 let round_tripped = markdown_to_adf(&md).unwrap();
14793 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14794 assert_eq!(
14795 attrs["layout"], "default",
14796 "layout='default' should be preserved"
14797 );
14798 }
14799
14800 #[test]
14801 fn table_is_number_column_enabled_false_preserved() {
14802 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"}]}]}]}]}]}"#;
14804 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14805 let md = adf_to_markdown(&doc).unwrap();
14806 let round_tripped = markdown_to_adf(&md).unwrap();
14807 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14808 assert_eq!(
14809 attrs["isNumberColumnEnabled"], false,
14810 "isNumberColumnEnabled=false should be preserved"
14811 );
14812 }
14813
14814 #[test]
14815 fn table_is_number_column_enabled_true_preserved() {
14816 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"}]}]}]}]}]}"#;
14818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14819 let md = adf_to_markdown(&doc).unwrap();
14820 let round_tripped = markdown_to_adf(&md).unwrap();
14821 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14822 assert_eq!(
14823 attrs["isNumberColumnEnabled"], true,
14824 "isNumberColumnEnabled=true should be preserved"
14825 );
14826 }
14827
14828 #[test]
14829 fn directive_table_is_number_column_enabled_false_preserved() {
14830 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14833 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14834 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14835 ]}]}]}]}"#;
14836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14837 let md = adf_to_markdown(&doc).unwrap();
14838 assert!(md.contains("::::table"), "should use directive table form");
14839 assert!(
14840 md.contains("numbered=false"),
14841 "should contain numbered=false, got: {md}"
14842 );
14843 let round_tripped = markdown_to_adf(&md).unwrap();
14844 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14845 assert_eq!(attrs["isNumberColumnEnabled"], false);
14846 assert_eq!(attrs["layout"], "default");
14847 }
14848
14849 #[test]
14850 fn directive_table_is_number_column_enabled_true_preserved() {
14851 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14853 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14854 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14855 ]}]}]}]}"#;
14856 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14857 let md = adf_to_markdown(&doc).unwrap();
14858 assert!(md.contains("::::table"), "should use directive table form");
14859 assert!(
14860 md.contains("numbered}") || md.contains("numbered "),
14861 "should contain numbered flag, got: {md}"
14862 );
14863 let round_tripped = markdown_to_adf(&md).unwrap();
14864 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14865 assert_eq!(attrs["isNumberColumnEnabled"], true);
14866 }
14867
14868 #[test]
14869 fn trailing_space_in_bullet_list_item_preserved() {
14870 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14872 {"type":"listItem","content":[{"type":"paragraph","content":[
14873 {"type":"text","text":"Before link "},
14874 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14875 {"type":"text","text":" "}
14876 ]}]}
14877 ]}]}"#;
14878 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14879 let md = adf_to_markdown(&doc).unwrap();
14880 let round_tripped = markdown_to_adf(&md).unwrap();
14881 let list = &round_tripped.content[0];
14882 let item = &list.content.as_ref().unwrap()[0];
14883 let para = &item.content.as_ref().unwrap()[0];
14884 let inlines = para.content.as_ref().unwrap();
14885 let last = inlines.last().unwrap();
14886 assert_eq!(
14887 last.text.as_deref(),
14888 Some(" "),
14889 "trailing space text node should be preserved, got nodes: {:?}",
14890 inlines
14891 .iter()
14892 .map(|n| (&n.node_type, &n.text))
14893 .collect::<Vec<_>>()
14894 );
14895 }
14896
14897 #[test]
14898 fn trailing_space_after_mention_in_bullet_list_preserved() {
14899 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14901 {"type":"listItem","content":[{"type":"paragraph","content":[
14902 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14903 {"type":"text","text":" "}
14904 ]}]}
14905 ]}]}"#;
14906 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14907 let md = adf_to_markdown(&doc).unwrap();
14908 let round_tripped = markdown_to_adf(&md).unwrap();
14909 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14910 .content
14911 .as_ref()
14912 .unwrap()[0];
14913 let inlines = para.content.as_ref().unwrap();
14914 assert!(
14915 inlines.len() >= 2,
14916 "should have mention + trailing space, got {} nodes",
14917 inlines.len()
14918 );
14919 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
14920 }
14921
14922 #[test]
14923 fn trailing_space_in_ordered_list_item_preserved() {
14924 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14926 {"type":"listItem","content":[{"type":"paragraph","content":[
14927 {"type":"text","text":"item "},
14928 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14929 {"type":"text","text":" "}
14930 ]}]}
14931 ]}]}"#;
14932 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14933 let md = adf_to_markdown(&doc).unwrap();
14934 let round_tripped = markdown_to_adf(&md).unwrap();
14935 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14936 .content
14937 .as_ref()
14938 .unwrap()[0];
14939 let inlines = para.content.as_ref().unwrap();
14940 let last = inlines.last().unwrap();
14941 assert_eq!(
14942 last.text.as_deref(),
14943 Some(" "),
14944 "trailing space should be preserved in ordered list item"
14945 );
14946 }
14947
14948 #[test]
14949 fn trailing_space_in_heading_text_preserved() {
14950 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
14952 {"type":"text","text":"Firefighting Engineers "}
14953 ]}]}"#;
14954 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14955 let md = adf_to_markdown(&doc).unwrap();
14956 let round_tripped = markdown_to_adf(&md).unwrap();
14957 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14958 assert_eq!(
14959 inlines[0].text.as_deref(),
14960 Some("Firefighting Engineers "),
14961 "trailing space in heading should be preserved"
14962 );
14963 }
14964
14965 #[test]
14966 fn trailing_space_in_heading_before_bold_preserved() {
14967 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14969 {"type":"text","text":"Classic "},
14970 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
14971 ]}]}"#;
14972 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14973 let md = adf_to_markdown(&doc).unwrap();
14974 let round_tripped = markdown_to_adf(&md).unwrap();
14975 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14976 assert_eq!(
14977 inlines[0].text.as_deref(),
14978 Some("Classic "),
14979 "trailing space in heading text before bold should be preserved"
14980 );
14981 }
14982
14983 #[test]
14984 fn leading_space_in_heading_text_preserved() {
14985 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
14987 {"type":"text","text":" #general-channel"}
14988 ]}]}"#;
14989 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14990 let md = adf_to_markdown(&doc).unwrap();
14991 let round_tripped = markdown_to_adf(&md).unwrap();
14992 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14993 assert_eq!(
14994 inlines[0].text.as_deref(),
14995 Some(" #general-channel"),
14996 "leading spaces in heading text should be preserved"
14997 );
14998 }
14999
15000 #[test]
15001 fn leading_space_in_heading_before_bold_preserved() {
15002 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15004 {"type":"text","text":" indented"},
15005 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15006 ]}]}"#;
15007 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15008 let md = adf_to_markdown(&doc).unwrap();
15009 let round_tripped = markdown_to_adf(&md).unwrap();
15010 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15011 assert_eq!(
15012 inlines[0].text.as_deref(),
15013 Some(" indented"),
15014 "leading spaces in heading text before bold should be preserved"
15015 );
15016 }
15017
15018 #[test]
15019 fn heading_multiple_leading_spaces_markdown_parse() {
15020 let md = "### \t #general-channel";
15022 let doc = markdown_to_adf(md).unwrap();
15023 let inlines = doc.content[0].content.as_ref().unwrap();
15024 assert_eq!(
15025 inlines[0].text.as_deref(),
15026 Some("\t #general-channel"),
15027 "leading whitespace in heading text should be preserved during JFM parsing"
15028 );
15029 }
15030
15031 #[test]
15032 fn trailing_space_in_paragraph_text_preserved() {
15033 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15035 {"type":"text","text":"word followed by space "},
15036 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15037 ]}]}"#;
15038 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15039 let md = adf_to_markdown(&doc).unwrap();
15040 let round_tripped = markdown_to_adf(&md).unwrap();
15041 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15042 assert_eq!(
15043 inlines[0].text.as_deref(),
15044 Some("word followed by space "),
15045 "trailing space in paragraph text should be preserved"
15046 );
15047 }
15048
15049 #[test]
15050 fn nested_bullet_list_roundtrip() {
15051 let adf_doc = serde_json::json!({
15053 "type": "doc",
15054 "version": 1,
15055 "content": [{
15056 "type": "bulletList",
15057 "content": [{
15058 "type": "listItem",
15059 "content": [
15060 {
15061 "type": "paragraph",
15062 "content": [{"type": "text", "text": "parent item"}]
15063 },
15064 {
15065 "type": "bulletList",
15066 "content": [
15067 {
15068 "type": "listItem",
15069 "content": [{
15070 "type": "paragraph",
15071 "content": [{"type": "text", "text": "sub item 1"}]
15072 }]
15073 },
15074 {
15075 "type": "listItem",
15076 "content": [{
15077 "type": "paragraph",
15078 "content": [{"type": "text", "text": "sub item 2"}]
15079 }]
15080 }
15081 ]
15082 }
15083 ]
15084 }]
15085 }]
15086 });
15087 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15088 let md = adf_to_markdown(&doc).unwrap();
15089 assert!(
15090 md.contains("- parent item\n"),
15091 "expected top-level item in markdown, got: {md}"
15092 );
15093 assert!(
15094 md.contains(" - sub item 1\n"),
15095 "expected indented sub item 1 in markdown, got: {md}"
15096 );
15097 assert!(
15098 md.contains(" - sub item 2\n"),
15099 "expected indented sub item 2 in markdown, got: {md}"
15100 );
15101
15102 let doc2 = markdown_to_adf(&md).unwrap();
15104 let list = &doc2.content[0];
15105 assert_eq!(list.node_type, "bulletList");
15106 let item = &list.content.as_ref().unwrap()[0];
15107 assert_eq!(item.node_type, "listItem");
15108 let item_content = item.content.as_ref().unwrap();
15109 assert_eq!(
15110 item_content.len(),
15111 2,
15112 "listItem should have paragraph + nested list"
15113 );
15114 assert_eq!(item_content[0].node_type, "paragraph");
15115 assert_eq!(item_content[1].node_type, "bulletList");
15116 let sub_items = item_content[1].content.as_ref().unwrap();
15117 assert_eq!(sub_items.len(), 2);
15118 }
15119
15120 #[test]
15121 fn nested_bullet_in_table_cell_roundtrip() {
15122 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15123 let doc = markdown_to_adf(md).unwrap();
15124 let table = &doc.content[0];
15125 let row = &table.content.as_ref().unwrap()[0];
15126 let cell = &row.content.as_ref().unwrap()[0];
15127 let list = &cell.content.as_ref().unwrap()[0];
15128 assert_eq!(list.node_type, "bulletList");
15129 let item = &list.content.as_ref().unwrap()[0];
15130 let item_content = item.content.as_ref().unwrap();
15131 assert_eq!(
15132 item_content.len(),
15133 2,
15134 "listItem should have paragraph + nested list"
15135 );
15136 assert_eq!(item_content[1].node_type, "bulletList");
15137
15138 let md2 = adf_to_markdown(&doc).unwrap();
15140 assert!(
15141 md2.contains(" - child"),
15142 "expected indented child in round-tripped markdown, got: {md2}"
15143 );
15144 }
15145
15146 #[test]
15147 fn nested_ordered_list_roundtrip() {
15148 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15150 {"type":"listItem","content":[
15151 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15152 {"type":"orderedList","attrs":{"order":1},"content":[
15153 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15154 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15155 ]}
15156 ]},
15157 {"type":"listItem","content":[
15158 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15159 ]}
15160 ]}]}"#;
15161 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15162 let md = adf_to_markdown(&doc).unwrap();
15163 let round_tripped = markdown_to_adf(&md).unwrap();
15164
15165 let outer = &round_tripped.content[0];
15167 assert_eq!(outer.node_type, "orderedList");
15168 assert_eq!(
15169 outer.attrs.as_ref().unwrap()["order"],
15170 1,
15171 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15172 );
15173 let outer_items = outer.content.as_ref().unwrap();
15174 assert_eq!(
15175 outer_items.len(),
15176 2,
15177 "outer list should have 2 items, got {}",
15178 outer_items.len()
15179 );
15180
15181 let first_item = &outer_items[0];
15183 let first_content = first_item.content.as_ref().unwrap();
15184 assert_eq!(
15185 first_content.len(),
15186 2,
15187 "first listItem should have paragraph + nested list, got {}",
15188 first_content.len()
15189 );
15190 assert_eq!(first_content[0].node_type, "paragraph");
15191 assert_eq!(first_content[1].node_type, "orderedList");
15192 let nested_items = first_content[1].content.as_ref().unwrap();
15193 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15194 }
15195
15196 #[test]
15197 fn nested_ordered_list_markdown_parsing() {
15198 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15200 let doc = markdown_to_adf(md).unwrap();
15201 let outer = &doc.content[0];
15202 assert_eq!(outer.node_type, "orderedList");
15203 let outer_items = outer.content.as_ref().unwrap();
15204 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15205
15206 let first_content = outer_items[0].content.as_ref().unwrap();
15207 assert_eq!(
15208 first_content.len(),
15209 2,
15210 "first item should have paragraph + nested list"
15211 );
15212 assert_eq!(first_content[1].node_type, "orderedList");
15213 }
15214
15215 #[test]
15216 fn bullet_list_nested_inside_ordered_list() {
15217 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15219 let doc = markdown_to_adf(md).unwrap();
15220 let outer = &doc.content[0];
15221 assert_eq!(outer.node_type, "orderedList");
15222 let outer_items = outer.content.as_ref().unwrap();
15223 assert_eq!(outer_items.len(), 2);
15224
15225 let first_content = outer_items[0].content.as_ref().unwrap();
15226 assert_eq!(
15227 first_content.len(),
15228 2,
15229 "first item should have paragraph + nested list"
15230 );
15231 assert_eq!(first_content[1].node_type, "bulletList");
15232 let sub_items = first_content[1].content.as_ref().unwrap();
15233 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15234 }
15235
15236 #[test]
15237 fn ordered_list_order_attr_one_is_elided() {
15238 let md = "1. A\n2. B\n";
15242 let doc = markdown_to_adf(md).unwrap();
15243 assert!(
15244 doc.content[0].attrs.is_none(),
15245 "attrs should be elided when order=1"
15246 );
15247
15248 let md2 = adf_to_markdown(&doc).unwrap();
15250 let doc2 = markdown_to_adf(&md2).unwrap();
15251 assert!(
15252 doc2.content[0].attrs.is_none(),
15253 "attrs should remain elided after round-trip"
15254 );
15255 }
15256
15257 #[test]
15258 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15259 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"}]}]}]}]}"#;
15262 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15263 let md = adf_to_markdown(&doc).unwrap();
15264 let rt = markdown_to_adf(&md).unwrap();
15265 assert!(
15266 rt.content[0].attrs.is_none(),
15267 "round-tripped orderedList should not have attrs, got: {:?}",
15268 rt.content[0].attrs
15269 );
15270
15271 let rt_json = serde_json::to_string(&rt).unwrap();
15273 assert!(
15274 !rt_json.contains("\"order\""),
15275 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15276 );
15277 }
15278
15279 fn assert_roundtrip_byte_identical(adf_json: &str) {
15285 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15286 let md = adf_to_markdown(&doc).unwrap();
15287 let rt = markdown_to_adf(&md).unwrap();
15288
15289 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15290 let canonical_rt: serde_json::Value =
15291 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15292 assert_eq!(
15293 canonical_src, canonical_rt,
15294 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15295 );
15296 }
15297
15298 #[test]
15299 fn issue_547_single_item_no_attrs_roundtrip() {
15300 assert_roundtrip_byte_identical(
15301 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15302 );
15303 }
15304
15305 #[test]
15306 fn issue_547_many_items_no_attrs_roundtrip() {
15307 assert_roundtrip_byte_identical(
15308 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"}]}]}]}]}"#,
15309 );
15310 }
15311
15312 #[test]
15313 fn issue_547_non_default_order_preserved() {
15314 assert_roundtrip_byte_identical(
15317 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15318 );
15319 }
15320
15321 #[test]
15322 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15323 assert_roundtrip_byte_identical(
15325 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"}]}]}]}]}]}]}"#,
15326 );
15327 }
15328
15329 #[test]
15330 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15331 assert_roundtrip_byte_identical(
15332 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"}]}]}]}]}]}]}"#,
15333 );
15334 }
15335
15336 #[test]
15337 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15338 assert_roundtrip_byte_identical(
15339 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"}]}]}]}]}]}]}"#,
15340 );
15341 }
15342
15343 #[test]
15344 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15345 assert_roundtrip_byte_identical(
15346 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"}]}]}"#,
15347 );
15348 }
15349
15350 #[test]
15351 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15352 assert_roundtrip_byte_identical(
15353 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15354 );
15355 }
15356
15357 #[test]
15358 fn issue_547_ordered_list_with_link_roundtrip() {
15359 assert_roundtrip_byte_identical(
15360 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"}}]}]}]}]}]}"#,
15361 );
15362 }
15363
15364 #[test]
15365 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15366 assert_roundtrip_byte_identical(
15367 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"}]}]}]}]}"#,
15368 );
15369 }
15370
15371 #[test]
15372 fn issue_547_triple_nested_ordered_roundtrip() {
15373 assert_roundtrip_byte_identical(
15374 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"}]}]}]}]}]}]}]}]}"#,
15375 );
15376 }
15377
15378 #[test]
15379 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15380 assert_roundtrip_byte_identical(
15381 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"}]}"#,
15382 );
15383 }
15384
15385 #[test]
15386 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15387 assert_roundtrip_byte_identical(
15389 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15390 );
15391 }
15392
15393 #[test]
15394 fn issue_547_explicit_order_one_preserved_roundtrip() {
15395 assert_roundtrip_byte_identical(
15400 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15401 );
15402 }
15403
15404 #[test]
15405 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15406 assert_roundtrip_byte_identical(
15409 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"}]}]}]}]}]}]}"#,
15410 );
15411 }
15412
15413 #[test]
15414 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15415 assert_roundtrip_byte_identical(
15418 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"}]}]}]}]}"#,
15419 );
15420 }
15421
15422 #[test]
15423 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15424 assert_roundtrip_byte_identical(
15428 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"}]}]}]}]}"#,
15429 );
15430 }
15431
15432 #[test]
15433 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15434 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15438 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"}]}]}]}]}"#;
15439 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"}]}]}]}]}"#;
15440
15441 let md_no =
15442 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15443 let md_one =
15444 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15445 let md_five =
15446 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15447
15448 assert!(
15449 !md_no.contains("{order="),
15450 "no-attrs source must not emit order signal, got: {md_no:?}"
15451 );
15452 assert!(
15453 md_one.contains("{order=1}"),
15454 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15455 );
15456 assert!(
15457 !md_five.contains("{order="),
15458 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15459 );
15460 }
15461
15462 #[test]
15465 fn file_media_roundtrip() {
15466 let adf_doc = serde_json::json!({
15468 "type": "doc",
15469 "version": 1,
15470 "content": [{
15471 "type": "mediaSingle",
15472 "attrs": {"layout": "center"},
15473 "content": [{
15474 "type": "media",
15475 "attrs": {
15476 "type": "file",
15477 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15478 "collection": "contentId-8220672100",
15479 "height": 56,
15480 "width": 312,
15481 "alt": "Screenshot.png"
15482 }
15483 }]
15484 }]
15485 });
15486 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15487 let md = adf_to_markdown(&doc).unwrap();
15488 assert!(
15489 md.contains("type=file"),
15490 "expected type=file in markdown, got: {md}"
15491 );
15492 assert!(
15493 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15494 "expected id in markdown, got: {md}"
15495 );
15496 assert!(
15497 md.contains("collection=contentId-8220672100"),
15498 "expected collection in markdown, got: {md}"
15499 );
15500 let doc2 = markdown_to_adf(&md).unwrap();
15502 let ms = &doc2.content[0];
15503 assert_eq!(ms.node_type, "mediaSingle");
15504 let media = &ms.content.as_ref().unwrap()[0];
15505 assert_eq!(media.node_type, "media");
15506 let attrs = media.attrs.as_ref().unwrap();
15507 assert_eq!(attrs["type"], "file");
15508 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15509 assert_eq!(attrs["collection"], "contentId-8220672100");
15510 assert_eq!(attrs["height"], 56);
15511 assert_eq!(attrs["width"], 312);
15512 assert_eq!(attrs["alt"], "Screenshot.png");
15513 }
15514
15515 #[test]
15519 fn file_media_roundtrip_issue_550_reproducer() {
15520 let adf_json = r#"{
15521 "version": 1,
15522 "type": "doc",
15523 "content": [
15524 {
15525 "type": "mediaSingle",
15526 "attrs": {"layout": "center"},
15527 "content": [
15528 {
15529 "type": "media",
15530 "attrs": {
15531 "type": "file",
15532 "id": "abc-123-def-456",
15533 "collection": "my-collection",
15534 "width": 941,
15535 "height": 655
15536 }
15537 }
15538 ]
15539 }
15540 ]
15541 }"#;
15542 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15543 let md = adf_to_markdown(&doc).unwrap();
15544 let rt = markdown_to_adf(&md).unwrap();
15545 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15546 let actual = serde_json::to_value(&rt).unwrap();
15547 assert_eq!(
15548 actual, expected,
15549 "roundtrip should preserve file media attrs; md was:\n{md}"
15550 );
15551 }
15552
15553 #[test]
15559 fn file_media_roundtrip_id_with_spaces() {
15560 let adf_json = r#"{
15561 "version": 1,
15562 "type": "doc",
15563 "content": [
15564 {
15565 "type": "mediaSingle",
15566 "attrs": {"layout": "center"},
15567 "content": [
15568 {
15569 "type": "media",
15570 "attrs": {
15571 "type": "file",
15572 "id": "abc 123 def 456",
15573 "collection": "my-collection",
15574 "width": 800,
15575 "height": 600
15576 }
15577 }
15578 ]
15579 }
15580 ]
15581 }"#;
15582 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15583 let md = adf_to_markdown(&doc).unwrap();
15584 assert!(
15585 md.contains(r#"id="abc 123 def 456""#),
15586 "id with spaces should be quoted in JFM, got:\n{md}"
15587 );
15588 let rt = markdown_to_adf(&md).unwrap();
15589 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15590 let actual = serde_json::to_value(&rt).unwrap();
15591 assert_eq!(
15592 actual, expected,
15593 "space-containing id must round-trip; md was:\n{md}"
15594 );
15595 }
15596
15597 #[test]
15599 fn file_media_roundtrip_collection_with_spaces() {
15600 let adf_json = r#"{
15601 "version": 1,
15602 "type": "doc",
15603 "content": [
15604 {
15605 "type": "mediaSingle",
15606 "attrs": {"layout": "center"},
15607 "content": [
15608 {
15609 "type": "media",
15610 "attrs": {
15611 "type": "file",
15612 "id": "abc-123",
15613 "collection": "my collection with spaces"
15614 }
15615 }
15616 ]
15617 }
15618 ]
15619 }"#;
15620 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15621 let md = adf_to_markdown(&doc).unwrap();
15622 let rt = markdown_to_adf(&md).unwrap();
15623 let media = &rt.content[0].content.as_ref().unwrap()[0];
15624 assert_eq!(
15625 media.attrs.as_ref().unwrap()["collection"],
15626 "my collection with spaces"
15627 );
15628 }
15629
15630 #[test]
15632 fn file_media_roundtrip_occurrence_key_with_spaces() {
15633 let adf_json = r#"{
15634 "version": 1,
15635 "type": "doc",
15636 "content": [
15637 {
15638 "type": "mediaSingle",
15639 "attrs": {"layout": "center"},
15640 "content": [
15641 {
15642 "type": "media",
15643 "attrs": {
15644 "type": "file",
15645 "id": "x",
15646 "collection": "y",
15647 "occurrenceKey": "key with spaces"
15648 }
15649 }
15650 ]
15651 }
15652 ]
15653 }"#;
15654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15655 let md = adf_to_markdown(&doc).unwrap();
15656 let rt = markdown_to_adf(&md).unwrap();
15657 let media = &rt.content[0].content.as_ref().unwrap()[0];
15658 assert_eq!(
15659 media.attrs.as_ref().unwrap()["occurrenceKey"],
15660 "key with spaces"
15661 );
15662 }
15663
15664 #[test]
15666 fn file_media_roundtrip_id_with_quote_char() {
15667 let adf_json = r#"{
15668 "version": 1,
15669 "type": "doc",
15670 "content": [
15671 {
15672 "type": "mediaSingle",
15673 "attrs": {"layout": "center"},
15674 "content": [
15675 {
15676 "type": "media",
15677 "attrs": {
15678 "type": "file",
15679 "id": "a\"b\"c",
15680 "collection": "col"
15681 }
15682 }
15683 ]
15684 }
15685 ]
15686 }"#;
15687 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15688 let md = adf_to_markdown(&doc).unwrap();
15689 let rt = markdown_to_adf(&md).unwrap();
15690 let media = &rt.content[0].content.as_ref().unwrap()[0];
15691 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15692 }
15693
15694 #[test]
15697 fn media_inline_roundtrip_id_with_spaces() {
15698 let adf_json = r#"{
15699 "version": 1,
15700 "type": "doc",
15701 "content": [
15702 {
15703 "type": "paragraph",
15704 "content": [
15705 {"type": "text", "text": "before "},
15706 {
15707 "type": "mediaInline",
15708 "attrs": {
15709 "type": "file",
15710 "id": "a b c",
15711 "collection": "my col"
15712 }
15713 },
15714 {"type": "text", "text": " after"}
15715 ]
15716 }
15717 ]
15718 }"#;
15719 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15720 let md = adf_to_markdown(&doc).unwrap();
15721 let rt = markdown_to_adf(&md).unwrap();
15722 let inline = &rt.content[0].content.as_ref().unwrap()[1];
15723 assert_eq!(inline.node_type, "mediaInline");
15724 let attrs = inline.attrs.as_ref().unwrap();
15725 assert_eq!(attrs["id"], "a b c");
15726 assert_eq!(attrs["collection"], "my col");
15727 }
15728
15729 #[test]
15732 fn file_media_roundtrip_preserves_occurrence_key() {
15733 let adf_json = r#"{
15734 "version": 1,
15735 "type": "doc",
15736 "content": [
15737 {
15738 "type": "mediaSingle",
15739 "attrs": {"layout": "center"},
15740 "content": [
15741 {
15742 "type": "media",
15743 "attrs": {
15744 "type": "file",
15745 "id": "abc-123",
15746 "collection": "my-collection",
15747 "occurrenceKey": "unique-key-xyz",
15748 "width": 200,
15749 "height": 100
15750 }
15751 }
15752 ]
15753 }
15754 ]
15755 }"#;
15756 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15757 let md = adf_to_markdown(&doc).unwrap();
15758 assert!(
15759 md.contains("occurrenceKey=unique-key-xyz"),
15760 "expected occurrenceKey in markdown, got: {md}"
15761 );
15762 let rt = markdown_to_adf(&md).unwrap();
15763 let media = &rt.content[0].content.as_ref().unwrap()[0];
15764 let attrs = media.attrs.as_ref().unwrap();
15765 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15766 assert_eq!(attrs["type"], "file");
15767 assert_eq!(attrs["id"], "abc-123");
15768 assert_eq!(attrs["collection"], "my-collection");
15769 }
15770
15771 #[test]
15774 fn media_single_caption_adf_to_markdown() {
15775 let adf_doc = serde_json::json!({
15776 "type": "doc",
15777 "version": 1,
15778 "content": [{
15779 "type": "mediaSingle",
15780 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15781 "content": [
15782 {
15783 "type": "media",
15784 "attrs": {
15785 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15786 "type": "file",
15787 "collection": "contentId-123456",
15788 "width": 800,
15789 "height": 600
15790 }
15791 },
15792 {
15793 "type": "caption",
15794 "content": [{"type": "text", "text": "An image caption here"}]
15795 }
15796 ]
15797 }]
15798 });
15799 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15800 let md = adf_to_markdown(&doc).unwrap();
15801 assert!(
15802 md.contains(":::caption"),
15803 "expected :::caption in markdown, got: {md}"
15804 );
15805 assert!(
15806 md.contains("An image caption here"),
15807 "expected caption text in markdown, got: {md}"
15808 );
15809 }
15810
15811 #[test]
15812 fn media_single_caption_markdown_to_adf() {
15813 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15814 let doc = markdown_to_adf(md).unwrap();
15815 let ms = &doc.content[0];
15816 assert_eq!(ms.node_type, "mediaSingle");
15817 let content = ms.content.as_ref().unwrap();
15818 assert_eq!(content.len(), 2, "expected media + caption children");
15819 assert_eq!(content[0].node_type, "media");
15820 assert_eq!(content[1].node_type, "caption");
15821 let caption_content = content[1].content.as_ref().unwrap();
15822 assert_eq!(
15823 caption_content[0].text.as_deref(),
15824 Some("An image caption here")
15825 );
15826 }
15827
15828 #[test]
15829 fn media_single_caption_round_trip() {
15830 let adf_doc = serde_json::json!({
15832 "type": "doc",
15833 "version": 1,
15834 "content": [{
15835 "type": "mediaSingle",
15836 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15837 "content": [
15838 {
15839 "type": "media",
15840 "attrs": {
15841 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15842 "type": "file",
15843 "collection": "contentId-123456",
15844 "width": 800,
15845 "height": 600
15846 }
15847 },
15848 {
15849 "type": "caption",
15850 "content": [{"type": "text", "text": "An image caption here"}]
15851 }
15852 ]
15853 }]
15854 });
15855 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15856 let md = adf_to_markdown(&doc).unwrap();
15857 let doc2 = markdown_to_adf(&md).unwrap();
15858 let ms = &doc2.content[0];
15859 assert_eq!(ms.node_type, "mediaSingle");
15860 let content = ms.content.as_ref().unwrap();
15861 assert_eq!(
15862 content.len(),
15863 2,
15864 "expected media + caption after round-trip"
15865 );
15866 assert_eq!(content[1].node_type, "caption");
15867 let caption_content = content[1].content.as_ref().unwrap();
15868 assert_eq!(
15869 caption_content[0].text.as_deref(),
15870 Some("An image caption here")
15871 );
15872 }
15873
15874 #[test]
15875 fn media_single_caption_with_inline_marks() {
15876 let adf_doc = serde_json::json!({
15877 "type": "doc",
15878 "version": 1,
15879 "content": [{
15880 "type": "mediaSingle",
15881 "attrs": {"layout": "center"},
15882 "content": [
15883 {
15884 "type": "media",
15885 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15886 },
15887 {
15888 "type": "caption",
15889 "content": [
15890 {"type": "text", "text": "A "},
15891 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15892 {"type": "text", "text": " caption"}
15893 ]
15894 }
15895 ]
15896 }]
15897 });
15898 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15899 let md = adf_to_markdown(&doc).unwrap();
15900 assert!(
15901 md.contains("**bold**"),
15902 "expected bold in caption, got: {md}"
15903 );
15904
15905 let doc2 = markdown_to_adf(&md).unwrap();
15906 let content = doc2.content[0].content.as_ref().unwrap();
15907 assert_eq!(content.len(), 2, "expected media + caption");
15908 assert_eq!(content[1].node_type, "caption");
15909 let caption_inlines = content[1].content.as_ref().unwrap();
15910 let bold_node = caption_inlines
15911 .iter()
15912 .find(|n| n.text.as_deref() == Some("bold"))
15913 .unwrap();
15914 let marks = bold_node.marks.as_ref().unwrap();
15915 assert_eq!(marks[0].mark_type, "strong");
15916 }
15917
15918 #[test]
15919 fn media_single_no_caption_unaffected() {
15920 let adf_doc = serde_json::json!({
15922 "type": "doc",
15923 "version": 1,
15924 "content": [{
15925 "type": "mediaSingle",
15926 "attrs": {"layout": "center"},
15927 "content": [{
15928 "type": "media",
15929 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15930 }]
15931 }]
15932 });
15933 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15934 let md = adf_to_markdown(&doc).unwrap();
15935 assert!(
15936 !md.contains(":::caption"),
15937 "should not emit caption when none present"
15938 );
15939 let doc2 = markdown_to_adf(&md).unwrap();
15940 let content = doc2.content[0].content.as_ref().unwrap();
15941 assert_eq!(content.len(), 1, "should only have media child");
15942 assert_eq!(content[0].node_type, "media");
15943 }
15944
15945 #[test]
15946 fn media_single_empty_caption_round_trip() {
15947 let adf_doc = serde_json::json!({
15949 "type": "doc",
15950 "version": 1,
15951 "content": [{
15952 "type": "mediaSingle",
15953 "attrs": {"layout": "center"},
15954 "content": [
15955 {
15956 "type": "media",
15957 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15958 },
15959 {
15960 "type": "caption"
15961 }
15962 ]
15963 }]
15964 });
15965 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15966 let md = adf_to_markdown(&doc).unwrap();
15967 assert!(
15968 md.contains(":::caption"),
15969 "expected :::caption even for empty caption, got: {md}"
15970 );
15971 assert!(
15972 md.contains(":::\n"),
15973 "expected closing ::: fence, got: {md}"
15974 );
15975 }
15976
15977 #[test]
15978 fn media_single_external_caption_round_trip() {
15979 let md = "\n:::caption\nImage description\n:::\n";
15981 let doc = markdown_to_adf(md).unwrap();
15982 let ms = &doc.content[0];
15983 assert_eq!(ms.node_type, "mediaSingle");
15984 let content = ms.content.as_ref().unwrap();
15985 assert_eq!(content.len(), 2);
15986 assert_eq!(content[0].node_type, "media");
15987 assert_eq!(content[1].node_type, "caption");
15988
15989 let md2 = adf_to_markdown(&doc).unwrap();
15990 let doc2 = markdown_to_adf(&md2).unwrap();
15991 let content2 = doc2.content[0].content.as_ref().unwrap();
15992 assert_eq!(content2.len(), 2);
15993 assert_eq!(content2[1].node_type, "caption");
15994 let caption_text = content2[1].content.as_ref().unwrap();
15995 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
15996 }
15997
15998 #[test]
16001 fn media_single_caption_localid_roundtrip() {
16002 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"}]}]}]}"#;
16003 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16004 let md = adf_to_markdown(&doc).unwrap();
16005 assert!(
16006 md.contains("localId=9da8c2104471"),
16007 "caption localId should appear in markdown: {md}"
16008 );
16009 let rt = markdown_to_adf(&md).unwrap();
16010 let content = rt.content[0].content.as_ref().unwrap();
16011 let caption = &content[1];
16012 assert_eq!(caption.node_type, "caption");
16013 assert_eq!(
16014 caption.attrs.as_ref().unwrap()["localId"],
16015 "9da8c2104471",
16016 "caption localId should round-trip"
16017 );
16018 }
16019
16020 #[test]
16021 fn media_single_caption_without_localid() {
16022 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16023 let doc = markdown_to_adf(md).unwrap();
16024 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16025 assert_eq!(caption.node_type, "caption");
16026 assert!(
16027 caption.attrs.is_none(),
16028 "caption without localId should not gain attrs"
16029 );
16030 let md2 = adf_to_markdown(&doc).unwrap();
16031 assert!(
16032 !md2.contains("localId"),
16033 "no localId should appear in output: {md2}"
16034 );
16035 }
16036
16037 #[test]
16038 fn media_single_caption_localid_stripped_when_option_set() {
16039 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"}]}]}]}"#;
16040 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16041 let opts = RenderOptions {
16042 strip_local_ids: true,
16043 ..Default::default()
16044 };
16045 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16046 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16047 }
16048
16049 #[test]
16050 fn table_width_roundtrip() {
16051 let adf_doc = serde_json::json!({
16053 "type": "doc",
16054 "version": 1,
16055 "content": [{
16056 "type": "table",
16057 "attrs": {"layout": "default", "width": 760.0},
16058 "content": [{
16059 "type": "tableRow",
16060 "content": [{
16061 "type": "tableHeader",
16062 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16063 }]
16064 }]
16065 }]
16066 });
16067 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16068 let md = adf_to_markdown(&doc).unwrap();
16069 assert!(
16070 md.contains("width=760.0"),
16071 "expected width=760.0 in markdown (float preserved), got: {md}"
16072 );
16073 let doc2 = markdown_to_adf(&md).unwrap();
16075 let table = &doc2.content[0];
16076 assert_eq!(table.node_type, "table");
16077 let table_attrs = table.attrs.as_ref().unwrap();
16078 assert_eq!(table_attrs["width"], 760.0);
16079 assert!(
16080 table_attrs["width"].is_f64(),
16081 "expected float width to be preserved as f64, got: {:?}",
16082 table_attrs["width"]
16083 );
16084 }
16085
16086 #[test]
16087 fn table_integer_width_roundtrip_preserves_integer() {
16088 let adf_doc = serde_json::json!({
16091 "type": "doc",
16092 "version": 1,
16093 "content": [{
16094 "type": "table",
16095 "attrs": {
16096 "isNumberColumnEnabled": false,
16097 "layout": "center",
16098 "localId": "abc-123",
16099 "width": 1420
16100 },
16101 "content": [{
16102 "type": "tableRow",
16103 "content": [{
16104 "type": "tableCell",
16105 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16106 }]
16107 }]
16108 }]
16109 });
16110 let doc: crate::atlassian::adf::AdfDocument =
16111 serde_json::from_value(adf_doc.clone()).unwrap();
16112 let md = adf_to_markdown(&doc).unwrap();
16113 assert!(
16114 md.contains("width=1420"),
16115 "expected width=1420 in markdown, got: {md}"
16116 );
16117 assert!(
16118 !md.contains("width=1420.0"),
16119 "integer width should not be rendered with decimal: {md}"
16120 );
16121
16122 let doc2 = markdown_to_adf(&md).unwrap();
16123 let table = &doc2.content[0];
16124 assert_eq!(table.node_type, "table");
16125 let table_attrs = table.attrs.as_ref().unwrap();
16126 assert_eq!(table_attrs["width"], 1420);
16127 assert!(
16128 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16129 "width should remain an integer, got: {:?}",
16130 table_attrs["width"]
16131 );
16132 assert!(
16133 !table_attrs["width"].is_f64(),
16134 "width should not be a float, got: {:?}",
16135 table_attrs["width"]
16136 );
16137
16138 let roundtripped = serde_json::to_value(&doc2).unwrap();
16140 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16141 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16142 assert_eq!(
16143 orig_width, rt_width,
16144 "width value must roundtrip byte-for-byte"
16145 );
16146 }
16147
16148 #[test]
16149 fn table_fractional_width_roundtrip() {
16150 let adf_doc = serde_json::json!({
16152 "type": "doc",
16153 "version": 1,
16154 "content": [{
16155 "type": "table",
16156 "attrs": {"layout": "default", "width": 760.5},
16157 "content": [{
16158 "type": "tableRow",
16159 "content": [{
16160 "type": "tableHeader",
16161 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16162 }]
16163 }]
16164 }]
16165 });
16166 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16167 let md = adf_to_markdown(&doc).unwrap();
16168 assert!(
16169 md.contains("width=760.5"),
16170 "expected width=760.5 in markdown, got: {md}"
16171 );
16172 let doc2 = markdown_to_adf(&md).unwrap();
16173 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16174 assert_eq!(table_attrs["width"], 760.5);
16175 assert!(table_attrs["width"].is_f64());
16176 }
16177
16178 #[test]
16179 fn pipe_table_integer_width_roundtrip() {
16180 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16182 let doc = markdown_to_adf(md).unwrap();
16183 let table = &doc.content[0];
16184 assert_eq!(table.node_type, "table");
16185 let attrs = table.attrs.as_ref().unwrap();
16186 assert_eq!(attrs["width"], 1420);
16187 assert!(
16188 attrs["width"].is_u64() || attrs["width"].is_i64(),
16189 "pipe-table width must stay integer, got: {:?}",
16190 attrs["width"]
16191 );
16192 }
16193
16194 #[test]
16195 fn file_media_width_type_roundtrip() {
16196 let adf_doc = serde_json::json!({
16198 "type": "doc",
16199 "version": 1,
16200 "content": [{
16201 "type": "mediaSingle",
16202 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16203 "content": [{
16204 "type": "media",
16205 "attrs": {
16206 "type": "file",
16207 "id": "abc123",
16208 "collection": "contentId-999",
16209 "height": 56,
16210 "width": 312
16211 }
16212 }]
16213 }]
16214 });
16215 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16216 let md = adf_to_markdown(&doc).unwrap();
16217 assert!(
16218 md.contains("widthType=pixel"),
16219 "expected widthType=pixel in markdown, got: {md}"
16220 );
16221 let doc2 = markdown_to_adf(&md).unwrap();
16222 let ms = &doc2.content[0];
16223 let ms_attrs = ms.attrs.as_ref().unwrap();
16224 assert_eq!(ms_attrs["widthType"], "pixel");
16225 assert_eq!(ms_attrs["width"], 312);
16226 }
16227
16228 #[test]
16229 fn file_media_mode_roundtrip() {
16230 let adf_doc = serde_json::json!({
16232 "type": "doc",
16233 "version": 1,
16234 "content": [{
16235 "type": "mediaSingle",
16236 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16237 "content": [{
16238 "type": "media",
16239 "attrs": {
16240 "type": "file",
16241 "id": "abc123",
16242 "collection": "test",
16243 "width": 1200,
16244 "height": 600
16245 }
16246 }]
16247 }]
16248 });
16249 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16250 let md = adf_to_markdown(&doc).unwrap();
16251 assert!(
16252 md.contains("mode=wide"),
16253 "expected mode=wide in markdown, got: {md}"
16254 );
16255 let doc2 = markdown_to_adf(&md).unwrap();
16256 let ms = &doc2.content[0];
16257 let ms_attrs = ms.attrs.as_ref().unwrap();
16258 assert_eq!(ms_attrs["mode"], "wide");
16259 assert_eq!(ms_attrs["layout"], "wide");
16260 assert_eq!(ms_attrs["width"], 1200);
16261 }
16262
16263 #[test]
16264 fn external_media_mode_roundtrip() {
16265 let adf_doc = serde_json::json!({
16267 "type": "doc",
16268 "version": 1,
16269 "content": [{
16270 "type": "mediaSingle",
16271 "attrs": {"layout": "wide", "mode": "wide"},
16272 "content": [{
16273 "type": "media",
16274 "attrs": {
16275 "type": "external",
16276 "url": "https://example.com/image.png"
16277 }
16278 }]
16279 }]
16280 });
16281 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16282 let md = adf_to_markdown(&doc).unwrap();
16283 assert!(
16284 md.contains("mode=wide"),
16285 "expected mode=wide in markdown, got: {md}"
16286 );
16287 let doc2 = markdown_to_adf(&md).unwrap();
16288 let ms = &doc2.content[0];
16289 let ms_attrs = ms.attrs.as_ref().unwrap();
16290 assert_eq!(ms_attrs["mode"], "wide");
16291 assert_eq!(ms_attrs["layout"], "wide");
16292 }
16293
16294 #[test]
16295 fn media_mode_only_roundtrip() {
16296 let adf_doc = serde_json::json!({
16298 "type": "doc",
16299 "version": 1,
16300 "content": [{
16301 "type": "mediaSingle",
16302 "attrs": {"layout": "center", "mode": "default"},
16303 "content": [{
16304 "type": "media",
16305 "attrs": {
16306 "type": "external",
16307 "url": "https://example.com/image.png"
16308 }
16309 }]
16310 }]
16311 });
16312 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16313 let md = adf_to_markdown(&doc).unwrap();
16314 assert!(
16315 md.contains("mode=default"),
16316 "expected mode=default in markdown, got: {md}"
16317 );
16318 let doc2 = markdown_to_adf(&md).unwrap();
16319 let ms = &doc2.content[0];
16320 let ms_attrs = ms.attrs.as_ref().unwrap();
16321 assert_eq!(ms_attrs["mode"], "default");
16322 }
16323
16324 #[test]
16325 fn file_media_hex_localid_roundtrip() {
16326 let adf_doc = serde_json::json!({
16328 "type": "doc",
16329 "version": 1,
16330 "content": [{
16331 "type": "mediaSingle",
16332 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16333 "content": [{
16334 "type": "media",
16335 "attrs": {
16336 "type": "file",
16337 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16338 "collection": "contentId-123",
16339 "height": 484,
16340 "width": 915,
16341 "alt": "image.png",
16342 "localId": "0e79f58ac382"
16343 }
16344 }]
16345 }]
16346 });
16347 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16348 let md = adf_to_markdown(&doc).unwrap();
16349 assert!(
16350 md.contains("localId=0e79f58ac382"),
16351 "expected localId=0e79f58ac382 in markdown, got: {md}"
16352 );
16353 let doc2 = markdown_to_adf(&md).unwrap();
16354 let ms = &doc2.content[0];
16355 let media = &ms.content.as_ref().unwrap()[0];
16356 let attrs = media.attrs.as_ref().unwrap();
16357 assert_eq!(attrs["localId"], "0e79f58ac382");
16358 }
16359
16360 #[test]
16361 fn file_media_uuid_localid_roundtrip() {
16362 let adf_doc = serde_json::json!({
16364 "type": "doc",
16365 "version": 1,
16366 "content": [{
16367 "type": "mediaSingle",
16368 "attrs": {"layout": "center"},
16369 "content": [{
16370 "type": "media",
16371 "attrs": {
16372 "type": "file",
16373 "id": "abc-123",
16374 "collection": "contentId-456",
16375 "height": 100,
16376 "width": 200,
16377 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16378 }
16379 }]
16380 }]
16381 });
16382 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16383 let md = adf_to_markdown(&doc).unwrap();
16384 assert!(
16385 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16386 "expected UUID localId in markdown, got: {md}"
16387 );
16388 let doc2 = markdown_to_adf(&md).unwrap();
16389 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16390 let attrs = media.attrs.as_ref().unwrap();
16391 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16392 }
16393
16394 #[test]
16395 fn file_media_null_uuid_localid_stripped() {
16396 let adf_doc = serde_json::json!({
16398 "type": "doc",
16399 "version": 1,
16400 "content": [{
16401 "type": "mediaSingle",
16402 "attrs": {"layout": "center"},
16403 "content": [{
16404 "type": "media",
16405 "attrs": {
16406 "type": "file",
16407 "id": "abc-123",
16408 "collection": "contentId-456",
16409 "height": 100,
16410 "width": 200,
16411 "localId": "00000000-0000-0000-0000-000000000000"
16412 }
16413 }]
16414 }]
16415 });
16416 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16417 let md = adf_to_markdown(&doc).unwrap();
16418 assert!(
16419 !md.contains("localId="),
16420 "null UUID localId should be stripped, got: {md}"
16421 );
16422 }
16423
16424 #[test]
16425 fn file_media_localid_stripped_when_option_set() {
16426 let adf_doc = serde_json::json!({
16428 "type": "doc",
16429 "version": 1,
16430 "content": [{
16431 "type": "mediaSingle",
16432 "attrs": {"layout": "center"},
16433 "content": [{
16434 "type": "media",
16435 "attrs": {
16436 "type": "file",
16437 "id": "abc-123",
16438 "collection": "contentId-456",
16439 "height": 100,
16440 "width": 200,
16441 "localId": "0e79f58ac382"
16442 }
16443 }]
16444 }]
16445 });
16446 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16447 let opts = RenderOptions {
16448 strip_local_ids: true,
16449 ..Default::default()
16450 };
16451 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16452 assert!(
16453 !md.contains("localId="),
16454 "localId should be stripped with strip_local_ids, got: {md}"
16455 );
16456 }
16457
16458 #[test]
16459 fn external_media_localid_roundtrip() {
16460 let adf_doc = serde_json::json!({
16462 "type": "doc",
16463 "version": 1,
16464 "content": [{
16465 "type": "mediaSingle",
16466 "attrs": {"layout": "center"},
16467 "content": [{
16468 "type": "media",
16469 "attrs": {
16470 "type": "external",
16471 "url": "https://example.com/image.png",
16472 "alt": "test",
16473 "localId": "deadbeef1234"
16474 }
16475 }]
16476 }]
16477 });
16478 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16479 let md = adf_to_markdown(&doc).unwrap();
16480 assert!(
16481 md.contains("localId=deadbeef1234"),
16482 "expected localId in markdown for external media, got: {md}"
16483 );
16484 let doc2 = markdown_to_adf(&md).unwrap();
16485 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16486 let attrs = media.attrs.as_ref().unwrap();
16487 assert_eq!(attrs["localId"], "deadbeef1234");
16488 }
16489
16490 #[test]
16491 fn bracket_in_text_not_parsed_as_link() {
16492 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16494 let doc = markdown_to_adf(md).unwrap();
16495 let para = &doc.content[0];
16496 assert_eq!(para.node_type, "paragraph");
16497 let content = para.content.as_ref().unwrap();
16498 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16500 let has_task_bracket = text_nodes
16501 .iter()
16502 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16503 assert!(
16504 has_task_bracket,
16505 "expected [Task] in plain text, nodes: {content:?}"
16506 );
16507 let link_nodes: Vec<_> = content
16509 .iter()
16510 .filter(|n| {
16511 n.marks
16512 .as_ref()
16513 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16514 })
16515 .collect();
16516 assert!(!link_nodes.is_empty(), "expected a link node");
16517 assert_eq!(
16518 link_nodes[0].text.as_deref(),
16519 Some("Link"),
16520 "link text should be 'Link'"
16521 );
16522 }
16523
16524 #[test]
16525 fn empty_paragraph_roundtrip() {
16526 let mut adf_in = AdfDocument::new();
16528 adf_in.content = vec![
16529 AdfNode::paragraph(vec![AdfNode::text("before")]),
16530 AdfNode::paragraph(vec![]),
16531 AdfNode::paragraph(vec![AdfNode::text("after")]),
16532 ];
16533 let md = adf_to_markdown(&adf_in).unwrap();
16534 let adf_out = markdown_to_adf(&md).unwrap();
16535 assert_eq!(
16536 adf_out.content.len(),
16537 3,
16538 "should have 3 blocks, markdown:\n{md}"
16539 );
16540 assert_eq!(adf_out.content[0].node_type, "paragraph");
16541 assert_eq!(adf_out.content[1].node_type, "paragraph");
16542 assert!(
16543 adf_out.content[1].content.is_none(),
16544 "middle paragraph should be empty"
16545 );
16546 assert_eq!(adf_out.content[2].node_type, "paragraph");
16547 }
16548
16549 #[test]
16550 fn nbsp_paragraph_roundtrip() {
16551 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16553 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16554 let md = adf_to_markdown(&doc).unwrap();
16555 assert!(
16556 md.contains("::paragraph["),
16557 "NBSP paragraph should use directive form: {md}"
16558 );
16559 let rt = markdown_to_adf(&md).unwrap();
16560 assert_eq!(rt.content.len(), 1, "should have 1 block");
16561 assert_eq!(rt.content[0].node_type, "paragraph");
16562 let text = rt.content[0].content.as_ref().unwrap()[0]
16563 .text
16564 .as_deref()
16565 .unwrap_or("");
16566 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16567 }
16568
16569 #[test]
16570 fn nbsp_in_nested_expand_roundtrip() {
16571 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16573 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16574 let md = adf_to_markdown(&doc).unwrap();
16575 let rt = markdown_to_adf(&md).unwrap();
16576 let ne = &rt.content[0];
16577 assert_eq!(ne.node_type, "nestedExpand");
16578 let inner = ne.content.as_ref().unwrap();
16579 assert_eq!(inner.len(), 1, "should have 1 inner block");
16580 assert_eq!(inner[0].node_type, "paragraph");
16581 let content = inner[0].content.as_ref().unwrap();
16582 assert!(!content.is_empty(), "paragraph should not be empty");
16583 let text = content[0].text.as_deref().unwrap_or("");
16584 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16585 }
16586
16587 #[test]
16588 fn nbsp_followed_by_content() {
16589 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\"}]}]}";
16591 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16592 let md = adf_to_markdown(&doc).unwrap();
16593 let rt = markdown_to_adf(&md).unwrap();
16594 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16595 let after_para = rt.content.iter().find(|n| {
16597 n.node_type == "paragraph"
16598 && n.content
16599 .as_ref()
16600 .and_then(|c| c.first())
16601 .and_then(|n| n.text.as_deref())
16602 .is_some_and(|t| t.contains("after"))
16603 });
16604 assert!(after_para.is_some(), "should have paragraph with 'after'");
16605 }
16606
16607 #[test]
16608 fn nbsp_paragraph_with_marks_survives() {
16609 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16612 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16613 let md = adf_to_markdown(&doc).unwrap();
16614 assert!(md.contains("**"), "should have bold markers: {md}");
16615 let rt = markdown_to_adf(&md).unwrap();
16616 let content = rt.content[0].content.as_ref().unwrap();
16617 assert!(!content.is_empty(), "should preserve content");
16618 }
16619
16620 #[test]
16621 fn regular_paragraph_unchanged() {
16622 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16624 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16625 let md = adf_to_markdown(&doc).unwrap();
16626 assert!(
16627 !md.contains("::paragraph"),
16628 "regular paragraphs should not use directive form: {md}"
16629 );
16630 assert!(md.contains("hello"));
16631 }
16632
16633 #[test]
16634 fn paragraph_directive_with_content_parsed() {
16635 let md = "::paragraph[\u{00a0}]\n";
16637 let doc = markdown_to_adf(md).unwrap();
16638 assert_eq!(doc.content.len(), 1);
16639 assert_eq!(doc.content[0].node_type, "paragraph");
16640 let content = doc.content[0].content.as_ref().unwrap();
16641 assert!(!content.is_empty(), "should have inline content");
16642 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16643 }
16644
16645 #[test]
16646 fn nbsp_paragraph_in_list_item_with_nested_list() {
16647 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"}]}]}]}]}]}]}"#;
16649 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16650 let md = adf_to_markdown(&doc).unwrap();
16651 let rt = markdown_to_adf(&md).unwrap();
16652 let list = &rt.content[0];
16653 assert_eq!(list.node_type, "bulletList");
16654 let item = &list.content.as_ref().unwrap()[0];
16655 let item_content = item.content.as_ref().unwrap();
16656 assert_eq!(
16657 item_content.len(),
16658 2,
16659 "listItem should have paragraph + nested list, got: {item_content:?}"
16660 );
16661 let para = &item_content[0];
16662 assert_eq!(para.node_type, "paragraph");
16663 let para_content = para
16664 .content
16665 .as_ref()
16666 .expect("paragraph should have content");
16667 assert!(
16668 !para_content.is_empty(),
16669 "NBSP paragraph content should not be empty"
16670 );
16671 assert_eq!(
16672 para_content[0].text.as_deref().unwrap(),
16673 "\u{00a0}",
16674 "NBSP should survive round-trip inside listItem"
16675 );
16676 }
16677
16678 #[test]
16679 fn nbsp_paragraph_in_list_item_with_local_ids() {
16680 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"}]}]}]}]}]}]}"#;
16682 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16683 let md = adf_to_markdown(&doc).unwrap();
16684 let rt = markdown_to_adf(&md).unwrap();
16685 let list = &rt.content[0];
16686 let item = &list.content.as_ref().unwrap()[0];
16687 assert_eq!(
16689 item.attrs.as_ref().unwrap()["localId"],
16690 "li-001",
16691 "listItem localId should survive"
16692 );
16693 let item_content = item.content.as_ref().unwrap();
16694 assert_eq!(item_content.len(), 2);
16695 let para = &item_content[0];
16697 assert_eq!(
16698 para.attrs.as_ref().unwrap()["localId"],
16699 "p-001",
16700 "paragraph localId should survive"
16701 );
16702 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16703 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16704 }
16705
16706 #[test]
16707 fn nbsp_paragraph_in_list_item_without_nested_list() {
16708 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"}]}]}]}]}"#;
16710 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16711 let md = adf_to_markdown(&doc).unwrap();
16712 let rt = markdown_to_adf(&md).unwrap();
16713 let list = &rt.content[0];
16714 let item = &list.content.as_ref().unwrap()[0];
16715 let para = &item.content.as_ref().unwrap()[0];
16716 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16717 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16718 }
16719
16720 #[test]
16721 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16722 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"}]}]}]}]}]}]}"#;
16724 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16725 let md = adf_to_markdown(&doc).unwrap();
16726 let rt = markdown_to_adf(&md).unwrap();
16727 let list = &rt.content[0];
16728 let item = &list.content.as_ref().unwrap()[0];
16729 let item_content = item.content.as_ref().unwrap();
16730 assert_eq!(item_content.len(), 2);
16731 let para = &item_content[0];
16732 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16733 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16734 }
16735
16736 #[test]
16737 fn list_item_leading_space_preserved() {
16738 let md = "- hello world\n- - text";
16740 let doc = markdown_to_adf(md).unwrap();
16741 let list = &doc.content[0];
16742 assert_eq!(list.node_type, "bulletList");
16743 let items = list.content.as_ref().unwrap();
16744 let first_para = &items[0].content.as_ref().unwrap()[0];
16746 let first_text = &first_para.content.as_ref().unwrap()[0];
16747 assert_eq!(first_text.text.as_deref(), Some("hello world"));
16748 }
16749
16750 #[test]
16751 fn list_item_leading_space_not_stripped() {
16752 let md = "- leading space text";
16755 let doc = markdown_to_adf(md).unwrap();
16756 let list = &doc.content[0];
16757 let items = list.content.as_ref().unwrap();
16758 let para = &items[0].content.as_ref().unwrap()[0];
16759 let text_node = ¶.content.as_ref().unwrap()[0];
16760 assert_eq!(
16762 text_node.text.as_deref(),
16763 Some(" leading space text"),
16764 "leading space should be preserved"
16765 );
16766 }
16767
16768 #[test]
16773 fn hardbreak_in_cell_uses_directive_table() {
16774 let adf = AdfDocument {
16777 version: 1,
16778 doc_type: "doc".to_string(),
16779 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16780 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16781 AdfNode::text("before"),
16782 AdfNode::hard_break(),
16783 AdfNode::text("after"),
16784 ])]),
16785 ])])],
16786 };
16787 let md = adf_to_markdown(&adf).unwrap();
16788 assert!(
16790 md.contains(":::td") || md.contains("::::table"),
16791 "Table with hardBreak should use directive form, got:\n{md}"
16792 );
16793 assert!(
16794 !md.contains("| before"),
16795 "Should NOT use pipe syntax with hardBreak"
16796 );
16797 }
16798
16799 #[test]
16800 fn hardbreak_in_cell_roundtrips() {
16801 let adf = AdfDocument {
16803 version: 1,
16804 doc_type: "doc".to_string(),
16805 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16806 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16807 AdfNode::text("line one"),
16808 AdfNode::hard_break(),
16809 AdfNode::text("line two"),
16810 ])]),
16811 ])])],
16812 };
16813 let md = adf_to_markdown(&adf).unwrap();
16814 let roundtripped = markdown_to_adf(&md).unwrap();
16815
16816 assert_eq!(roundtripped.content.len(), 1);
16818 assert_eq!(roundtripped.content[0].node_type, "table");
16819 let rows = roundtripped.content[0].content.as_ref().unwrap();
16820 assert_eq!(
16821 rows.len(),
16822 1,
16823 "Should have exactly 1 row, got {}",
16824 rows.len()
16825 );
16826 }
16827
16828 #[test]
16829 fn hardbreak_in_paragraph_roundtrips() {
16830 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16832 {"type":"text","text":"line one"},
16833 {"type":"hardBreak"},
16834 {"type":"text","text":"line two"}
16835 ]}]}"#;
16836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16837 let md = adf_to_markdown(&doc).unwrap();
16838 let round_tripped = markdown_to_adf(&md).unwrap();
16839 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16840 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16841 assert_eq!(
16842 types,
16843 vec!["text", "hardBreak", "text"],
16844 "hardBreak should be preserved, got: {types:?}"
16845 );
16846 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16847 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16848 }
16849
16850 #[test]
16851 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16852 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16854 {"type":"text","text":"before"},
16855 {"type":"hardBreak"},
16856 {"type":"hardBreak"},
16857 {"type":"text","text":"after"}
16858 ]}]}"#;
16859 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16860 let md = adf_to_markdown(&doc).unwrap();
16861 let round_tripped = markdown_to_adf(&md).unwrap();
16862 assert_eq!(
16863 round_tripped.content.len(),
16864 1,
16865 "Should remain a single paragraph, got {} blocks",
16866 round_tripped.content.len()
16867 );
16868 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16869 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16870 assert_eq!(
16871 types,
16872 vec!["text", "hardBreak", "hardBreak", "text"],
16873 "Both hardBreaks should be preserved, got: {types:?}"
16874 );
16875 assert_eq!(inlines[0].text.as_deref(), Some("before"));
16876 assert_eq!(inlines[3].text.as_deref(), Some("after"));
16877 }
16878
16879 #[test]
16880 fn hardbreak_only_paragraph_roundtrips() {
16881 let adf_json = r#"{"version":1,"type":"doc","content":[
16883 {"type":"paragraph","content":[{"type":"hardBreak"}]}
16884 ]}"#;
16885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16886 let md = adf_to_markdown(&doc).unwrap();
16887 let round_tripped = markdown_to_adf(&md).unwrap();
16888 assert_eq!(
16889 round_tripped.content.len(),
16890 1,
16891 "Paragraph should not be dropped, got {} blocks",
16892 round_tripped.content.len()
16893 );
16894 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16895 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16896 assert_eq!(
16897 types,
16898 vec!["hardBreak"],
16899 "hardBreak-only paragraph should preserve its content, got: {types:?}"
16900 );
16901 }
16902
16903 #[test]
16904 fn issue_410_full_reproducer_roundtrips() {
16905 let adf_json = r#"{"version":1,"type":"doc","content":[
16907 {"type":"paragraph","content":[
16908 {"type":"text","text":"before"},
16909 {"type":"hardBreak"},
16910 {"type":"hardBreak"},
16911 {"type":"text","text":"after"}
16912 ]},
16913 {"type":"paragraph","content":[
16914 {"type":"hardBreak"}
16915 ]}
16916 ]}"#;
16917 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16918 let md = adf_to_markdown(&doc).unwrap();
16919 let round_tripped = markdown_to_adf(&md).unwrap();
16920 assert_eq!(
16921 round_tripped.content.len(),
16922 2,
16923 "Should have exactly 2 paragraphs, got {}",
16924 round_tripped.content.len()
16925 );
16926 let p1 = round_tripped.content[0].content.as_ref().unwrap();
16928 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16929 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
16930 let p2 = round_tripped.content[1].content.as_ref().unwrap();
16932 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
16933 assert_eq!(types2, vec!["hardBreak"]);
16934 }
16935
16936 #[test]
16937 fn trailing_space_hardbreak_still_parsed() {
16938 let md = "line one \nline two\n";
16940 let doc = markdown_to_adf(md).unwrap();
16941 let inlines = doc.content[0].content.as_ref().unwrap();
16942 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16943 assert_eq!(
16944 types,
16945 vec!["text", "hardBreak", "text"],
16946 "Trailing-space hardBreak should still parse, got: {types:?}"
16947 );
16948 }
16949
16950 #[test]
16951 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
16952 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16954 {"type":"text","text":"text"},
16955 {"type":"hardBreak"}
16956 ]}]}"#;
16957 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16958 let md = adf_to_markdown(&doc).unwrap();
16959 let round_tripped = markdown_to_adf(&md).unwrap();
16960 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16961 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16962 assert_eq!(
16963 types,
16964 vec!["text", "hardBreak"],
16965 "Trailing hardBreak should be preserved, got: {types:?}"
16966 );
16967 }
16968
16969 #[test]
16970 #[test]
16971 fn table_with_header_row_uses_pipe_syntax() {
16972 let adf = AdfDocument {
16974 version: 1,
16975 doc_type: "doc".to_string(),
16976 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16977 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
16978 ])])],
16979 };
16980 let md = adf_to_markdown(&adf).unwrap();
16981 assert!(
16982 md.contains("| header cell |"),
16983 "Table with header row should use pipe syntax, got:\n{md}"
16984 );
16985 }
16986
16987 #[test]
16988 fn table_without_header_row_uses_directive_syntax() {
16989 let adf = AdfDocument {
16992 version: 1,
16993 doc_type: "doc".to_string(),
16994 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16995 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
16996 ])])],
16997 };
16998 let md = adf_to_markdown(&adf).unwrap();
16999 assert!(
17000 md.contains("::::table"),
17001 "Table without header row should use directive syntax, got:\n{md}"
17002 );
17003 }
17004
17005 #[test]
17006 fn tablecell_first_row_preserved_on_roundtrip() {
17007 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17009 {"type":"tableRow","content":[
17010 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17011 ]},
17012 {"type":"tableRow","content":[
17013 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17014 ]}
17015 ]}]}"#;
17016 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17017 let md = adf_to_markdown(&doc).unwrap();
17018 let round_tripped = markdown_to_adf(&md).unwrap();
17019 let rows = round_tripped.content[0].content.as_ref().unwrap();
17020 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17021 assert_eq!(
17022 row0_cell.node_type, "tableCell",
17023 "first row cell should remain tableCell, got: {}",
17024 row0_cell.node_type
17025 );
17026 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17027 assert_eq!(row1_cell.node_type, "tableCell");
17028 }
17029
17030 #[test]
17031 fn mixed_header_and_cell_first_row_uses_pipe() {
17032 let adf = AdfDocument {
17034 version: 1,
17035 doc_type: "doc".to_string(),
17036 content: vec![AdfNode::table(vec![
17037 AdfNode::table_row(vec![
17038 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17039 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17040 ]),
17041 AdfNode::table_row(vec![
17042 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17043 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17044 ]),
17045 ])],
17046 };
17047 let md = adf_to_markdown(&adf).unwrap();
17048 assert!(
17049 md.contains("| H1 |"),
17050 "Table with header first row should use pipe syntax, got:\n{md}"
17051 );
17052 assert!(!md.contains("::::table"), "should not use directive syntax");
17053 }
17054
17055 #[test]
17058 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17059 let adf = AdfDocument {
17062 version: 1,
17063 doc_type: "doc".to_string(),
17064 content: vec![AdfNode::table(vec![
17065 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17066 AdfNode::text("Header"),
17067 ])])]),
17068 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17069 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17070 ])])]),
17071 ])],
17072 };
17073 let md = adf_to_markdown(&adf).unwrap();
17074 assert!(
17075 md.contains(r"`a\|b`"),
17076 "Pipe inside code span must be escaped, got:\n{md}"
17077 );
17078 }
17079
17080 #[test]
17081 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17082 let adf = AdfDocument {
17083 version: 1,
17084 doc_type: "doc".to_string(),
17085 content: vec![AdfNode::table(vec![
17086 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17087 AdfNode::text("Header"),
17088 ])])]),
17089 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17090 AdfNode::text("x|y"),
17091 ])])]),
17092 ])],
17093 };
17094 let md = adf_to_markdown(&adf).unwrap();
17095 assert!(
17096 md.contains(r"x\|y"),
17097 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17098 );
17099 }
17100
17101 #[test]
17102 fn code_span_with_pipe_in_table_cell_roundtrips() {
17103 let adf_json = r#"{
17105 "version": 1,
17106 "type": "doc",
17107 "content": [{
17108 "type": "table",
17109 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17110 "content": [
17111 {"type": "tableRow", "content": [
17112 {"type": "tableHeader", "attrs": {}, "content": [
17113 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17114 ]},
17115 {"type": "tableHeader", "attrs": {}, "content": [
17116 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17117 ]}
17118 ]},
17119 {"type": "tableRow", "content": [
17120 {"type": "tableCell", "attrs": {}, "content": [
17121 {"type": "paragraph", "content": [
17122 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17123 ]}
17124 ]},
17125 {"type": "tableCell", "attrs": {}, "content": [
17126 {"type": "paragraph", "content": [
17127 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17128 ]}
17129 ]}
17130 ]}
17131 ]
17132 }]
17133 }"#;
17134 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17135 let md = adf_to_markdown(&doc).unwrap();
17136 let round_tripped = markdown_to_adf(&md).unwrap();
17137
17138 let rows = round_tripped.content[0].content.as_ref().unwrap();
17139 assert_eq!(
17140 rows.len(),
17141 2,
17142 "Table should have 2 rows, got: {}",
17143 rows.len()
17144 );
17145
17146 let body_row = rows[1].content.as_ref().unwrap();
17147 assert_eq!(
17148 body_row.len(),
17149 2,
17150 "Body row should have 2 cells (not split by the pipe), got: {}",
17151 body_row.len()
17152 );
17153
17154 let second_cell = &body_row[1];
17155 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17156 let inlines = para.content.as_ref().unwrap();
17157 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17158 assert_eq!(
17159 inlines[0].text.as_deref(),
17160 Some("parser.decode[T|json]"),
17161 "Code-span text must be preserved with literal pipe"
17162 );
17163 let marks = inlines[0]
17164 .marks
17165 .as_ref()
17166 .expect("code mark must be preserved");
17167 assert!(
17168 marks.iter().any(|m| m.mark_type == "code"),
17169 "text node should carry the code mark"
17170 );
17171 }
17172
17173 #[test]
17174 fn plain_text_pipe_in_table_cell_roundtrips() {
17175 let adf = AdfDocument {
17177 version: 1,
17178 doc_type: "doc".to_string(),
17179 content: vec![AdfNode::table(vec![
17180 AdfNode::table_row(vec![
17181 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17182 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17183 ]),
17184 AdfNode::table_row(vec![
17185 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17186 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17187 ]),
17188 ])],
17189 };
17190 let md = adf_to_markdown(&adf).unwrap();
17191 let round_tripped = markdown_to_adf(&md).unwrap();
17192 let rows = round_tripped.content[0].content.as_ref().unwrap();
17193 let body_row = rows[1].content.as_ref().unwrap();
17194 assert_eq!(
17195 body_row.len(),
17196 2,
17197 "Body row should keep 2 cells, got: {}",
17198 body_row.len()
17199 );
17200 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17201 .content
17202 .as_ref()
17203 .unwrap()[0]
17204 .text
17205 .as_deref();
17206 assert_eq!(first_cell_text, Some("a|b"));
17207 }
17208
17209 #[test]
17210 fn cell_contains_hard_break_true() {
17211 let para = AdfNode::paragraph(vec![
17212 AdfNode::text("a"),
17213 AdfNode::hard_break(),
17214 AdfNode::text("b"),
17215 ]);
17216 assert!(cell_contains_hard_break(¶));
17217 }
17218
17219 #[test]
17220 fn cell_contains_hard_break_false() {
17221 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17222 assert!(!cell_contains_hard_break(¶));
17223 }
17224
17225 #[test]
17226 fn cell_contains_hard_break_empty() {
17227 let para = AdfNode::paragraph(vec![]);
17228 assert!(!cell_contains_hard_break(¶));
17229 }
17230
17231 #[test]
17234 fn multi_paragraph_panel_roundtrips() {
17235 let adf = AdfDocument {
17236 version: 1,
17237 doc_type: "doc".to_string(),
17238 content: vec![AdfNode {
17239 node_type: "panel".to_string(),
17240 attrs: Some(serde_json::json!({"panelType": "info"})),
17241 content: Some(vec![
17242 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17243 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17244 ]),
17245 text: None,
17246 marks: None,
17247 local_id: None,
17248 parameters: None,
17249 }],
17250 };
17251
17252 let md = adf_to_markdown(&adf).unwrap();
17253 assert!(
17255 md.contains("First paragraph.\n\nSecond paragraph."),
17256 "Panel should have blank line between paragraphs, got:\n{md}"
17257 );
17258
17259 let roundtripped = markdown_to_adf(&md).unwrap();
17261 assert_eq!(roundtripped.content.len(), 1);
17262 assert_eq!(roundtripped.content[0].node_type, "panel");
17263 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17264 assert_eq!(
17265 panel_content.len(),
17266 2,
17267 "Panel should have 2 paragraphs after round-trip, got {}",
17268 panel_content.len()
17269 );
17270 }
17271
17272 #[test]
17273 fn multi_paragraph_expand_roundtrips() {
17274 let adf = AdfDocument {
17275 version: 1,
17276 doc_type: "doc".to_string(),
17277 content: vec![AdfNode {
17278 node_type: "expand".to_string(),
17279 attrs: Some(serde_json::json!({"title": "Details"})),
17280 content: Some(vec![
17281 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17282 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17283 ]),
17284 text: None,
17285 marks: None,
17286 local_id: None,
17287 parameters: None,
17288 }],
17289 };
17290
17291 let md = adf_to_markdown(&adf).unwrap();
17292 let roundtripped = markdown_to_adf(&md).unwrap();
17293 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17294 assert_eq!(
17295 expand_content.len(),
17296 2,
17297 "Expand should have 2 paragraphs after round-trip, got {}",
17298 expand_content.len()
17299 );
17300 }
17301
17302 #[test]
17303 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17304 let cell_content = vec![
17305 AdfNode {
17306 node_type: "nestedExpand".to_string(),
17307 attrs: Some(serde_json::json!({"title": "First"})),
17308 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17309 text: None,
17310 marks: None,
17311 local_id: None,
17312 parameters: None,
17313 },
17314 AdfNode {
17315 node_type: "nestedExpand".to_string(),
17316 attrs: Some(serde_json::json!({"title": "Second"})),
17317 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17318 text: None,
17319 marks: None,
17320 local_id: None,
17321 parameters: None,
17322 },
17323 ];
17324 let adf = AdfDocument {
17325 version: 1,
17326 doc_type: "doc".to_string(),
17327 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17328 AdfNode::table_cell(cell_content),
17329 ])])],
17330 };
17331
17332 let md = adf_to_markdown(&adf).unwrap();
17333 assert!(
17334 md.contains(":::\n\n:::nested-expand"),
17335 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17336 );
17337
17338 let rt = markdown_to_adf(&md).unwrap();
17339 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17340 .content
17341 .as_ref()
17342 .unwrap()[0];
17343 let cell_nodes = cell.content.as_ref().unwrap();
17344 let expand_count = cell_nodes
17345 .iter()
17346 .filter(|n| n.node_type == "nestedExpand")
17347 .count();
17348 assert_eq!(
17349 expand_count, 2,
17350 "Both nested-expands should survive round-trip, got {expand_count}"
17351 );
17352 }
17353
17354 #[test]
17355 fn multi_paragraph_in_table_cell_roundtrip() {
17356 let adf = AdfDocument {
17358 version: 1,
17359 doc_type: "doc".to_string(),
17360 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17361 AdfNode::table_cell(vec![
17362 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17363 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17364 ]),
17365 ])])],
17366 };
17367
17368 let md = adf_to_markdown(&adf).unwrap();
17369 assert!(
17370 md.contains("Para one.\n\nPara two."),
17371 "Should have blank line between paragraphs in cell, got:\n{md}"
17372 );
17373
17374 let rt = markdown_to_adf(&md).unwrap();
17375 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17376 .content
17377 .as_ref()
17378 .unwrap()[0];
17379 let para_count = cell
17380 .content
17381 .as_ref()
17382 .unwrap()
17383 .iter()
17384 .filter(|n| n.node_type == "paragraph")
17385 .count();
17386 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17387 }
17388
17389 #[test]
17390 fn panel_inside_table_cell_roundtrip() {
17391 let adf = AdfDocument {
17393 version: 1,
17394 doc_type: "doc".to_string(),
17395 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17396 AdfNode::table_cell(vec![
17397 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17398 AdfNode {
17399 node_type: "panel".to_string(),
17400 attrs: Some(serde_json::json!({"panelType": "info"})),
17401 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17402 "Panel content",
17403 )])]),
17404 text: None,
17405 marks: None,
17406 local_id: None,
17407 parameters: None,
17408 },
17409 ]),
17410 ])])],
17411 };
17412
17413 let md = adf_to_markdown(&adf).unwrap();
17414 assert!(
17415 md.contains(":::panel"),
17416 "Should contain panel directive, got:\n{md}"
17417 );
17418
17419 let rt = markdown_to_adf(&md).unwrap();
17420 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17421 .content
17422 .as_ref()
17423 .unwrap()[0];
17424 let has_panel = cell
17425 .content
17426 .as_ref()
17427 .unwrap()
17428 .iter()
17429 .any(|n| n.node_type == "panel");
17430 assert!(has_panel, "Panel should survive round-trip in table cell");
17431 }
17432
17433 #[test]
17434 fn three_consecutive_expands_in_table_cell() {
17435 let make_expand = |title: &str| AdfNode {
17436 node_type: "nestedExpand".to_string(),
17437 attrs: Some(serde_json::json!({"title": title})),
17438 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17439 text: None,
17440 marks: None,
17441 local_id: None,
17442 parameters: None,
17443 };
17444 let adf = AdfDocument {
17445 version: 1,
17446 doc_type: "doc".to_string(),
17447 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17448 AdfNode::table_cell(vec![
17449 make_expand("First"),
17450 make_expand("Second"),
17451 make_expand("Third"),
17452 ]),
17453 ])])],
17454 };
17455
17456 let md = adf_to_markdown(&adf).unwrap();
17457 let rt = markdown_to_adf(&md).unwrap();
17458 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17459 .content
17460 .as_ref()
17461 .unwrap()[0];
17462 let expand_count = cell
17463 .content
17464 .as_ref()
17465 .unwrap()
17466 .iter()
17467 .filter(|n| n.node_type == "nestedExpand")
17468 .count();
17469 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17470 }
17471
17472 #[test]
17475 fn nested_expand_inside_panel() {
17476 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17481 let adf = markdown_to_adf(md).unwrap();
17482
17483 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17484 assert!(err.violations.iter().any(|v| matches!(
17485 v,
17486 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17487 parent_type, child_type, ..
17488 } if parent_type == "panel" && child_type == "expand"
17489 )));
17490 }
17491
17492 #[test]
17493 fn nested_expand_inside_table_cell() {
17494 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17498 let adf = markdown_to_adf(md).unwrap();
17499
17500 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17501 assert!(err.violations.iter().any(|v| matches!(
17502 v,
17503 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17504 parent_type, child_type, ..
17505 } if parent_type == "tableCell" && child_type == "expand"
17506 )));
17507 }
17508
17509 #[test]
17510 fn nested_expand_inside_layout_column() {
17511 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17516 let adf = markdown_to_adf(md).unwrap();
17517
17518 assert_eq!(adf.content.len(), 1);
17519 assert_eq!(adf.content[0].node_type, "layoutSection");
17520
17521 let columns = adf.content[0].content.as_ref().unwrap();
17522 assert_eq!(columns.len(), 2);
17523 let col_content = columns[0].content.as_ref().unwrap();
17524 assert!(
17525 col_content.iter().any(|n| n.node_type == "expand"),
17526 "Column should contain an expand node, got: {:?}",
17527 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17528 );
17529
17530 crate::atlassian::adf_validated::validate(&adf).unwrap();
17532 }
17533
17534 #[test]
17535 fn expand_localid_in_directive_attrs() {
17536 let adf_json = r#"{"version":1,"type":"doc","content":[
17538 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17539 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17540 ]}
17541 ]}"#;
17542 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17543 let md = adf_to_markdown(&doc).unwrap();
17544 assert!(
17545 md.contains("localId=exp-001"),
17546 "should contain localId: {md}"
17547 );
17548 assert!(
17549 md.contains(":::expand{"),
17550 "should have expand directive with attrs: {md}"
17551 );
17552 assert!(
17553 !md.contains(":::\n{localId="),
17554 "localId should NOT be trailing: {md}"
17555 );
17556 }
17557
17558 #[test]
17559 fn expand_localid_roundtrip() {
17560 let adf_json = r#"{"version":1,"type":"doc","content":[
17561 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17562 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17563 ]}
17564 ]}"#;
17565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17566 let md = adf_to_markdown(&doc).unwrap();
17567 let rt = markdown_to_adf(&md).unwrap();
17568 let expand = &rt.content[0];
17569 assert_eq!(expand.node_type, "expand");
17570 assert_eq!(
17571 expand.local_id.as_deref(),
17572 Some("exp-001"),
17573 "expand localId should survive round-trip"
17574 );
17575 assert_eq!(
17576 expand.attrs.as_ref().unwrap()["title"],
17577 "Details",
17578 "expand title should survive round-trip"
17579 );
17580 }
17581
17582 #[test]
17583 fn nested_expand_localid_roundtrip() {
17584 let adf_json = r#"{"version":1,"type":"doc","content":[
17585 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17586 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17587 ]}
17588 ]}"#;
17589 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17590 let md = adf_to_markdown(&doc).unwrap();
17591 assert!(
17592 md.contains(":::nested-expand{"),
17593 "should have directive: {md}"
17594 );
17595 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17596 let rt = markdown_to_adf(&md).unwrap();
17597 let ne = &rt.content[0];
17598 assert_eq!(ne.node_type, "nestedExpand");
17599 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17600 }
17601
17602 #[test]
17603 fn nested_expand_localid_followed_by_content() {
17604 let adf_json = "{\
17606 \"version\":1,\"type\":\"doc\",\"content\":[\
17607 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17608 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17609 ]},\
17610 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17611 ]}";
17612 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17613 let md = adf_to_markdown(&doc).unwrap();
17614 let rt = markdown_to_adf(&md).unwrap();
17615 let ne = &rt.content[0];
17617 assert_eq!(ne.node_type, "nestedExpand");
17618 assert_eq!(
17619 ne.local_id.as_deref(),
17620 Some("exp-001"),
17621 "nestedExpand should preserve localId"
17622 );
17623 let para = &rt.content[1];
17625 assert_eq!(para.node_type, "paragraph");
17626 let text = para.content.as_ref().unwrap()[0]
17627 .text
17628 .as_deref()
17629 .unwrap_or("");
17630 assert!(
17631 !text.contains("localId"),
17632 "following paragraph should not contain localId: {text}"
17633 );
17634 assert!(
17635 text.contains("after"),
17636 "following paragraph should contain 'after': {text}"
17637 );
17638 }
17639
17640 #[test]
17641 fn expand_localid_without_title() {
17642 let adf_json = r#"{"version":1,"type":"doc","content":[
17643 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17644 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17645 ]}
17646 ]}"#;
17647 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17648 let md = adf_to_markdown(&doc).unwrap();
17649 assert!(
17650 md.contains(":::expand{localId=exp-002}"),
17651 "should have localId without title: {md}"
17652 );
17653 let rt = markdown_to_adf(&md).unwrap();
17654 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17655 }
17656
17657 #[test]
17658 fn expand_localid_stripped() {
17659 let adf_json = r#"{"version":1,"type":"doc","content":[
17660 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17661 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17662 ]}
17663 ]}"#;
17664 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17665 let opts = RenderOptions {
17666 strip_local_ids: true,
17667 };
17668 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17669 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17670 assert!(
17671 md.contains(":::expand{title=\"X\"}"),
17672 "title should remain: {md}"
17673 );
17674 }
17675
17676 #[test]
17679 fn expand_top_level_localid_roundtrip() {
17680 let adf_json = r#"{"version":1,"type":"doc","content":[
17682 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17683 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17684 ]}
17685 ]}"#;
17686 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17687 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17688 let md = adf_to_markdown(&doc).unwrap();
17689 assert!(
17690 md.contains("localId=abc-123"),
17691 "JFM should contain localId: {md}"
17692 );
17693 let rt = markdown_to_adf(&md).unwrap();
17694 let expand = &rt.content[0];
17695 assert_eq!(expand.node_type, "expand");
17696 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17697 assert_eq!(
17698 expand.attrs.as_ref().unwrap()["title"],
17699 "My Section",
17700 "title should survive round-trip"
17701 );
17702 }
17703
17704 #[test]
17705 fn expand_parameters_roundtrip() {
17706 let adf_json = r#"{"version":1,"type":"doc","content":[
17708 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17709 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17710 ]}
17711 ]}"#;
17712 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17713 assert!(doc.content[0].parameters.is_some());
17714 let md = adf_to_markdown(&doc).unwrap();
17715 assert!(md.contains("params="), "JFM should contain params: {md}");
17716 let rt = markdown_to_adf(&md).unwrap();
17717 let expand = &rt.content[0];
17718 let params = expand
17719 .parameters
17720 .as_ref()
17721 .expect("parameters should survive round-trip");
17722 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17723 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17724 }
17725
17726 #[test]
17727 fn expand_localid_and_parameters_roundtrip() {
17728 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"}]}]}]}"#;
17730 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17731 let md = adf_to_markdown(&doc).unwrap();
17732 let rt = markdown_to_adf(&md).unwrap();
17733 let expand = &rt.content[0];
17734 assert_eq!(expand.node_type, "expand");
17735 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17736 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17737 let params = expand
17738 .parameters
17739 .as_ref()
17740 .expect("parameters should survive");
17741 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17742 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17743 }
17744
17745 #[test]
17746 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17747 let adf_json = r#"{"version":1,"type":"doc","content":[
17748 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17749 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17750 ]}
17751 ]}"#;
17752 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17753 let md = adf_to_markdown(&doc).unwrap();
17754 assert!(
17755 md.contains(":::nested-expand{"),
17756 "should use nested-expand: {md}"
17757 );
17758 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17759 assert!(md.contains("params="), "should have params: {md}");
17760 let rt = markdown_to_adf(&md).unwrap();
17761 let ne = &rt.content[0];
17762 assert_eq!(ne.node_type, "nestedExpand");
17763 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17764 assert_eq!(
17765 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17766 "nm-001"
17767 );
17768 }
17769
17770 #[test]
17771 fn expand_top_level_localid_stripped() {
17772 let adf_json = r#"{"version":1,"type":"doc","content":[
17774 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17775 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17776 ]}
17777 ]}"#;
17778 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17779 let opts = RenderOptions {
17780 strip_local_ids: true,
17781 };
17782 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17783 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17784 assert!(
17785 md.contains(":::expand{title=\"X\"}"),
17786 "title should remain: {md}"
17787 );
17788 }
17789
17790 #[test]
17791 fn expand_parameters_without_localid() {
17792 let adf_json = r#"{"version":1,"type":"doc","content":[
17794 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17795 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17796 ]}
17797 ]}"#;
17798 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17799 let md = adf_to_markdown(&doc).unwrap();
17800 assert!(!md.contains("localId"), "no localId: {md}");
17801 assert!(md.contains("params="), "has params: {md}");
17802 let rt = markdown_to_adf(&md).unwrap();
17803 assert!(rt.content[0].local_id.is_none());
17804 assert_eq!(
17805 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17806 "solo"
17807 );
17808 }
17809
17810 #[test]
17811 fn expand_localid_without_parameters() {
17812 let adf_json = r#"{"version":1,"type":"doc","content":[
17814 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17815 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17816 ]}
17817 ]}"#;
17818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17819 let md = adf_to_markdown(&doc).unwrap();
17820 assert!(md.contains("localId=lid-only"), "has localId: {md}");
17821 assert!(!md.contains("params="), "no params: {md}");
17822 let rt = markdown_to_adf(&md).unwrap();
17823 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17824 assert!(rt.content[0].parameters.is_none());
17825 }
17826
17827 #[test]
17828 fn nested_panel_inside_panel() {
17829 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17830 let adf = markdown_to_adf(md).unwrap();
17831
17832 assert_eq!(adf.content.len(), 1);
17834 assert_eq!(adf.content[0].node_type, "panel");
17835
17836 let panel_content = adf.content[0].content.as_ref().unwrap();
17838 assert!(
17839 panel_content.iter().any(|n| n.node_type == "panel"),
17840 "Outer panel should contain an inner panel, got: {:?}",
17841 panel_content
17842 .iter()
17843 .map(|n| &n.node_type)
17844 .collect::<Vec<_>>()
17845 );
17846 }
17847
17848 #[test]
17849 fn content_after_directive_table_is_preserved() {
17850 let md = "\
17852## Before table
17853
17854::::table{layout=default}
17855:::tr
17856:::th{}
17857Cell
17858:::
17859:::
17860::::
17861
17862## After table
17863
17864Paragraph after.";
17865 let adf = markdown_to_adf(md).unwrap();
17866 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17867 assert_eq!(
17868 types,
17869 vec!["heading", "table", "heading", "paragraph"],
17870 "Content after table was dropped: got {types:?}"
17871 );
17872 }
17873
17874 #[test]
17875 fn paragraph_after_directive_table_is_preserved() {
17876 let md = "\
17878::::table{layout=default}
17879:::tr
17880:::th{}
17881Header
17882:::
17883:::
17884::::
17885
17886Just a paragraph.";
17887 let adf = markdown_to_adf(md).unwrap();
17888 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17889 assert_eq!(
17890 types,
17891 vec!["table", "paragraph"],
17892 "Paragraph after table was dropped: got {types:?}"
17893 );
17894 }
17895
17896 #[test]
17897 fn extension_after_directive_table_is_preserved() {
17898 let md = "\
17900::::table{layout=default}
17901:::tr
17902:::th{}
17903Header
17904:::
17905:::
17906::::
17907
17908::extension{type=com.atlassian.confluence.macro.core key=toc}";
17909 let adf = markdown_to_adf(md).unwrap();
17910 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17911 assert_eq!(
17912 types,
17913 vec!["table", "extension"],
17914 "Extension after table was dropped: got {types:?}"
17915 );
17916 }
17917
17918 #[test]
17919 fn multiple_blocks_after_directive_table() {
17920 let md = "\
17922## Heading 1
17923
17924::::table{layout=default}
17925:::tr
17926:::td{}
17927A
17928:::
17929:::td{}
17930B
17931:::
17932:::
17933::::
17934
17935## Heading 2
17936
17937Some text.
17938
17939---
17940
17941::::table{layout=default}
17942:::tr
17943:::th{}
17944C
17945:::
17946:::
17947::::
17948
17949## Heading 3";
17950 let adf = markdown_to_adf(md).unwrap();
17951 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17952 assert_eq!(
17953 types,
17954 vec![
17955 "heading",
17956 "table",
17957 "heading",
17958 "paragraph",
17959 "rule",
17960 "table",
17961 "heading"
17962 ],
17963 "Content after tables was dropped: got {types:?}"
17964 );
17965 }
17966
17967 #[test]
17970 fn adf_table_caption_to_markdown() {
17971 let doc = AdfDocument {
17972 version: 1,
17973 doc_type: "doc".to_string(),
17974 content: vec![AdfNode::table(vec![
17975 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17976 AdfNode::text("cell"),
17977 ])])]),
17978 AdfNode::caption(vec![AdfNode::text("Table caption")]),
17979 ])],
17980 };
17981 let md = adf_to_markdown(&doc).unwrap();
17982 assert!(
17983 md.contains("::::table"),
17984 "table with caption must use directive form"
17985 );
17986 assert!(
17987 md.contains(":::caption"),
17988 "caption directive missing, got: {md}"
17989 );
17990 assert!(
17991 md.contains("Table caption"),
17992 "caption text missing, got: {md}"
17993 );
17994 }
17995
17996 #[test]
17997 fn directive_table_caption_parses() {
17998 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
17999 let doc = markdown_to_adf(md).unwrap();
18000 let table = &doc.content[0];
18001 assert_eq!(table.node_type, "table");
18002 let children = table.content.as_ref().unwrap();
18003 assert_eq!(children.len(), 2, "expected row + caption");
18004 assert_eq!(children[0].node_type, "tableRow");
18005 assert_eq!(children[1].node_type, "caption");
18006 let caption_content = children[1].content.as_ref().unwrap();
18007 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18008 }
18009
18010 #[test]
18011 fn table_caption_round_trip_from_adf_json() {
18012 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18013 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18014 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18015 ]}]}"#;
18016 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18017 let md = adf_to_markdown(&doc).unwrap();
18018 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18019 let round_tripped = markdown_to_adf(&md).unwrap();
18020 let children = round_tripped.content[0].content.as_ref().unwrap();
18021 let caption = children.iter().find(|n| n.node_type == "caption");
18022 assert!(caption.is_some(), "caption lost on round-trip");
18023 let caption_text = caption.unwrap().content.as_ref().unwrap();
18024 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18025 }
18026
18027 #[test]
18028 fn table_caption_with_inline_marks_round_trips() {
18029 let doc = AdfDocument {
18030 version: 1,
18031 doc_type: "doc".to_string(),
18032 content: vec![AdfNode::table(vec![
18033 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18034 AdfNode::text("data"),
18035 ])])]),
18036 AdfNode::caption(vec![
18037 AdfNode::text("Caption with "),
18038 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18039 ]),
18040 ])],
18041 };
18042 let md = adf_to_markdown(&doc).unwrap();
18043 assert!(md.contains("**bold**"), "bold mark missing in caption");
18044 let round_tripped = markdown_to_adf(&md).unwrap();
18045 let caption = round_tripped.content[0]
18046 .content
18047 .as_ref()
18048 .unwrap()
18049 .iter()
18050 .find(|n| n.node_type == "caption")
18051 .expect("caption node missing after round-trip");
18052 let inlines = caption.content.as_ref().unwrap();
18053 let bold_node = inlines.iter().find(|n| {
18054 n.marks
18055 .as_ref()
18056 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18057 });
18058 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18059 }
18060
18061 #[test]
18064 fn table_caption_localid_roundtrip() {
18065 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18066 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18067 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18068 ]}]}"#;
18069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18070 let md = adf_to_markdown(&doc).unwrap();
18071 assert!(
18072 md.contains("localId=abcdef123456"),
18073 "table caption localId should appear in markdown: {md}"
18074 );
18075 let rt = markdown_to_adf(&md).unwrap();
18076 let caption = rt.content[0]
18077 .content
18078 .as_ref()
18079 .unwrap()
18080 .iter()
18081 .find(|n| n.node_type == "caption")
18082 .expect("caption should survive round-trip");
18083 assert_eq!(
18084 caption.attrs.as_ref().unwrap()["localId"],
18085 "abcdef123456",
18086 "table caption localId should round-trip"
18087 );
18088 }
18089
18090 #[test]
18091 fn table_caption_without_localid_unchanged() {
18092 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18093 let doc = markdown_to_adf(md).unwrap();
18094 let caption = doc.content[0]
18095 .content
18096 .as_ref()
18097 .unwrap()
18098 .iter()
18099 .find(|n| n.node_type == "caption")
18100 .unwrap();
18101 assert!(
18102 caption.attrs.is_none(),
18103 "table caption without localId should not gain attrs"
18104 );
18105 let md2 = adf_to_markdown(&doc).unwrap();
18106 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18107 }
18108
18109 #[test]
18110 fn table_caption_localid_stripped_when_option_set() {
18111 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18112 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18113 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18114 ]}]}"#;
18115 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18116 let opts = RenderOptions {
18117 strip_local_ids: true,
18118 ..Default::default()
18119 };
18120 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18121 assert!(
18122 !md.contains("localId"),
18123 "table caption localId should be stripped: {md}"
18124 );
18125 }
18126
18127 #[test]
18128 #[test]
18129 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18130 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"}]}]}]}]}]}"#;
18132 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18133 let md = adf_to_markdown(&doc).unwrap();
18134 let round_tripped = markdown_to_adf(&md).unwrap();
18135 let rows = round_tripped.content[0].content.as_ref().unwrap();
18136 let cell = &rows[0].content.as_ref().unwrap()[0];
18137 assert!(
18138 cell.attrs.is_some(),
18139 "tableCell attrs should be preserved, got None"
18140 );
18141 assert_eq!(
18142 cell.attrs.as_ref().unwrap(),
18143 &serde_json::json!({}),
18144 "tableCell attrs should be an empty object"
18145 );
18146 }
18147
18148 #[test]
18149 fn tablecell_empty_attrs_serialized_in_json() {
18150 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"}]}]}]}]}]}"#;
18152 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18153 let md = adf_to_markdown(&doc).unwrap();
18154 let round_tripped = markdown_to_adf(&md).unwrap();
18155 let json = serde_json::to_string(&round_tripped).unwrap();
18156 assert!(
18157 json.contains(r#""attrs":{}"#),
18158 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18159 );
18160 }
18161
18162 #[test]
18163 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18164 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"}]}]}]}]}]}"#;
18166 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18167 let md = adf_to_markdown(&doc).unwrap();
18168 assert!(
18170 md.contains("{} hello"),
18171 "cell with empty attrs should render '{{}} hello', got: {md}"
18172 );
18173 assert!(
18174 !md.contains("{} world"),
18175 "cell without attrs should not render '{{}}', got: {md}"
18176 );
18177 }
18178
18179 #[test]
18180 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18181 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"}]}]}]}]}]}"#;
18183 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18184 let md = adf_to_markdown(&doc).unwrap();
18185 let round_tripped = markdown_to_adf(&md).unwrap();
18186 let rows = round_tripped.content[0].content.as_ref().unwrap();
18187 let cell = &rows[0].content.as_ref().unwrap()[0];
18188 assert!(
18189 cell.attrs.is_none(),
18190 "tableCell without attrs should stay None, got: {:?}",
18191 cell.attrs
18192 );
18193 }
18194
18195 #[test]
18196 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18197 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"}]}]}]}]}]}"##;
18199 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18200 let md = adf_to_markdown(&doc).unwrap();
18201 let round_tripped = markdown_to_adf(&md).unwrap();
18202 let rows = round_tripped.content[0].content.as_ref().unwrap();
18203 let cell = &rows[1].content.as_ref().unwrap()[0];
18204 let attrs = cell.attrs.as_ref().unwrap();
18205 assert_eq!(attrs["background"], "#DEEBFF");
18206 assert_eq!(attrs["colspan"], 2);
18207 }
18208
18209 #[test]
18210 fn pipe_table_not_used_when_caption_present() {
18211 let doc = AdfDocument {
18212 version: 1,
18213 doc_type: "doc".to_string(),
18214 content: vec![AdfNode::table(vec![
18215 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18216 AdfNode::text("H"),
18217 ])])]),
18218 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18219 AdfNode::text("D"),
18220 ])])]),
18221 AdfNode::caption(vec![AdfNode::text("cap")]),
18222 ])],
18223 };
18224 let md = adf_to_markdown(&doc).unwrap();
18225 assert!(
18226 md.contains("::::table"),
18227 "pipe syntax should not be used when caption is present"
18228 );
18229 }
18230
18231 #[test]
18234 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18235 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18238 {"type":"listItem","content":[{"type":"paragraph","content":[
18239 {"type":"text","text":"1. First item"},
18240 {"type":"hardBreak"},
18241 {"type":"text","text":"2. Honouring existing commitments"}
18242 ]}]}
18243 ]}]}"#;
18244 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18245 let md = adf_to_markdown(&doc).unwrap();
18246
18247 assert!(
18249 md.contains(" 2. Honouring"),
18250 "Continuation line should be indented, got:\n{md}"
18251 );
18252
18253 let rt = markdown_to_adf(&md).unwrap();
18255 let list = &rt.content[0];
18256 assert_eq!(list.node_type, "bulletList");
18257 let items = list.content.as_ref().unwrap();
18258 assert_eq!(
18259 items.len(),
18260 1,
18261 "Should be one list item, got {}",
18262 items.len()
18263 );
18264
18265 let para = &items[0].content.as_ref().unwrap()[0];
18266 let inlines = para.content.as_ref().unwrap();
18267 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18268 assert_eq!(
18269 types,
18270 vec!["text", "hardBreak", "text"],
18271 "Expected text+hardBreak+text, got {types:?}"
18272 );
18273 assert_eq!(
18274 inlines[2].text.as_deref().unwrap(),
18275 "2. Honouring existing commitments"
18276 );
18277 }
18278
18279 #[test]
18280 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18281 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18283 {"type":"listItem","content":[{"type":"paragraph","content":[
18284 {"type":"text","text":"Introduction "},
18285 {"type":"hardBreak"},
18286 {"type":"text","text":"3. Third point"}
18287 ]}]}
18288 ]}]}"#;
18289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18290 let md = adf_to_markdown(&doc).unwrap();
18291 let rt = markdown_to_adf(&md).unwrap();
18292
18293 let list = &rt.content[0];
18294 assert_eq!(list.node_type, "orderedList");
18295 let items = list.content.as_ref().unwrap();
18296 assert_eq!(items.len(), 1);
18297
18298 let para = &items[0].content.as_ref().unwrap()[0];
18299 let inlines = para.content.as_ref().unwrap();
18300 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18301 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18302 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18303 }
18304
18305 #[test]
18306 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18307 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18309 {"type":"listItem","content":[{"type":"paragraph","content":[
18310 {"type":"text","text":"Header "},
18311 {"type":"hardBreak"},
18312 {"type":"text","text":"- not a sub-item"}
18313 ]}]}
18314 ]}]}"#;
18315 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18316 let md = adf_to_markdown(&doc).unwrap();
18317 let rt = markdown_to_adf(&md).unwrap();
18318
18319 let list = &rt.content[0];
18320 assert_eq!(list.node_type, "bulletList");
18321 let items = list.content.as_ref().unwrap();
18322 assert_eq!(
18323 items.len(),
18324 1,
18325 "Should be one list item, not {}",
18326 items.len()
18327 );
18328
18329 let para = &items[0].content.as_ref().unwrap()[0];
18330 let inlines = para.content.as_ref().unwrap();
18331 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18332 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18333 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18334 }
18335
18336 #[test]
18337 fn hardbreak_continuation_followed_by_sub_list() {
18338 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18340 {"type":"listItem","content":[
18341 {"type":"paragraph","content":[
18342 {"type":"text","text":"Main item "},
18343 {"type":"hardBreak"},
18344 {"type":"text","text":"continued here"}
18345 ]},
18346 {"type":"bulletList","content":[
18347 {"type":"listItem","content":[{"type":"paragraph","content":[
18348 {"type":"text","text":"sub-item"}
18349 ]}]}
18350 ]}
18351 ]}
18352 ]}]}"#;
18353 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18354 let md = adf_to_markdown(&doc).unwrap();
18355 let rt = markdown_to_adf(&md).unwrap();
18356
18357 let list = &rt.content[0];
18358 let items = list.content.as_ref().unwrap();
18359 assert_eq!(items.len(), 1);
18360
18361 let item_content = items[0].content.as_ref().unwrap();
18362 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18363 assert_eq!(item_content[0].node_type, "paragraph");
18364 assert_eq!(item_content[1].node_type, "bulletList");
18365
18366 let inlines = item_content[0].content.as_ref().unwrap();
18368 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18369 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18370 }
18371
18372 #[test]
18373 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18374 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18376 {"type":"listItem","content":[{"type":"paragraph","content":[
18377 {"type":"text","text":"Preamble "},
18378 {"type":"hardBreak"},
18379 {"type":"text","text":"1. Alpha "},
18380 {"type":"hardBreak"},
18381 {"type":"text","text":"2. Bravo"}
18382 ]}]}
18383 ]}]}"#;
18384 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18385 let md = adf_to_markdown(&doc).unwrap();
18386 let rt = markdown_to_adf(&md).unwrap();
18387
18388 let items = rt.content[0].content.as_ref().unwrap();
18389 assert_eq!(items.len(), 1);
18390
18391 let inlines = items[0].content.as_ref().unwrap()[0]
18392 .content
18393 .as_ref()
18394 .unwrap();
18395 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18396 assert_eq!(
18397 types,
18398 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18399 );
18400 }
18401
18402 #[test]
18403 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18404 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18408 {"type":"listItem","content":[{"type":"paragraph","content":[
18409 {"type":"text","text":"ends with break"},
18410 {"type":"hardBreak"}
18411 ]}]}
18412 ]}]}"#;
18413 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18414 let md = adf_to_markdown(&doc).unwrap();
18415 let rt = markdown_to_adf(&md).unwrap();
18416
18417 let list = &rt.content[0];
18418 assert_eq!(list.node_type, "bulletList");
18419 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18420 .content
18421 .as_ref()
18422 .unwrap();
18423 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18424 assert_eq!(types, vec!["text", "hardBreak"]);
18425 }
18426
18427 #[test]
18428 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18429 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18432 {"type":"listItem","content":[{"type":"paragraph","content":[
18433 {"type":"text","text":"ends with break"},
18434 {"type":"hardBreak"}
18435 ]}]}
18436 ]}]}"#;
18437 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18438 let md = adf_to_markdown(&doc).unwrap();
18439 let rt = markdown_to_adf(&md).unwrap();
18440
18441 let list = &rt.content[0];
18442 assert_eq!(list.node_type, "orderedList");
18443 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18444 .content
18445 .as_ref()
18446 .unwrap();
18447 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18448 assert_eq!(types, vec!["text", "hardBreak"]);
18449 }
18450
18451 #[test]
18452 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18453 let md = "- first line \n 2. continued\n";
18457 let doc = markdown_to_adf(md).unwrap();
18458
18459 let list = &doc.content[0];
18460 assert_eq!(list.node_type, "bulletList");
18461 let items = list.content.as_ref().unwrap();
18462 assert_eq!(
18463 items.len(),
18464 1,
18465 "Should be one list item, got {}",
18466 items.len()
18467 );
18468
18469 let para = &items[0].content.as_ref().unwrap()[0];
18470 let inlines = para.content.as_ref().unwrap();
18471 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18472 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18473 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18474 }
18475
18476 #[test]
18477 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18478 let md = "1. first line \n - continued\n";
18481 let doc = markdown_to_adf(md).unwrap();
18482
18483 let list = &doc.content[0];
18484 assert_eq!(list.node_type, "orderedList");
18485 let items = list.content.as_ref().unwrap();
18486 assert_eq!(
18487 items.len(),
18488 1,
18489 "Should be one list item, got {}",
18490 items.len()
18491 );
18492
18493 let para = &items[0].content.as_ref().unwrap()[0];
18494 let inlines = para.content.as_ref().unwrap();
18495 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18496 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18497 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18498 }
18499
18500 #[test]
18501 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18502 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18505 {"type":"listItem","content":[
18506 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18507 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18508 ]}
18509 ]}]}"#;
18510 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18511 let md = adf_to_markdown(&doc).unwrap();
18512 let rt = markdown_to_adf(&md).unwrap();
18513
18514 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18515 let list = &rt.content[0];
18516 assert_eq!(list.node_type, "bulletList");
18517 let items = list.content.as_ref().unwrap();
18518 assert_eq!(items.len(), 1);
18519 let item_content = items[0].content.as_ref().unwrap();
18520 assert_eq!(
18521 item_content.len(),
18522 2,
18523 "Expected 2 paragraphs inside the list item, got {}",
18524 item_content.len()
18525 );
18526 assert_eq!(item_content[0].node_type, "paragraph");
18527 assert_eq!(item_content[1].node_type, "paragraph");
18528 let text = item_content[1].content.as_ref().unwrap()[0]
18529 .text
18530 .as_deref()
18531 .unwrap();
18532 assert_eq!(text, "2. Honouring existing commitments");
18533 }
18534
18535 #[test]
18536 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18537 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18539 {"type":"listItem","content":[
18540 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18541 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18542 ]}
18543 ]}]}"#;
18544 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18545 let md = adf_to_markdown(&doc).unwrap();
18546 let rt = markdown_to_adf(&md).unwrap();
18547
18548 let items = rt.content[0].content.as_ref().unwrap();
18549 assert_eq!(items.len(), 1);
18550 let item_content = items[0].content.as_ref().unwrap();
18551 assert_eq!(item_content.len(), 2);
18552 assert_eq!(item_content[1].node_type, "paragraph");
18553 let text = item_content[1].content.as_ref().unwrap()[0]
18554 .text
18555 .as_deref()
18556 .unwrap();
18557 assert_eq!(text, "- not a sub-item");
18558 }
18559
18560 #[test]
18561 fn backslash_escape_in_inline_text() {
18562 let nodes = parse_inline(r"2\. text");
18564 assert_eq!(nodes.len(), 1, "Should be one text node");
18565 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18566 }
18567
18568 #[test]
18569 fn escape_list_marker_ordered() {
18570 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18571 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18572 }
18573
18574 #[test]
18575 fn escape_list_marker_bullet() {
18576 assert_eq!(escape_list_marker("- text"), r"\- text");
18577 assert_eq!(escape_list_marker("* text"), r"\* text");
18578 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18579 }
18580
18581 #[test]
18582 fn escape_list_marker_plain() {
18583 assert_eq!(escape_list_marker("plain text"), "plain text");
18584 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18585 }
18586
18587 #[test]
18588 fn escape_emoji_shortcodes_basic() {
18589 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18590 assert_eq!(
18591 escape_emoji_shortcodes("hello :wave: world"),
18592 r"hello \:wave: world"
18593 );
18594 }
18595
18596 #[test]
18597 fn escape_emoji_shortcodes_double_colon() {
18598 assert_eq!(
18600 escape_emoji_shortcodes("Status::Active::Running"),
18601 r"Status:\:Active::Running"
18602 );
18603 }
18604
18605 #[test]
18606 fn escape_emoji_shortcodes_no_match() {
18607 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18609 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18610 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18611 assert_eq!(escape_emoji_shortcodes(":"), ":");
18612 }
18613
18614 #[test]
18615 fn escape_emoji_shortcodes_mixed() {
18616 assert_eq!(
18617 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18618 r"Alert \:fire: on pod:pod42"
18619 );
18620 }
18621
18622 #[test]
18623 fn escape_emoji_shortcodes_unicode() {
18624 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18629 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18630 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18631 assert_eq!(
18632 escape_emoji_shortcodes("ZBC::配置::Production"),
18633 r"ZBC:\:配置::Production"
18634 );
18635 }
18636
18637 #[test]
18638 fn escape_emoji_shortcodes_mixed_script_name() {
18639 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18642 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18643 }
18644
18645 #[test]
18646 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18647 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18652 }
18653
18654 #[test]
18655 fn escape_emoji_shortcodes_name_runs_to_end() {
18656 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18662 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18663 }
18664
18665 #[test]
18666 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18667 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18670 {"type":"text","text":"Visit :Café: today"}
18671 ]}]}"#;
18672 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18673
18674 let md = adf_to_markdown(&doc).unwrap();
18675 let round_tripped = markdown_to_adf(&md).unwrap();
18676 let content = round_tripped.content[0].content.as_ref().unwrap();
18677
18678 assert_eq!(
18679 content.len(),
18680 1,
18681 "should be a single text node, got: {content:?}"
18682 );
18683 assert_eq!(content[0].node_type, "text");
18684 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18685 }
18686
18687 #[test]
18688 fn unicode_double_colon_pattern_text_round_trips() {
18689 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18691 {"type":"text","text":"Use ZBC::配置::Production for prod"}
18692 ]}]}"#;
18693 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18694
18695 let md = adf_to_markdown(&doc).unwrap();
18696 let round_tripped = markdown_to_adf(&md).unwrap();
18697 let content = round_tripped.content[0].content.as_ref().unwrap();
18698
18699 assert_eq!(
18700 content.len(),
18701 1,
18702 "should be a single text node, got: {content:?}"
18703 );
18704 assert_eq!(
18705 content[0].text.as_deref().unwrap(),
18706 "Use ZBC::配置::Production for prod"
18707 );
18708 }
18709
18710 #[test]
18711 fn merge_adjacent_text_nodes() {
18712 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18713 merge_adjacent_text(&mut nodes);
18714 assert_eq!(nodes.len(), 1);
18715 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18716 }
18717
18718 #[test]
18721 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18722 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18725 {"type":"text","text":"Introduction: "},
18726 {"type":"hardBreak"},
18727 {"type":"text","text":"1. This text follows a hardBreak"}
18728 ]}]}"#;
18729 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18730 let md = adf_to_markdown(&doc).unwrap();
18731 let rt = markdown_to_adf(&md).unwrap();
18732
18733 assert_eq!(rt.content.len(), 1, "Should remain one block");
18734 assert_eq!(rt.content[0].node_type, "paragraph");
18735 let inlines = rt.content[0].content.as_ref().unwrap();
18736 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18737 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18738 assert_eq!(
18739 inlines[2].text.as_deref(),
18740 Some("1. This text follows a hardBreak")
18741 );
18742 }
18743
18744 #[test]
18745 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18746 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18748 {"type":"text","text":"Intro"},
18749 {"type":"hardBreak"},
18750 {"type":"text","text":"- not a list item"}
18751 ]}]}"#;
18752 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18753 let md = adf_to_markdown(&doc).unwrap();
18754 let rt = markdown_to_adf(&md).unwrap();
18755
18756 assert_eq!(rt.content.len(), 1);
18757 assert_eq!(rt.content[0].node_type, "paragraph");
18758 let inlines = rt.content[0].content.as_ref().unwrap();
18759 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18760 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18761 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18762 }
18763
18764 #[test]
18765 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18766 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18768 {"type":"text","text":"Intro"},
18769 {"type":"hardBreak"},
18770 {"type":"text","text":"# not a heading"}
18771 ]}]}"##;
18772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18773 let md = adf_to_markdown(&doc).unwrap();
18774 let rt = markdown_to_adf(&md).unwrap();
18775
18776 assert_eq!(rt.content.len(), 1);
18777 assert_eq!(rt.content[0].node_type, "paragraph");
18778 let inlines = rt.content[0].content.as_ref().unwrap();
18779 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18780 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18781 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18782 }
18783
18784 #[test]
18785 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18786 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18788 {"type":"text","text":"Intro"},
18789 {"type":"hardBreak"},
18790 {"type":"text","text":"> not a blockquote"}
18791 ]}]}"#;
18792 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18793 let md = adf_to_markdown(&doc).unwrap();
18794 let rt = markdown_to_adf(&md).unwrap();
18795
18796 assert_eq!(rt.content.len(), 1);
18797 assert_eq!(rt.content[0].node_type, "paragraph");
18798 let inlines = rt.content[0].content.as_ref().unwrap();
18799 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18800 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18801 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18802 }
18803
18804 #[test]
18805 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18806 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18808 {"type":"text","text":"Preamble"},
18809 {"type":"hardBreak"},
18810 {"type":"text","text":"1. First"},
18811 {"type":"hardBreak"},
18812 {"type":"text","text":"2. Second"},
18813 {"type":"hardBreak"},
18814 {"type":"text","text":"3. Third"}
18815 ]}]}"#;
18816 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18817 let md = adf_to_markdown(&doc).unwrap();
18818 let rt = markdown_to_adf(&md).unwrap();
18819
18820 assert_eq!(rt.content.len(), 1);
18821 assert_eq!(rt.content[0].node_type, "paragraph");
18822 let inlines = rt.content[0].content.as_ref().unwrap();
18823 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18824 assert_eq!(
18825 types,
18826 vec![
18827 "text",
18828 "hardBreak",
18829 "text",
18830 "hardBreak",
18831 "text",
18832 "hardBreak",
18833 "text"
18834 ]
18835 );
18836 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18837 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18838 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18839 }
18840
18841 #[test]
18842 fn issue_455_paragraph_hardbreak_jfm_indentation() {
18843 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18845 {"type":"text","text":"Intro"},
18846 {"type":"hardBreak"},
18847 {"type":"text","text":"1. continued"}
18848 ]}]}"#;
18849 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18850 let md = adf_to_markdown(&doc).unwrap();
18851 assert!(
18852 md.contains("Intro\\\n 1. continued"),
18853 "Continuation should be 2-space-indented, got: {md:?}"
18854 );
18855 }
18856
18857 #[test]
18858 fn issue_455_paragraph_hardbreak_from_jfm() {
18859 let md = "Intro\\\n 1. This is continuation text\n";
18862 let doc = markdown_to_adf(md).unwrap();
18863
18864 assert_eq!(doc.content.len(), 1);
18865 assert_eq!(doc.content[0].node_type, "paragraph");
18866 let inlines = doc.content[0].content.as_ref().unwrap();
18867 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18868 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18869 assert_eq!(
18870 inlines[2].text.as_deref(),
18871 Some("1. This is continuation text")
18872 );
18873 }
18874
18875 #[test]
18876 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18877 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18881 {"type":"text","text":"1. Starting with a number"},
18882 {"type":"hardBreak"},
18883 {"type":"text","text":"continuation after break"}
18884 ]}]}"#;
18885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18886 let md = adf_to_markdown(&doc).unwrap();
18887 assert!(
18889 md.contains(r"1\. Starting with a number"),
18890 "First line should have escaped list marker, got: {md:?}"
18891 );
18892 let rt = markdown_to_adf(&md).unwrap();
18893
18894 assert_eq!(rt.content.len(), 1);
18895 assert_eq!(rt.content[0].node_type, "paragraph");
18896 let inlines = rt.content[0].content.as_ref().unwrap();
18897 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18898 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18899 assert_eq!(
18900 inlines[0].text.as_deref(),
18901 Some("1. Starting with a number")
18902 );
18903 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18904 }
18905
18906 #[test]
18907 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
18908 let adf_json = r#"{"version":1,"type":"doc","content":[{
18911 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
18912 "content":[{"type":"tableRow","content":[{
18913 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
18914 "content":[{"type":"paragraph","content":[
18915 {"type":"text","text":"2. Honouring existing commitments"}
18916 ]}]
18917 }]}]
18918 }]}"#;
18919 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18920 let md = adf_to_markdown(&doc).unwrap();
18921 let rt = markdown_to_adf(&md).unwrap();
18922
18923 let table = &rt.content[0];
18924 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
18925 let para = &cell.content.as_ref().unwrap()[0];
18926 assert_eq!(para.node_type, "paragraph");
18927 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18928 assert_eq!(text, "2. Honouring existing commitments");
18929 }
18930
18931 #[test]
18932 fn bullet_marker_paragraph_standalone_roundtrips() {
18933 let adf_json = r#"{"version":1,"type":"doc","content":[
18936 {"type":"paragraph","content":[
18937 {"type":"text","text":"- not a list item"}
18938 ]}
18939 ]}"#;
18940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18941 let md = adf_to_markdown(&doc).unwrap();
18942 assert!(
18943 md.contains(r"\- not a list item"),
18944 "Should escape the leading dash, got:\n{md}"
18945 );
18946 let rt = markdown_to_adf(&md).unwrap();
18947 assert_eq!(rt.content[0].node_type, "paragraph");
18948 let text = rt.content[0].content.as_ref().unwrap()[0]
18949 .text
18950 .as_deref()
18951 .unwrap();
18952 assert_eq!(text, "- not a list item");
18953 }
18954
18955 #[test]
18956 fn merge_adjacent_text_skips_non_text_nodes() {
18957 let mut nodes = vec![
18960 AdfNode::text("a"),
18961 AdfNode::hard_break(),
18962 AdfNode::text("b"),
18963 ];
18964 merge_adjacent_text(&mut nodes);
18965 assert_eq!(nodes.len(), 3);
18966 }
18967
18968 #[test]
18969 fn star_bullet_paragraph_roundtrips() {
18970 let adf_json = r#"{"version":1,"type":"doc","content":[
18973 {"type":"paragraph","content":[
18974 {"type":"text","text":"* starred"}
18975 ]}
18976 ]}"#;
18977 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18978 let md = adf_to_markdown(&doc).unwrap();
18979 let rt = markdown_to_adf(&md).unwrap();
18980 assert_eq!(rt.content[0].node_type, "paragraph");
18981 assert_eq!(
18982 rt.content[0].content.as_ref().unwrap()[0]
18983 .text
18984 .as_deref()
18985 .unwrap(),
18986 "* starred"
18987 );
18988 }
18989
18990 #[test]
18993 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
18994 let adf_json = r#"{"version":1,"type":"doc","content":[
18997 {"type":"orderedList","attrs":{"order":1},"content":[
18998 {"type":"listItem","content":[
18999 {"type":"paragraph","content":[
19000 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19001 {"type":"hardBreak"},
19002 {"type":"text","text":"Content after break"}
19003 ]}
19004 ]},
19005 {"type":"listItem","content":[
19006 {"type":"paragraph","content":[
19007 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19008 {"type":"hardBreak"},
19009 {"type":"text","text":"More content"}
19010 ]}
19011 ]}
19012 ]}
19013 ]}"#;
19014 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19015 let md = adf_to_markdown(&doc).unwrap();
19016 let rt = markdown_to_adf(&md).unwrap();
19017
19018 assert_eq!(
19020 rt.content.len(),
19021 1,
19022 "Should be 1 block (orderedList), got {}",
19023 rt.content.len()
19024 );
19025 assert_eq!(rt.content[0].node_type, "orderedList");
19026 let items = rt.content[0].content.as_ref().unwrap();
19027 assert_eq!(
19028 items.len(),
19029 2,
19030 "Should have 2 listItems, got {}",
19031 items.len()
19032 );
19033
19034 let p1 = items[0].content.as_ref().unwrap()[0]
19036 .content
19037 .as_ref()
19038 .unwrap();
19039 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19040 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19041 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19042 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19043
19044 let p2 = items[1].content.as_ref().unwrap()[0]
19046 .content
19047 .as_ref()
19048 .unwrap();
19049 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19050 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19051 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19052 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19053 }
19054
19055 #[test]
19056 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19057 let adf_json = r#"{"version":1,"type":"doc","content":[
19059 {"type":"bulletList","content":[
19060 {"type":"listItem","content":[
19061 {"type":"paragraph","content":[
19062 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19063 {"type":"hardBreak"},
19064 {"type":"text","text":"details"}
19065 ]}
19066 ]},
19067 {"type":"listItem","content":[
19068 {"type":"paragraph","content":[
19069 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19070 {"type":"hardBreak"},
19071 {"type":"text","text":"more details"}
19072 ]}
19073 ]}
19074 ]}
19075 ]}"#;
19076 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19077 let md = adf_to_markdown(&doc).unwrap();
19078 let rt = markdown_to_adf(&md).unwrap();
19079
19080 assert_eq!(rt.content.len(), 1);
19081 assert_eq!(rt.content[0].node_type, "bulletList");
19082 let items = rt.content[0].content.as_ref().unwrap();
19083 assert_eq!(items.len(), 2);
19084
19085 let p1 = items[0].content.as_ref().unwrap()[0]
19086 .content
19087 .as_ref()
19088 .unwrap();
19089 assert_eq!(p1[0].text.as_deref(), Some("First"));
19090 assert_eq!(p1[2].text.as_deref(), Some("details"));
19091
19092 let p2 = items[1].content.as_ref().unwrap()[0]
19093 .content
19094 .as_ref()
19095 .unwrap();
19096 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19097 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19098 }
19099
19100 #[test]
19101 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19102 let adf_json = r#"{"version":1,"type":"doc","content":[
19104 {"type":"orderedList","attrs":{"order":1},"content":[
19105 {"type":"listItem","content":[
19106 {"type":"paragraph","content":[
19107 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19108 {"type":"hardBreak"},
19109 {"type":"text","text":"body"}
19110 ]}
19111 ]}
19112 ]}
19113 ]}"#;
19114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19115 let md = adf_to_markdown(&doc).unwrap();
19116 assert!(
19117 md.contains("1. **heading**\\\n body"),
19118 "Continuation should be indented, got:\n{md}"
19119 );
19120 }
19121
19122 #[test]
19123 fn issue_388_ordered_list_hardbreak_from_jfm() {
19124 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19126 let doc = markdown_to_adf(md).unwrap();
19127
19128 assert_eq!(doc.content.len(), 1);
19129 assert_eq!(doc.content[0].node_type, "orderedList");
19130 let items = doc.content[0].content.as_ref().unwrap();
19131 assert_eq!(items.len(), 2);
19132
19133 let p1 = items[0].content.as_ref().unwrap()[0]
19134 .content
19135 .as_ref()
19136 .unwrap();
19137 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19138 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19139 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19140 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19141
19142 let p2 = items[1].content.as_ref().unwrap()[0]
19143 .content
19144 .as_ref()
19145 .unwrap();
19146 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19147 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19148 }
19149
19150 #[test]
19151 fn issue_388_bullet_list_hardbreak_from_jfm() {
19152 let md = "- first\\\n second\n- third\\\n fourth\n";
19154 let doc = markdown_to_adf(md).unwrap();
19155
19156 assert_eq!(doc.content.len(), 1);
19157 assert_eq!(doc.content[0].node_type, "bulletList");
19158 let items = doc.content[0].content.as_ref().unwrap();
19159 assert_eq!(items.len(), 2);
19160
19161 for (i, expected) in [("first", "second"), ("third", "fourth")]
19162 .iter()
19163 .enumerate()
19164 {
19165 let p = items[i].content.as_ref().unwrap()[0]
19166 .content
19167 .as_ref()
19168 .unwrap();
19169 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19170 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19171 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19172 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19173 }
19174 }
19175
19176 #[test]
19177 fn issue_433_heading_hardbreak_roundtrips() {
19178 let adf_json = r#"{"version":1,"type":"doc","content":[{
19180 "type":"heading",
19181 "attrs":{"level":1},
19182 "content":[
19183 {"type":"text","text":"Line one"},
19184 {"type":"hardBreak"},
19185 {"type":"text","text":"Line two"}
19186 ]
19187 }]}"#;
19188 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19189 let md = adf_to_markdown(&doc).unwrap();
19190 let rt = markdown_to_adf(&md).unwrap();
19191
19192 assert_eq!(
19193 rt.content.len(),
19194 1,
19195 "Should remain a single heading, got {} blocks",
19196 rt.content.len()
19197 );
19198 assert_eq!(rt.content[0].node_type, "heading");
19199 let inlines = rt.content[0].content.as_ref().unwrap();
19200 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19201 assert_eq!(
19202 types,
19203 vec!["text", "hardBreak", "text"],
19204 "hardBreak should be preserved, got: {types:?}"
19205 );
19206 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19207 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19208 }
19209
19210 #[test]
19211 fn issue_433_heading_hardbreak_jfm_indentation() {
19212 let adf_json = r#"{"version":1,"type":"doc","content":[{
19214 "type":"heading",
19215 "attrs":{"level":2},
19216 "content":[
19217 {"type":"text","text":"Title"},
19218 {"type":"hardBreak"},
19219 {"type":"text","text":"Subtitle"}
19220 ]
19221 }]}"#;
19222 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19223 let md = adf_to_markdown(&doc).unwrap();
19224 assert!(
19225 md.contains("## Title\\\n Subtitle"),
19226 "Continuation should be indented, got:\n{md}"
19227 );
19228 }
19229
19230 #[test]
19231 fn issue_433_heading_hardbreak_from_jfm() {
19232 let md = "# First\\\n Second\n";
19234 let doc = markdown_to_adf(md).unwrap();
19235
19236 assert_eq!(doc.content.len(), 1);
19237 assert_eq!(doc.content[0].node_type, "heading");
19238 let inlines = doc.content[0].content.as_ref().unwrap();
19239 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19240 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19241 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19242 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19243 }
19244
19245 #[test]
19246 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19247 let adf_json = r#"{"version":1,"type":"doc","content":[{
19249 "type":"heading",
19250 "attrs":{"level":3},
19251 "content":[
19252 {"type":"text","text":"A"},
19253 {"type":"hardBreak"},
19254 {"type":"hardBreak"},
19255 {"type":"text","text":"B"}
19256 ]
19257 }]}"#;
19258 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19259 let md = adf_to_markdown(&doc).unwrap();
19260 let rt = markdown_to_adf(&md).unwrap();
19261
19262 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19263 assert_eq!(rt.content[0].node_type, "heading");
19264 let inlines = rt.content[0].content.as_ref().unwrap();
19265 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19266 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19267 }
19268
19269 #[test]
19270 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19271 let adf_json = r#"{"version":1,"type":"doc","content":[{
19273 "type":"heading",
19274 "attrs":{"level":1},
19275 "content":[
19276 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19277 {"type":"hardBreak"},
19278 {"type":"text","text":"plain continuation"}
19279 ]
19280 }]}"#;
19281 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19282 let md = adf_to_markdown(&doc).unwrap();
19283 let rt = markdown_to_adf(&md).unwrap();
19284
19285 assert_eq!(rt.content.len(), 1);
19286 assert_eq!(rt.content[0].node_type, "heading");
19287 let inlines = rt.content[0].content.as_ref().unwrap();
19288 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19289 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19290 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19291 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19292 }
19293
19294 #[test]
19295 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19296 let adf_json = r#"{"version":1,"type":"doc","content":[{
19298 "type":"heading",
19299 "attrs":{"level":1},
19300 "content":[
19301 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19302 {"type":"hardBreak"},
19303 {"type":"text","text":"Subtitle text"}
19304 ]
19305 }]}"#;
19306 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19307 let md = adf_to_markdown(&doc).unwrap();
19308 let rt = markdown_to_adf(&md).unwrap();
19309
19310 assert_eq!(rt.content.len(), 1);
19311 assert_eq!(rt.content[0].node_type, "heading");
19312 let inlines = rt.content[0].content.as_ref().unwrap();
19313 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19314 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19315 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19316 }
19317
19318 #[test]
19319 fn has_trailing_hard_break_backslash() {
19320 assert!(has_trailing_hard_break("text\\"));
19321 assert!(has_trailing_hard_break("**bold**\\"));
19322 }
19323
19324 #[test]
19325 fn has_trailing_hard_break_trailing_spaces() {
19326 assert!(has_trailing_hard_break("text "));
19327 assert!(has_trailing_hard_break("word "));
19328 }
19329
19330 #[test]
19331 fn has_trailing_hard_break_false() {
19332 assert!(!has_trailing_hard_break("plain text"));
19333 assert!(!has_trailing_hard_break("text "));
19334 assert!(!has_trailing_hard_break(""));
19335 }
19336
19337 #[test]
19338 fn collect_hardbreak_continuations_collects_indented() {
19339 let input = "first\\\n second\n third\n";
19342 let mut parser = MarkdownParser::new(input);
19343 parser.advance(); let mut text = "first\\".to_string();
19345 parser.collect_hardbreak_continuations(&mut text);
19346 assert_eq!(text, "first\\\nsecond");
19347 }
19348
19349 #[test]
19350 fn collect_hardbreak_continuations_stops_at_non_indented() {
19351 let input = "first\\\nnot indented\n";
19352 let mut parser = MarkdownParser::new(input);
19353 parser.advance();
19354 let mut text = "first\\".to_string();
19355 parser.collect_hardbreak_continuations(&mut text);
19356 assert_eq!(text, "first\\");
19358 }
19359
19360 #[test]
19361 fn collect_hardbreak_continuations_no_trailing_break() {
19362 let input = "plain\n indented\n";
19364 let mut parser = MarkdownParser::new(input);
19365 parser.advance();
19366 let mut text = "plain".to_string();
19367 parser.collect_hardbreak_continuations(&mut text);
19368 assert_eq!(text, "plain");
19369 }
19370
19371 #[test]
19372 fn collect_hardbreak_continuations_chained() {
19373 let input = "a\\\n b\\\n c\\\n d\n";
19375 let mut parser = MarkdownParser::new(input);
19376 parser.advance();
19377 let mut text = "a\\".to_string();
19378 parser.collect_hardbreak_continuations(&mut text);
19379 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19380 }
19381
19382 #[test]
19383 fn collect_hardbreak_continuations_stops_before_image_line() {
19384 let input = "text\\\n {type=file id=x}\n";
19387 let mut parser = MarkdownParser::new(input);
19388 parser.advance(); let mut text = "text\\".to_string();
19390 parser.collect_hardbreak_continuations(&mut text);
19391 assert_eq!(text, "text\\");
19393 assert!(!parser.at_end());
19395 assert!(parser.current_line().contains(""));
19396 }
19397
19398 #[test]
19399 fn is_block_level_continuation_marker_positive_cases() {
19400 assert!(is_block_level_continuation_marker(""));
19402 assert!(is_block_level_continuation_marker("```ruby"));
19403 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19404 }
19405
19406 #[test]
19407 fn is_block_level_continuation_marker_negative_cases() {
19408 assert!(!is_block_level_continuation_marker("plain text"));
19410 assert!(!is_block_level_continuation_marker("- nested item"));
19411 assert!(!is_block_level_continuation_marker("continuation\\"));
19412 assert!(!is_block_level_continuation_marker(""));
19413 assert!(!is_block_level_continuation_marker("::partial"));
19415 assert!(!is_block_level_continuation_marker("`inline`"));
19417 }
19418
19419 #[test]
19420 fn collect_hardbreak_continuations_stops_before_code_fence() {
19421 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19425 let mut parser = MarkdownParser::new(input);
19426 parser.advance();
19427 let mut text = "text\\".to_string();
19428 parser.collect_hardbreak_continuations(&mut text);
19429 assert_eq!(text, "text\\");
19430 assert!(!parser.at_end());
19431 assert!(parser.current_line().starts_with(" ```"));
19432 }
19433
19434 #[test]
19435 fn collect_hardbreak_continuations_stops_before_container_directive() {
19436 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19440 let mut parser = MarkdownParser::new(input);
19441 parser.advance();
19442 let mut text = "text\\".to_string();
19443 parser.collect_hardbreak_continuations(&mut text);
19444 assert_eq!(text, "text\\");
19445 assert!(!parser.at_end());
19446 assert!(parser.current_line().contains(":::panel"));
19447 }
19448
19449 #[test]
19450 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19451 let input = "text\\\n ```text\n :fire:\n ```\n";
19455 let mut parser = MarkdownParser::new(input);
19456 parser.advance();
19457 let mut text = "text\\".to_string();
19458 parser.collect_hardbreak_continuations(&mut text);
19459 assert_eq!(text, "text\\");
19460 assert!(!parser.at_end());
19461 assert!(parser.current_line().contains("```text"));
19462 }
19463
19464 #[test]
19465 fn ordered_list_with_sub_content_after_hardbreak() {
19466 let adf_json = r#"{"version":1,"type":"doc","content":[
19469 {"type":"orderedList","attrs":{"order":1},"content":[
19470 {"type":"listItem","content":[
19471 {"type":"paragraph","content":[
19472 {"type":"text","text":"parent"},
19473 {"type":"hardBreak"},
19474 {"type":"text","text":"continued"}
19475 ]},
19476 {"type":"bulletList","content":[
19477 {"type":"listItem","content":[
19478 {"type":"paragraph","content":[
19479 {"type":"text","text":"child"}
19480 ]}
19481 ]}
19482 ]}
19483 ]}
19484 ]}
19485 ]}"#;
19486 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19487 let md = adf_to_markdown(&doc).unwrap();
19488 let rt = markdown_to_adf(&md).unwrap();
19489
19490 assert_eq!(rt.content.len(), 1);
19491 assert_eq!(rt.content[0].node_type, "orderedList");
19492 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19493 .content
19494 .as_ref()
19495 .unwrap();
19496 let p = item_content[0].content.as_ref().unwrap();
19498 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19499 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19500 assert_eq!(p[0].text.as_deref(), Some("parent"));
19501 assert_eq!(p[2].text.as_deref(), Some("continued"));
19502 assert_eq!(item_content[1].node_type, "bulletList");
19504 }
19505
19506 #[test]
19507 fn render_list_item_content_no_content() {
19508 let item = AdfNode {
19510 node_type: "listItem".to_string(),
19511 attrs: None,
19512 content: None,
19513 text: None,
19514 marks: None,
19515 local_id: None,
19516 parameters: None,
19517 };
19518 let mut output = String::new();
19519 let opts = RenderOptions::default();
19520 render_list_item_content(&item, &mut output, &opts);
19521 assert_eq!(output, "\n");
19522 }
19523
19524 #[test]
19525 fn render_list_item_content_empty_content() {
19526 let item = AdfNode::list_item(vec![]);
19528 let mut output = String::new();
19529 let opts = RenderOptions::default();
19530 render_list_item_content(&item, &mut output, &opts);
19531 assert_eq!(output, "\n");
19532 }
19533
19534 #[test]
19535 fn plus_bullet_paragraph_roundtrips() {
19536 let adf_json = r#"{"version":1,"type":"doc","content":[
19539 {"type":"paragraph","content":[
19540 {"type":"text","text":"+ plus"}
19541 ]}
19542 ]}"#;
19543 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19544 let md = adf_to_markdown(&doc).unwrap();
19545 let rt = markdown_to_adf(&md).unwrap();
19546 assert_eq!(rt.content[0].node_type, "paragraph");
19547 assert_eq!(
19548 rt.content[0].content.as_ref().unwrap()[0]
19549 .text
19550 .as_deref()
19551 .unwrap(),
19552 "+ plus"
19553 );
19554 }
19555
19556 #[test]
19559 fn issue_430_file_media_in_bullet_list_roundtrip() {
19560 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19563 {"type":"listItem","content":[{
19564 "type":"mediaSingle",
19565 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19566 "content":[{
19567 "type":"media",
19568 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19569 }]
19570 }]}
19571 ]}]}"#;
19572 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19573 let md = adf_to_markdown(&doc).unwrap();
19574 let rt = markdown_to_adf(&md).unwrap();
19575
19576 let list = &rt.content[0];
19577 assert_eq!(list.node_type, "bulletList");
19578 let item = &list.content.as_ref().unwrap()[0];
19579 assert_eq!(item.node_type, "listItem");
19580 let ms = &item.content.as_ref().unwrap()[0];
19581 assert_eq!(ms.node_type, "mediaSingle");
19582 let ms_attrs = ms.attrs.as_ref().unwrap();
19583 assert_eq!(ms_attrs["layout"], "center");
19584 assert_eq!(ms_attrs["width"], 1009);
19585 assert_eq!(ms_attrs["widthType"], "pixel");
19586 let media = &ms.content.as_ref().unwrap()[0];
19587 assert_eq!(media.node_type, "media");
19588 let m_attrs = media.attrs.as_ref().unwrap();
19589 assert_eq!(m_attrs["type"], "file");
19590 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19591 assert_eq!(m_attrs["collection"], "contentId-123");
19592 assert_eq!(m_attrs["height"], 576);
19593 assert_eq!(m_attrs["width"], 1009);
19594 }
19595
19596 #[test]
19597 fn issue_430_file_media_in_ordered_list_roundtrip() {
19598 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19600 {"type":"listItem","content":[{
19601 "type":"mediaSingle",
19602 "attrs":{"layout":"center"},
19603 "content":[{
19604 "type":"media",
19605 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19606 }]
19607 }]}
19608 ]}]}"#;
19609 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19610 let md = adf_to_markdown(&doc).unwrap();
19611 let rt = markdown_to_adf(&md).unwrap();
19612
19613 let list = &rt.content[0];
19614 assert_eq!(list.node_type, "orderedList");
19615 let item = &list.content.as_ref().unwrap()[0];
19616 assert_eq!(item.node_type, "listItem");
19617 let ms = &item.content.as_ref().unwrap()[0];
19618 assert_eq!(ms.node_type, "mediaSingle");
19619 let media = &ms.content.as_ref().unwrap()[0];
19620 assert_eq!(media.node_type, "media");
19621 let m_attrs = media.attrs.as_ref().unwrap();
19622 assert_eq!(m_attrs["type"], "file");
19623 assert_eq!(m_attrs["id"], "abc-123");
19624 assert_eq!(m_attrs["collection"], "contentId-456");
19625 }
19626
19627 #[test]
19628 fn issue_430_external_media_in_bullet_list_roundtrip() {
19629 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19631 {"type":"listItem","content":[{
19632 "type":"mediaSingle",
19633 "attrs":{"layout":"center"},
19634 "content":[{
19635 "type":"media",
19636 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19637 }]
19638 }]}
19639 ]}]}"#;
19640 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19641 let md = adf_to_markdown(&doc).unwrap();
19642 let rt = markdown_to_adf(&md).unwrap();
19643
19644 let list = &rt.content[0];
19645 assert_eq!(list.node_type, "bulletList");
19646 let item = &list.content.as_ref().unwrap()[0];
19647 let ms = &item.content.as_ref().unwrap()[0];
19648 assert_eq!(ms.node_type, "mediaSingle");
19649 let media = &ms.content.as_ref().unwrap()[0];
19650 assert_eq!(media.node_type, "media");
19651 let m_attrs = media.attrs.as_ref().unwrap();
19652 assert_eq!(m_attrs["type"], "external");
19653 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19654 }
19655
19656 #[test]
19657 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19658 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19661 {"type":"listItem","content":[
19662 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19663 {"type":"mediaSingle","attrs":{"layout":"center"},
19664 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19665 ]}
19666 ]}]}"#;
19667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19668 let md = adf_to_markdown(&doc).unwrap();
19669 let rt = markdown_to_adf(&md).unwrap();
19670
19671 let item = &rt.content[0].content.as_ref().unwrap()[0];
19672 let children = item.content.as_ref().unwrap();
19673 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19674 assert_eq!(children[0].node_type, "paragraph");
19675 assert_eq!(children[1].node_type, "mediaSingle");
19676 let media = &children[1].content.as_ref().unwrap()[0];
19677 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19678 }
19679
19680 #[test]
19681 fn issue_430_multiple_media_in_list_items() {
19682 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19684 {"type":"listItem","content":[{
19685 "type":"mediaSingle","attrs":{"layout":"center"},
19686 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19687 }]},
19688 {"type":"listItem","content":[{
19689 "type":"mediaSingle","attrs":{"layout":"center"},
19690 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19691 }]}
19692 ]}]}"#;
19693 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19694 let md = adf_to_markdown(&doc).unwrap();
19695 let rt = markdown_to_adf(&md).unwrap();
19696
19697 let items = rt.content[0].content.as_ref().unwrap();
19698 assert_eq!(items.len(), 2);
19699 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19700 let ms = &items[i].content.as_ref().unwrap()[0];
19701 assert_eq!(ms.node_type, "mediaSingle");
19702 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19703 assert_eq!(m_attrs["id"], expected_id.0);
19704 assert_eq!(m_attrs["collection"], expected_id.1);
19705 }
19706 }
19707
19708 #[test]
19709 fn issue_430_jfm_to_adf_media_in_bullet_item() {
19710 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19713 let doc = markdown_to_adf(md).unwrap();
19714
19715 let list = &doc.content[0];
19716 assert_eq!(list.node_type, "bulletList");
19717 let item = &list.content.as_ref().unwrap()[0];
19718 let ms = &item.content.as_ref().unwrap()[0];
19719 assert_eq!(
19720 ms.node_type, "mediaSingle",
19721 "expected mediaSingle, got {}",
19722 ms.node_type
19723 );
19724 let media = &ms.content.as_ref().unwrap()[0];
19725 assert_eq!(media.node_type, "media");
19726 let m_attrs = media.attrs.as_ref().unwrap();
19727 assert_eq!(m_attrs["type"], "file");
19728 assert_eq!(m_attrs["id"], "test-id");
19729 }
19730
19731 #[test]
19732 fn issue_430_jfm_to_adf_media_in_ordered_item() {
19733 let md = "1. \n";
19735 let doc = markdown_to_adf(md).unwrap();
19736
19737 let list = &doc.content[0];
19738 assert_eq!(list.node_type, "orderedList");
19739 let item = &list.content.as_ref().unwrap()[0];
19740 let ms = &item.content.as_ref().unwrap()[0];
19741 assert_eq!(
19742 ms.node_type, "mediaSingle",
19743 "expected mediaSingle, got {}",
19744 ms.node_type
19745 );
19746 }
19747
19748 #[test]
19749 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19750 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19753 {"type":"listItem","content":[
19754 {"type":"mediaSingle","attrs":{"layout":"center"},
19755 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19756 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19757 ]}
19758 ]}]}"#;
19759 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19760 let md = adf_to_markdown(&doc).unwrap();
19761 let rt = markdown_to_adf(&md).unwrap();
19762
19763 let item = &rt.content[0].content.as_ref().unwrap()[0];
19764 let children = item.content.as_ref().unwrap();
19765 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19766 assert_eq!(children[0].node_type, "mediaSingle");
19767 let media = &children[0].content.as_ref().unwrap()[0];
19768 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19769 assert_eq!(children[1].node_type, "paragraph");
19770 }
19771
19772 #[test]
19773 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19774 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19776 {"type":"listItem","content":[
19777 {"type":"mediaSingle","attrs":{"layout":"center"},
19778 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19779 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19780 ]}
19781 ]}]}"#;
19782 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19783 let md = adf_to_markdown(&doc).unwrap();
19784 let rt = markdown_to_adf(&md).unwrap();
19785
19786 let item = &rt.content[0].content.as_ref().unwrap()[0];
19787 let children = item.content.as_ref().unwrap();
19788 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19789 assert_eq!(children[0].node_type, "mediaSingle");
19790 assert_eq!(children[1].node_type, "paragraph");
19791 }
19792
19793 #[test]
19794 fn issue_430_external_media_with_width_type_roundtrip() {
19795 let adf_json = r#"{"version":1,"type":"doc","content":[{
19797 "type":"mediaSingle",
19798 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19799 "content":[{
19800 "type":"media",
19801 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19802 }]
19803 }]}"#;
19804 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19805 let md = adf_to_markdown(&doc).unwrap();
19806 assert!(
19807 md.contains("widthType=pixel"),
19808 "expected widthType=pixel in markdown, got: {md}"
19809 );
19810 let rt = markdown_to_adf(&md).unwrap();
19811 let ms = &rt.content[0];
19812 assert_eq!(ms.node_type, "mediaSingle");
19813 let ms_attrs = ms.attrs.as_ref().unwrap();
19814 assert_eq!(ms_attrs["widthType"], "pixel");
19815 assert_eq!(ms_attrs["width"], 800);
19816 assert_eq!(ms_attrs["layout"], "wide");
19817 }
19818
19819 #[test]
19822 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19823 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19826 {"type":"listItem","content":[
19827 {"type":"paragraph","content":[
19828 {"type":"text","text":"Item with image:"},
19829 {"type":"hardBreak"}
19830 ]},
19831 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19832 "content":[{"type":"media","attrs":{
19833 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19834 "type":"file",
19835 "collection":"contentId-123456",
19836 "width":800,
19837 "height":600
19838 }}]}
19839 ]}
19840 ]}]}"#;
19841 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19842 let md = adf_to_markdown(&doc).unwrap();
19843 let rt = markdown_to_adf(&md).unwrap();
19844
19845 let item = &rt.content[0].content.as_ref().unwrap()[0];
19846 let children = item.content.as_ref().unwrap();
19847 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19848 assert_eq!(children[0].node_type, "paragraph");
19849 assert_eq!(
19850 children[1].node_type, "mediaSingle",
19851 "expected mediaSingle, got {:?}",
19852 children[1].node_type
19853 );
19854 let media = &children[1].content.as_ref().unwrap()[0];
19855 let m_attrs = media.attrs.as_ref().unwrap();
19856 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19857 assert_eq!(m_attrs["collection"], "contentId-123456");
19858 assert_eq!(m_attrs["height"], 600);
19859 assert_eq!(m_attrs["width"], 800);
19860 }
19861
19862 #[test]
19863 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19864 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19866 {"type":"listItem","content":[
19867 {"type":"paragraph","content":[
19868 {"type":"text","text":"Step with screenshot:"},
19869 {"type":"hardBreak"}
19870 ]},
19871 {"type":"mediaSingle","attrs":{"layout":"center"},
19872 "content":[{"type":"media","attrs":{
19873 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19874 }}]}
19875 ]}
19876 ]}]}"#;
19877 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19878 let md = adf_to_markdown(&doc).unwrap();
19879 let rt = markdown_to_adf(&md).unwrap();
19880
19881 let item = &rt.content[0].content.as_ref().unwrap()[0];
19882 let children = item.content.as_ref().unwrap();
19883 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19884 assert_eq!(children[0].node_type, "paragraph");
19885 assert_eq!(children[1].node_type, "mediaSingle");
19886 let media = &children[1].content.as_ref().unwrap()[0];
19887 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19888 }
19889
19890 #[test]
19891 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19892 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19895 let doc = markdown_to_adf(md).unwrap();
19896
19897 let item = &doc.content[0].content.as_ref().unwrap()[0];
19898 let children = item.content.as_ref().unwrap();
19899 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19900 assert_eq!(children[0].node_type, "paragraph");
19901 assert_eq!(
19902 children[1].node_type, "mediaSingle",
19903 "expected mediaSingle as second child, got {:?}",
19904 children[1].node_type
19905 );
19906 let media = &children[1].content.as_ref().unwrap()[0];
19907 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
19908 }
19909
19910 #[test]
19911 fn issue_490_hardbreak_continuation_still_works_for_text() {
19912 let md = "- first line\\\n second line\n";
19914 let doc = markdown_to_adf(md).unwrap();
19915
19916 let item = &doc.content[0].content.as_ref().unwrap()[0];
19917 let children = item.content.as_ref().unwrap();
19918 assert_eq!(
19919 children.len(),
19920 1,
19921 "expected 1 child (paragraph) in listItem"
19922 );
19923 assert_eq!(children[0].node_type, "paragraph");
19924 let inlines = children[0].content.as_ref().unwrap();
19925 assert_eq!(inlines.len(), 3);
19927 assert_eq!(inlines[0].node_type, "text");
19928 assert_eq!(inlines[1].node_type, "hardBreak");
19929 assert_eq!(inlines[2].node_type, "text");
19930 }
19931
19932 #[test]
19933 fn issue_490_external_media_after_hardbreak_roundtrip() {
19934 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19936 {"type":"listItem","content":[
19937 {"type":"paragraph","content":[
19938 {"type":"text","text":"See image:"},
19939 {"type":"hardBreak"}
19940 ]},
19941 {"type":"mediaSingle","attrs":{"layout":"center"},
19942 "content":[{"type":"media","attrs":{
19943 "type":"external","url":"https://example.com/photo.png","alt":"photo"
19944 }}]}
19945 ]}
19946 ]}]}"#;
19947 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19948 let md = adf_to_markdown(&doc).unwrap();
19949 let rt = markdown_to_adf(&md).unwrap();
19950
19951 let item = &rt.content[0].content.as_ref().unwrap()[0];
19952 let children = item.content.as_ref().unwrap();
19953 assert_eq!(children.len(), 2);
19954 assert_eq!(children[0].node_type, "paragraph");
19955 assert_eq!(children[1].node_type, "mediaSingle");
19956 let media = &children[1].content.as_ref().unwrap()[0];
19957 let m_attrs = media.attrs.as_ref().unwrap();
19958 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
19959 }
19960
19961 #[test]
19962 fn issue_490_multiple_hardbreaks_then_media_single() {
19963 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19965 {"type":"listItem","content":[
19966 {"type":"paragraph","content":[
19967 {"type":"text","text":"line one"},
19968 {"type":"hardBreak"},
19969 {"type":"text","text":"line two"},
19970 {"type":"hardBreak"}
19971 ]},
19972 {"type":"mediaSingle","attrs":{"layout":"center"},
19973 "content":[{"type":"media","attrs":{
19974 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
19975 }}]}
19976 ]}
19977 ]}]}"#;
19978 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19979 let md = adf_to_markdown(&doc).unwrap();
19980 let rt = markdown_to_adf(&md).unwrap();
19981
19982 let item = &rt.content[0].content.as_ref().unwrap()[0];
19983 let children = item.content.as_ref().unwrap();
19984 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
19985 assert_eq!(children[0].node_type, "paragraph");
19986 assert_eq!(children[1].node_type, "mediaSingle");
19987 let media = &children[1].content.as_ref().unwrap()[0];
19988 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
19989 }
19990
19991 #[test]
19994 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
19995 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"}]}]}]}]}]}]}"#;
19998 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19999 let md = adf_to_markdown(&doc).unwrap();
20000 let rt = markdown_to_adf(&md).unwrap();
20001
20002 let list = &rt.content[0];
20003 assert_eq!(list.node_type, "bulletList");
20004 let item = &list.content.as_ref().unwrap()[0];
20005 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20007 assert_eq!(
20008 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20009 "listItem localId must survive round-trip"
20010 );
20011 let children = item.content.as_ref().unwrap();
20012 assert_eq!(
20013 children.len(),
20014 3,
20015 "expected mediaSingle + paragraph + bulletList"
20016 );
20017 assert_eq!(children[0].node_type, "mediaSingle");
20018 assert_eq!(children[1].node_type, "paragraph");
20019 assert_eq!(children[2].node_type, "bulletList");
20020 }
20021
20022 #[test]
20023 fn issue_525_listitem_localid_with_mediasingle_only() {
20024 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20026 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20027 {"type":"mediaSingle","attrs":{"layout":"center"},
20028 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20029 ]}
20030 ]}]}"#;
20031 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20032 let md = adf_to_markdown(&doc).unwrap();
20033 let rt = markdown_to_adf(&md).unwrap();
20034
20035 let item = &rt.content[0].content.as_ref().unwrap()[0];
20036 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20037 assert_eq!(
20038 item_attrs["localId"], "li-media-only",
20039 "listItem localId must survive when sole child is mediaSingle"
20040 );
20041 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20042 }
20043
20044 #[test]
20045 fn issue_525_listitem_localid_with_external_media() {
20046 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20048 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20049 {"type":"mediaSingle","attrs":{"layout":"center"},
20050 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20051 ]}
20052 ]}]}"#;
20053 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20054 let md = adf_to_markdown(&doc).unwrap();
20055 let rt = markdown_to_adf(&md).unwrap();
20056
20057 let item = &rt.content[0].content.as_ref().unwrap()[0];
20058 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20059 assert_eq!(
20060 item_attrs["localId"], "li-ext-media",
20061 "listItem localId must survive with external mediaSingle"
20062 );
20063 }
20064
20065 #[test]
20066 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20067 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20069 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20070 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20071 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20072 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20073 ]}
20074 ]}]}"#;
20075 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20076 let md = adf_to_markdown(&doc).unwrap();
20077 let rt = markdown_to_adf(&md).unwrap();
20078
20079 let item = &rt.content[0].content.as_ref().unwrap()[0];
20080 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20081 assert_eq!(
20082 item_attrs["localId"], "li-ord-media",
20083 "listItem localId must survive in ordered list with mediaSingle"
20084 );
20085 let children = item.content.as_ref().unwrap();
20086 assert_eq!(children[0].node_type, "mediaSingle");
20087 assert_eq!(children[1].node_type, "paragraph");
20088 }
20089
20090 #[test]
20091 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20092 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20095 let doc = markdown_to_adf(md).unwrap();
20096
20097 let item = &doc.content[0].content.as_ref().unwrap()[0];
20098 let item_attrs = item
20099 .attrs
20100 .as_ref()
20101 .expect("listItem attrs must be present from JFM");
20102 assert_eq!(item_attrs["localId"], "li-jfm-525");
20103 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20104 }
20105
20106 #[test]
20107 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20108 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20111 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20112 {"type":"mediaSingle","attrs":{"layout":"center"},
20113 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20114 ]}
20115 ]}]}"#;
20116 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20117 let md = adf_to_markdown(&doc).unwrap();
20118 assert!(
20119 md.contains("{localId=li-emit-check}"),
20120 "expected localId in JFM output, got: {md}"
20121 );
20122 for line in md.lines() {
20124 if line.contains("![") {
20125 assert!(
20126 line.contains("localId=li-emit-check"),
20127 "localId must be on the same line as the image: {line}"
20128 );
20129 }
20130 }
20131 }
20132
20133 #[test]
20136 fn adf_placeholder_to_markdown() {
20137 let doc = AdfDocument {
20138 version: 1,
20139 doc_type: "doc".to_string(),
20140 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20141 "Type something here",
20142 )])],
20143 };
20144 let md = adf_to_markdown(&doc).unwrap();
20145 assert!(
20146 md.contains(":placeholder[Type something here]"),
20147 "expected :placeholder directive, got: {md}"
20148 );
20149 }
20150
20151 #[test]
20152 fn markdown_placeholder_to_adf() {
20153 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20154 let content = doc.content[0].content.as_ref().unwrap();
20155 assert_eq!(content[1].node_type, "placeholder");
20156 let attrs = content[1].attrs.as_ref().unwrap();
20157 assert_eq!(attrs["text"], "Enter name");
20158 }
20159
20160 #[test]
20161 fn placeholder_round_trip() {
20162 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20163 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20164 let md = adf_to_markdown(&doc).unwrap();
20165 let rt = markdown_to_adf(&md).unwrap();
20166 let content = rt.content[0].content.as_ref().unwrap();
20167 assert_eq!(content.len(), 1);
20168 assert_eq!(content[0].node_type, "placeholder");
20169 let attrs = content[0].attrs.as_ref().unwrap();
20170 assert_eq!(attrs["text"], "Type something here");
20171 }
20172
20173 #[test]
20174 fn placeholder_empty_text() {
20175 let doc = AdfDocument {
20176 version: 1,
20177 doc_type: "doc".to_string(),
20178 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20179 };
20180 let md = adf_to_markdown(&doc).unwrap();
20181 assert!(
20182 md.contains(":placeholder[]"),
20183 "expected empty placeholder directive, got: {md}"
20184 );
20185 let rt = markdown_to_adf(&md).unwrap();
20186 let content = rt.content[0].content.as_ref().unwrap();
20187 assert_eq!(content[0].node_type, "placeholder");
20188 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20189 }
20190
20191 #[test]
20192 fn placeholder_with_surrounding_text() {
20193 let md = "Click :placeholder[here] to continue\n";
20194 let doc = markdown_to_adf(md).unwrap();
20195 let content = doc.content[0].content.as_ref().unwrap();
20196 assert_eq!(content[0].text.as_deref(), Some("Click "));
20197 assert_eq!(content[1].node_type, "placeholder");
20198 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20199 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20200 }
20201
20202 #[test]
20203 fn placeholder_missing_attrs() {
20204 let doc = AdfDocument {
20206 version: 1,
20207 doc_type: "doc".to_string(),
20208 content: vec![AdfNode::paragraph(vec![AdfNode {
20209 node_type: "placeholder".to_string(),
20210 attrs: None,
20211 content: None,
20212 text: None,
20213 marks: None,
20214 local_id: None,
20215 parameters: None,
20216 }])],
20217 };
20218 let md = adf_to_markdown(&doc).unwrap();
20219 assert!(!md.contains("placeholder"));
20221 }
20222
20223 #[test]
20225 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20226 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":" "}]}]}]}]}]}]}]}"#;
20227 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20228 let md = adf_to_markdown(&doc).unwrap();
20229 let rt = markdown_to_adf(&md).unwrap();
20230
20231 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20233 .content
20234 .as_ref()
20235 .unwrap()[0];
20236 let list = &cell.content.as_ref().unwrap()[0];
20237 let list_item = &list.content.as_ref().unwrap()[0];
20238
20239 assert!(
20241 list_item
20242 .attrs
20243 .as_ref()
20244 .and_then(|a| a.get("localId"))
20245 .is_none(),
20246 "localId should stay on the mention, not the listItem"
20247 );
20248
20249 let para = &list_item.content.as_ref().unwrap()[0];
20250 let inlines = para.content.as_ref().unwrap();
20251
20252 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20254
20255 assert_eq!(inlines[0].node_type, "text");
20256 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20257
20258 assert_eq!(inlines[1].node_type, "mention");
20259 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20260 assert_eq!(
20261 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20262 "mention id must be preserved"
20263 );
20264 assert_eq!(
20265 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20266 "mention localId must be preserved"
20267 );
20268 assert_eq!(mention_attrs["text"], "@Alice Example");
20269
20270 assert_eq!(inlines[2].node_type, "text");
20271 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20272 }
20273
20274 #[test]
20275 fn mention_in_bullet_list_preserves_id_and_local_id() {
20276 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":" "}]}]}]}]}"#;
20278 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20279 let md = adf_to_markdown(&doc).unwrap();
20280 let rt = markdown_to_adf(&md).unwrap();
20281
20282 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20283 assert!(
20284 list_item
20285 .attrs
20286 .as_ref()
20287 .and_then(|a| a.get("localId"))
20288 .is_none(),
20289 "localId should stay on the mention, not the listItem"
20290 );
20291
20292 let para = &list_item.content.as_ref().unwrap()[0];
20293 let inlines = para.content.as_ref().unwrap();
20294 assert_eq!(inlines[0].node_type, "mention");
20295 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20296 assert_eq!(mention_attrs["id"], "user123");
20297 assert_eq!(
20298 mention_attrs["localId"],
20299 "11111111-2222-3333-4444-555555555555"
20300 );
20301 }
20302
20303 #[test]
20304 fn mention_in_ordered_list_preserves_id_and_local_id() {
20305 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"}}]}]}]}]}"#;
20306 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20307 let md = adf_to_markdown(&doc).unwrap();
20308 let rt = markdown_to_adf(&md).unwrap();
20309
20310 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20311 assert!(
20312 list_item
20313 .attrs
20314 .as_ref()
20315 .and_then(|a| a.get("localId"))
20316 .is_none(),
20317 "localId should stay on the mention, not the listItem"
20318 );
20319
20320 let para = &list_item.content.as_ref().unwrap()[0];
20321 let inlines = para.content.as_ref().unwrap();
20322 assert_eq!(inlines[1].node_type, "mention");
20323 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20324 assert_eq!(mention_attrs["id"], "xyz");
20325 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20326 }
20327
20328 #[test]
20329 fn list_item_own_local_id_with_mention_both_preserved() {
20330 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20333 let doc = markdown_to_adf(md).unwrap();
20334 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20335
20336 let item_attrs = list_item.attrs.as_ref().unwrap();
20338 assert_eq!(item_attrs["localId"], "item-lid");
20339
20340 let para = &list_item.content.as_ref().unwrap()[0];
20342 let inlines = para.content.as_ref().unwrap();
20343 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20344 let mention_attrs = mention.attrs.as_ref().unwrap();
20345 assert_eq!(mention_attrs["id"], "e1");
20346 assert_eq!(mention_attrs["localId"], "mention-lid");
20347 }
20348
20349 #[test]
20350 fn extract_trailing_local_id_ignores_directive_attrs() {
20351 let line = "text :mention[@X]{id=abc localId=uuid}";
20354 let (text, lid, plid) = extract_trailing_local_id(line);
20355 assert_eq!(text, line, "text should be unchanged");
20356 assert!(
20357 lid.is_none(),
20358 "should not extract localId from directive attrs"
20359 );
20360 assert!(plid.is_none());
20361 }
20362
20363 #[test]
20364 fn extract_trailing_local_id_matches_standalone_block() {
20365 let line = "some text {localId=abc-123}";
20367 let (text, lid, plid) = extract_trailing_local_id(line);
20368 assert_eq!(text, "some text");
20369 assert_eq!(lid.as_deref(), Some("abc-123"));
20370 assert!(plid.is_none());
20371 }
20372
20373 #[test]
20376 fn newline_in_text_node_roundtrips_in_bullet_list() {
20377 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"}]}]}]}]}"#;
20381 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20382 let md = adf_to_markdown(&doc).unwrap();
20383 let rt = markdown_to_adf(&md).unwrap();
20384
20385 assert_eq!(rt.content.len(), 1);
20387 let list = &rt.content[0];
20388 assert_eq!(list.node_type, "bulletList");
20389 let items = list.content.as_ref().unwrap();
20390 assert_eq!(items.len(), 1);
20391
20392 let item_content = items[0].content.as_ref().unwrap();
20394 assert_eq!(
20395 item_content.len(),
20396 1,
20397 "listItem should have exactly one paragraph"
20398 );
20399 assert_eq!(item_content[0].node_type, "paragraph");
20400
20401 let inlines = item_content[0].content.as_ref().unwrap();
20403 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20404 assert_eq!(
20405 types,
20406 vec!["text", "hardBreak", "text"],
20407 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20408 );
20409 assert_eq!(
20410 inlines[2].text.as_deref(),
20411 Some("first command\nsecond command")
20412 );
20413 }
20414
20415 #[test]
20416 fn newline_in_text_node_roundtrips_in_ordered_list() {
20417 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"}]}]}]}]}"#;
20419 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20420 let md = adf_to_markdown(&doc).unwrap();
20421 let rt = markdown_to_adf(&md).unwrap();
20422
20423 let list = &rt.content[0];
20424 assert_eq!(list.node_type, "orderedList");
20425 let items = list.content.as_ref().unwrap();
20426 assert_eq!(items.len(), 1);
20427
20428 let item_content = items[0].content.as_ref().unwrap();
20429 assert_eq!(item_content.len(), 1);
20430 assert_eq!(item_content[0].node_type, "paragraph");
20431
20432 let inlines = item_content[0].content.as_ref().unwrap();
20433 assert_eq!(inlines.len(), 1);
20434 assert_eq!(inlines[0].node_type, "text");
20435 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20436 }
20437
20438 #[test]
20439 fn newline_in_text_node_roundtrips_in_paragraph() {
20440 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20443 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20444 let md = adf_to_markdown(&doc).unwrap();
20445 assert!(
20446 md.contains("hello\\nworld"),
20447 "newline in text node should render as escaped \\n: {md:?}"
20448 );
20449
20450 let rt = markdown_to_adf(&md).unwrap();
20451 let inlines = rt.content[0].content.as_ref().unwrap();
20452 assert_eq!(inlines.len(), 1);
20453 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20454 }
20455
20456 #[test]
20457 fn multiple_newlines_in_text_node_roundtrip() {
20458 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20460 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20461 let md = adf_to_markdown(&doc).unwrap();
20462 let rt = markdown_to_adf(&md).unwrap();
20463
20464 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20465 .content
20466 .as_ref()
20467 .unwrap();
20468 assert_eq!(item_content.len(), 1);
20469
20470 let inlines = item_content[0].content.as_ref().unwrap();
20471 assert_eq!(inlines.len(), 1);
20472 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20473 }
20474
20475 #[test]
20476 fn newline_in_marked_text_node_roundtrips() {
20477 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20480 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20481 let md = adf_to_markdown(&doc).unwrap();
20482 assert!(
20483 md.contains("**bold\\ntext**"),
20484 "bold text with embedded newline should stay in one marked run: {md:?}"
20485 );
20486
20487 let rt = markdown_to_adf(&md).unwrap();
20488 let inlines = rt.content[0].content.as_ref().unwrap();
20489 assert_eq!(inlines.len(), 1);
20490 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20491 assert!(inlines[0]
20492 .marks
20493 .as_ref()
20494 .unwrap()
20495 .iter()
20496 .any(|m| m.mark_type == "strong"));
20497 }
20498
20499 #[test]
20500 fn trailing_newline_in_text_node_roundtrips() {
20501 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20504 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20505 let md = adf_to_markdown(&doc).unwrap();
20506 assert!(
20507 md.contains("trailing\\n"),
20508 "trailing newline should be escaped: {md:?}"
20509 );
20510
20511 let rt = markdown_to_adf(&md).unwrap();
20512 let inlines = rt.content[0].content.as_ref().unwrap();
20513 assert_eq!(inlines.len(), 1);
20514 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20515 }
20516
20517 #[test]
20518 fn hardbreak_and_embedded_newline_are_distinct() {
20519 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"}]}]}"#;
20522 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20523 let md = adf_to_markdown(&doc).unwrap();
20524 let rt = markdown_to_adf(&md).unwrap();
20525
20526 let inlines = rt.content[0].content.as_ref().unwrap();
20527 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20528 assert_eq!(
20529 types,
20530 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20531 );
20532 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20533 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20534 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20535 }
20536
20537 #[test]
20540 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20541 let adf_json = r#"{"version":1,"type":"doc","content":[
20544 {"type":"bulletList","content":[
20545 {"type":"listItem","content":[
20546 {"type":"paragraph","content":[
20547 {"type":"text","text":"First item"},
20548 {"type":"hardBreak"}
20549 ]}
20550 ]},
20551 {"type":"listItem","content":[
20552 {"type":"paragraph","content":[
20553 {"type":"text","text":"Second item"}
20554 ]}
20555 ]}
20556 ]}
20557 ]}"#;
20558 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20559 let md = adf_to_markdown(&doc).unwrap();
20560 let rt = markdown_to_adf(&md).unwrap();
20561
20562 assert_eq!(
20564 rt.content.len(),
20565 1,
20566 "Should be 1 block (bulletList), got {}",
20567 rt.content.len()
20568 );
20569 assert_eq!(rt.content[0].node_type, "bulletList");
20570 let items = rt.content[0].content.as_ref().unwrap();
20571 assert_eq!(
20572 items.len(),
20573 2,
20574 "Should have 2 listItems, got {}",
20575 items.len()
20576 );
20577
20578 let p1 = items[0].content.as_ref().unwrap()[0]
20580 .content
20581 .as_ref()
20582 .unwrap();
20583 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20584 assert_eq!(types1, vec!["text", "hardBreak"]);
20585 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20586
20587 let p2 = items[1].content.as_ref().unwrap()[0]
20589 .content
20590 .as_ref()
20591 .unwrap();
20592 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20593 }
20594
20595 #[test]
20596 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20597 let adf_json = r#"{"version":1,"type":"doc","content":[
20599 {"type":"orderedList","attrs":{"order":1},"content":[
20600 {"type":"listItem","content":[
20601 {"type":"paragraph","content":[
20602 {"type":"text","text":"Alpha"},
20603 {"type":"hardBreak"}
20604 ]}
20605 ]},
20606 {"type":"listItem","content":[
20607 {"type":"paragraph","content":[
20608 {"type":"text","text":"Beta"}
20609 ]}
20610 ]}
20611 ]}
20612 ]}"#;
20613 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20614 let md = adf_to_markdown(&doc).unwrap();
20615 let rt = markdown_to_adf(&md).unwrap();
20616
20617 assert_eq!(rt.content.len(), 1);
20618 assert_eq!(rt.content[0].node_type, "orderedList");
20619 let items = rt.content[0].content.as_ref().unwrap();
20620 assert_eq!(items.len(), 2);
20621
20622 let p1 = items[0].content.as_ref().unwrap()[0]
20623 .content
20624 .as_ref()
20625 .unwrap();
20626 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20627 assert_eq!(types1, vec!["text", "hardBreak"]);
20628 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20629 }
20630
20631 #[test]
20632 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20633 let adf_json = r#"{"version":1,"type":"doc","content":[
20636 {"type":"bulletList","content":[
20637 {"type":"listItem","content":[
20638 {"type":"paragraph","content":[
20639 {"type":"text","text":"Hello"},
20640 {"type":"hardBreak"}
20641 ]}
20642 ]},
20643 {"type":"listItem","content":[
20644 {"type":"paragraph","content":[
20645 {"type":"text","text":"World"}
20646 ]}
20647 ]}
20648 ]}
20649 ]}"#;
20650 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20651 let md = adf_to_markdown(&doc).unwrap();
20652
20653 assert_eq!(md, "- Hello\\\n- World\n");
20655 }
20656
20657 #[test]
20658 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20659 let adf_json = r#"{"version":1,"type":"doc","content":[
20661 {"type":"bulletList","content":[
20662 {"type":"listItem","content":[
20663 {"type":"paragraph","content":[
20664 {"type":"text","text":"Item"},
20665 {"type":"hardBreak"},
20666 {"type":"hardBreak"}
20667 ]}
20668 ]},
20669 {"type":"listItem","content":[
20670 {"type":"paragraph","content":[
20671 {"type":"text","text":"Next"}
20672 ]}
20673 ]}
20674 ]}
20675 ]}"#;
20676 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20677 let md = adf_to_markdown(&doc).unwrap();
20678 let rt = markdown_to_adf(&md).unwrap();
20679
20680 assert_eq!(rt.content.len(), 1);
20682 assert_eq!(rt.content[0].node_type, "bulletList");
20683 let items = rt.content[0].content.as_ref().unwrap();
20684 assert_eq!(items.len(), 2);
20685
20686 let p1 = items[0].content.as_ref().unwrap()[0]
20688 .content
20689 .as_ref()
20690 .unwrap();
20691 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20692 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20693 }
20694
20695 #[test]
20696 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20697 let adf_json = r#"{"version":1,"type":"doc","content":[
20699 {"type":"bulletList","content":[
20700 {"type":"listItem","content":[
20701 {"type":"paragraph","content":[
20702 {"type":"text","text":"Line one"},
20703 {"type":"hardBreak"},
20704 {"type":"text","text":"Line two"},
20705 {"type":"hardBreak"}
20706 ]}
20707 ]},
20708 {"type":"listItem","content":[
20709 {"type":"paragraph","content":[
20710 {"type":"text","text":"Other item"}
20711 ]}
20712 ]}
20713 ]}
20714 ]}"#;
20715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20716 let md = adf_to_markdown(&doc).unwrap();
20717 let rt = markdown_to_adf(&md).unwrap();
20718
20719 assert_eq!(rt.content.len(), 1);
20720 assert_eq!(rt.content[0].node_type, "bulletList");
20721 let items = rt.content[0].content.as_ref().unwrap();
20722 assert_eq!(items.len(), 2);
20723
20724 let p1 = items[0].content.as_ref().unwrap()[0]
20725 .content
20726 .as_ref()
20727 .unwrap();
20728 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20729 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20730 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20731 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20732 }
20733
20734 #[test]
20735 fn issue_472_only_hardbreak_in_listitem_paragraph() {
20736 let adf_json = r#"{"version":1,"type":"doc","content":[
20738 {"type":"bulletList","content":[
20739 {"type":"listItem","content":[
20740 {"type":"paragraph","content":[
20741 {"type":"hardBreak"}
20742 ]}
20743 ]},
20744 {"type":"listItem","content":[
20745 {"type":"paragraph","content":[
20746 {"type":"text","text":"After"}
20747 ]}
20748 ]}
20749 ]}
20750 ]}"#;
20751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20752 let md = adf_to_markdown(&doc).unwrap();
20753 let rt = markdown_to_adf(&md).unwrap();
20754
20755 assert_eq!(rt.content.len(), 1);
20757 assert_eq!(rt.content[0].node_type, "bulletList");
20758 let items = rt.content[0].content.as_ref().unwrap();
20759 assert_eq!(items.len(), 2);
20760 }
20761
20762 #[test]
20763 fn issue_472_three_items_middle_has_trailing_hardbreak() {
20764 let adf_json = r#"{"version":1,"type":"doc","content":[
20766 {"type":"bulletList","content":[
20767 {"type":"listItem","content":[
20768 {"type":"paragraph","content":[
20769 {"type":"text","text":"First"}
20770 ]}
20771 ]},
20772 {"type":"listItem","content":[
20773 {"type":"paragraph","content":[
20774 {"type":"text","text":"Second"},
20775 {"type":"hardBreak"}
20776 ]}
20777 ]},
20778 {"type":"listItem","content":[
20779 {"type":"paragraph","content":[
20780 {"type":"text","text":"Third"}
20781 ]}
20782 ]}
20783 ]}
20784 ]}"#;
20785 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20786 let md = adf_to_markdown(&doc).unwrap();
20787 let rt = markdown_to_adf(&md).unwrap();
20788
20789 assert_eq!(rt.content.len(), 1);
20790 assert_eq!(rt.content[0].node_type, "bulletList");
20791 let items = rt.content[0].content.as_ref().unwrap();
20792 assert_eq!(items.len(), 3);
20793 assert_eq!(
20794 items[0].content.as_ref().unwrap()[0]
20795 .content
20796 .as_ref()
20797 .unwrap()[0]
20798 .text
20799 .as_deref(),
20800 Some("First")
20801 );
20802 assert_eq!(
20803 items[2].content.as_ref().unwrap()[0]
20804 .content
20805 .as_ref()
20806 .unwrap()[0]
20807 .text
20808 .as_deref(),
20809 Some("Third")
20810 );
20811 }
20812
20813 #[test]
20816 fn issue_494_space_after_hardbreak_roundtrip() {
20817 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20820 {"type":"text","text":"Some text"},
20821 {"type":"hardBreak"},
20822 {"type":"text","text":" "}
20823 ]}]}"#;
20824 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20825 let md = adf_to_markdown(&doc).unwrap();
20826 let rt = markdown_to_adf(&md).unwrap();
20827 let inlines = rt.content[0].content.as_ref().unwrap();
20828 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20829 assert_eq!(
20830 types,
20831 vec!["text", "hardBreak", "text"],
20832 "space-only text node after hardBreak should survive round-trip"
20833 );
20834 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20835 }
20836
20837 #[test]
20838 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20839 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20841 {"type":"text","text":"Hello"},
20842 {"type":"hardBreak"},
20843 {"type":"text","text":" "}
20844 ]}]}"#;
20845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20846 let md = adf_to_markdown(&doc).unwrap();
20847 let rt = markdown_to_adf(&md).unwrap();
20848 let inlines = rt.content[0].content.as_ref().unwrap();
20849 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20850 assert_eq!(
20851 types,
20852 vec!["text", "hardBreak", "text"],
20853 "multi-space text node after hardBreak should survive round-trip"
20854 );
20855 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20856 }
20857
20858 #[test]
20859 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20860 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20863 {"type":"text","text":"Before"},
20864 {"type":"hardBreak"},
20865 {"type":"text","text":" After"}
20866 ]}]}"#;
20867 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20868 let md = adf_to_markdown(&doc).unwrap();
20869 let rt = markdown_to_adf(&md).unwrap();
20870 let inlines = rt.content[0].content.as_ref().unwrap();
20871 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20872 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20873 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20874 }
20875
20876 #[test]
20877 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20878 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20880 {"type":"text","text":"A"},
20881 {"type":"hardBreak"},
20882 {"type":"text","text":" "},
20883 {"type":"hardBreak"},
20884 {"type":"text","text":"B"}
20885 ]}]}"#;
20886 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20887 let md = adf_to_markdown(&doc).unwrap();
20888 let rt = markdown_to_adf(&md).unwrap();
20889 let inlines = rt.content[0].content.as_ref().unwrap();
20890 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20891 assert_eq!(
20892 types,
20893 vec!["text", "hardBreak", "text", "hardBreak", "text"],
20894 "space between two hardBreaks should survive round-trip"
20895 );
20896 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20897 assert_eq!(inlines[4].text.as_deref(), Some("B"));
20898 }
20899
20900 #[test]
20901 fn issue_494_trailing_space_hardbreak_style_not_confused() {
20902 let md = "first paragraph\n\nsecond paragraph\n";
20905 let doc = markdown_to_adf(md).unwrap();
20906 assert_eq!(
20907 doc.content.len(),
20908 2,
20909 "blank line should still separate paragraphs"
20910 );
20911 }
20912
20913 #[test]
20914 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
20915 let md = "line one \n \n";
20918 let doc = markdown_to_adf(md).unwrap();
20922 let inlines = doc.content[0].content.as_ref().unwrap();
20923 let has_text_after_break = inlines.iter().any(|n| {
20924 n.node_type == "text"
20925 && n.text
20926 .as_deref()
20927 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
20928 });
20929 assert!(
20930 has_text_after_break || inlines.len() >= 2,
20931 "space-only line after trailing-space hardBreak should be preserved"
20932 );
20933 }
20934
20935 #[test]
20936 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
20937 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20939 {"type":"listItem","content":[{"type":"paragraph","content":[
20940 {"type":"text","text":"item"},
20941 {"type":"hardBreak"},
20942 {"type":"text","text":" "}
20943 ]}]}
20944 ]}]}"#;
20945 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20946 let md = adf_to_markdown(&doc).unwrap();
20947 let rt = markdown_to_adf(&md).unwrap();
20948 let list = &rt.content[0];
20949 let item = &list.content.as_ref().unwrap()[0];
20950 let para = &item.content.as_ref().unwrap()[0];
20951 let inlines = para.content.as_ref().unwrap();
20952 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20953 assert_eq!(
20954 types,
20955 vec!["text", "hardBreak", "text"],
20956 "space after hardBreak in list item should survive round-trip"
20957 );
20958 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20959 }
20960
20961 #[test]
20964 fn issue_510_trailing_double_space_paragraph_roundtrip() {
20965 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"}]}]}"#;
20968 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20969 let md = adf_to_markdown(&doc).unwrap();
20970 let rt = markdown_to_adf(&md).unwrap();
20971
20972 assert_eq!(
20974 rt.content.len(),
20975 2,
20976 "should produce two paragraphs, got: {}",
20977 rt.content.len()
20978 );
20979 assert_eq!(rt.content[0].node_type, "paragraph");
20980 assert_eq!(rt.content[1].node_type, "paragraph");
20981
20982 let p1 = rt.content[0].content.as_ref().unwrap();
20984 assert_eq!(
20985 p1[0].text.as_deref(),
20986 Some("first paragraph with trailing spaces "),
20987 "trailing spaces should be preserved in first paragraph"
20988 );
20989
20990 let p2 = rt.content[1].content.as_ref().unwrap();
20992 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
20993
20994 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20996 assert!(
20997 !all_types.contains(&"hardBreak"),
20998 "trailing spaces should not produce hardBreak, got: {all_types:?}"
20999 );
21000 }
21001
21002 #[test]
21003 fn issue_510_trailing_triple_space_roundtrip() {
21004 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21006 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21007 let md = adf_to_markdown(&doc).unwrap();
21008 let rt = markdown_to_adf(&md).unwrap();
21009
21010 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21011 let p1 = rt.content[0].content.as_ref().unwrap();
21012 assert_eq!(
21013 p1[0].text.as_deref(),
21014 Some("text "),
21015 "three trailing spaces should be preserved"
21016 );
21017 }
21018
21019 #[test]
21020 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21021 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21024 let md = adf_to_markdown(&doc).unwrap();
21025 let rt = markdown_to_adf(&md).unwrap();
21026 let p = rt.content[0].content.as_ref().unwrap();
21027 assert_eq!(
21028 p[0].text.as_deref(),
21029 Some("end\\ "),
21030 "backslash + trailing spaces should both survive"
21031 );
21032 }
21033
21034 #[test]
21035 fn issue_510_jfm_contains_escaped_trailing_space() {
21036 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21038 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21039 let md = adf_to_markdown(&doc).unwrap();
21040 assert!(
21041 md.contains(r"\ "),
21042 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21043 );
21044 for line in md.lines() {
21046 assert!(
21047 !line.ends_with(" "),
21048 "no JFM line should end with two plain spaces, got: {line:?}"
21049 );
21050 }
21051 }
21052
21053 #[test]
21054 fn issue_510_single_trailing_space_not_escaped() {
21055 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21057 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21058 let md = adf_to_markdown(&doc).unwrap();
21059 assert!(
21060 !md.contains('\\'),
21061 "single trailing space should not be escaped, got: {md:?}"
21062 );
21063 let rt = markdown_to_adf(&md).unwrap();
21064 let p = rt.content[0].content.as_ref().unwrap();
21065 assert_eq!(p[0].text.as_deref(), Some("word "));
21066 }
21067
21068 #[test]
21069 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21070 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21072 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21073 let md = adf_to_markdown(&doc).unwrap();
21074 let rt = markdown_to_adf(&md).unwrap();
21075 let h = rt.content[0].content.as_ref().unwrap();
21076 assert_eq!(
21077 h[0].text.as_deref(),
21078 Some("heading "),
21079 "trailing spaces in heading should be preserved"
21080 );
21081 }
21082
21083 #[test]
21084 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21085 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21088 let md = adf_to_markdown(&doc).unwrap();
21089 let rt = markdown_to_adf(&md).unwrap();
21090 let list = &rt.content[0];
21091 let item = &list.content.as_ref().unwrap()[0];
21092 let para = &item.content.as_ref().unwrap()[0];
21093 let inlines = para.content.as_ref().unwrap();
21094 assert_eq!(
21095 inlines[0].text.as_deref(),
21096 Some("item "),
21097 "trailing spaces in list item should be preserved"
21098 );
21099 }
21100
21101 #[test]
21102 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21103 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21107 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21108 let md = adf_to_markdown(&doc).unwrap();
21109 let rt = markdown_to_adf(&md).unwrap();
21110 let p = rt.content[0].content.as_ref().unwrap();
21111 assert_eq!(
21112 p[0].text.as_deref(),
21113 Some("bold "),
21114 "trailing spaces in bold text should be preserved"
21115 );
21116 }
21117
21118 #[test]
21119 fn issue_510_hardbreak_between_paragraphs_still_works() {
21120 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21122 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21123 let md = adf_to_markdown(&doc).unwrap();
21124 let rt = markdown_to_adf(&md).unwrap();
21125 let inlines = rt.content[0].content.as_ref().unwrap();
21126 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21127 assert_eq!(
21128 types,
21129 vec!["text", "hardBreak", "text"],
21130 "explicit hardBreak should still round-trip"
21131 );
21132 }
21133
21134 #[test]
21135 fn issue_510_all_spaces_text_node_roundtrip() {
21136 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21138 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21139 let md = adf_to_markdown(&doc).unwrap();
21140 let rt = markdown_to_adf(&md).unwrap();
21141 let p = rt.content[0].content.as_ref().unwrap();
21142 assert_eq!(
21143 p[0].text.as_deref(),
21144 Some(" "),
21145 "space-only text node should survive round-trip"
21146 );
21147 }
21148
21149 #[test]
21152 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21153 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"}]}]}]}]}"#;
21156 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21157 let md = adf_to_markdown(&doc).unwrap();
21158 let rt = markdown_to_adf(&md).unwrap();
21159
21160 let items = rt.content[0].content.as_ref().unwrap();
21161 assert_eq!(items.len(), 1);
21162 let children = items[0].content.as_ref().unwrap();
21163 assert_eq!(
21164 children.len(),
21165 3,
21166 "Expected 3 paragraphs in listItem, got {}",
21167 children.len()
21168 );
21169 assert_eq!(children[0].node_type, "paragraph");
21170 assert_eq!(children[1].node_type, "paragraph");
21171 assert_eq!(children[2].node_type, "paragraph");
21172
21173 let text1 = children[1].content.as_ref().unwrap()[0]
21175 .text
21176 .as_deref()
21177 .unwrap();
21178 assert_eq!(text1, "second paragraph");
21179 let text2 = children[2].content.as_ref().unwrap()[0]
21180 .text
21181 .as_deref()
21182 .unwrap();
21183 assert_eq!(text2, "third paragraph");
21184 }
21185
21186 #[test]
21187 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21188 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"}]}]}]}]}"#;
21190 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21191 let md = adf_to_markdown(&doc).unwrap();
21192 let rt = markdown_to_adf(&md).unwrap();
21193
21194 let items = rt.content[0].content.as_ref().unwrap();
21195 let children = items[0].content.as_ref().unwrap();
21196 assert_eq!(
21197 children.len(),
21198 3,
21199 "Expected 3 paragraphs in ordered listItem, got {}",
21200 children.len()
21201 );
21202 assert_eq!(children[1].node_type, "paragraph");
21203 assert_eq!(children[2].node_type, "paragraph");
21204 assert_eq!(
21205 children[1].content.as_ref().unwrap()[0]
21206 .text
21207 .as_deref()
21208 .unwrap(),
21209 "second para"
21210 );
21211 assert_eq!(
21212 children[2].content.as_ref().unwrap()[0]
21213 .text
21214 .as_deref()
21215 .unwrap(),
21216 "third para"
21217 );
21218 }
21219
21220 #[test]
21221 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21222 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"}]}]}]}]}"#;
21224 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21225 let md = adf_to_markdown(&doc).unwrap();
21226 let rt = markdown_to_adf(&md).unwrap();
21227
21228 let items = rt.content[0].content.as_ref().unwrap();
21229 let children = items[0].content.as_ref().unwrap();
21230 assert_eq!(
21231 children.len(),
21232 2,
21233 "Expected 2 paragraphs in listItem, got {}",
21234 children.len()
21235 );
21236 assert_eq!(children[0].node_type, "paragraph");
21237 assert_eq!(children[1].node_type, "paragraph");
21238 }
21239
21240 #[test]
21241 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21242 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"}]}]}]}]}]}]}"#;
21245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21246 let md = adf_to_markdown(&doc).unwrap();
21247 assert!(
21249 !md.contains(" \n -"),
21250 "No blank separator between paragraph and nested list"
21251 );
21252 let rt = markdown_to_adf(&md).unwrap();
21253
21254 let items = rt.content[0].content.as_ref().unwrap();
21255 let children = items[0].content.as_ref().unwrap();
21256 assert_eq!(children.len(), 2);
21257 assert_eq!(children[0].node_type, "paragraph");
21258 assert_eq!(children[1].node_type, "bulletList");
21259 }
21260
21261 #[test]
21262 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21263 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"}]}]}]}]}"#;
21265 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21266 let md = adf_to_markdown(&doc).unwrap();
21267 let rt = markdown_to_adf(&md).unwrap();
21268
21269 let items = rt.content[0].content.as_ref().unwrap();
21270 let children = items[0].content.as_ref().unwrap();
21271 assert_eq!(
21272 children.len(),
21273 3,
21274 "Expected 3 paragraphs, got {}",
21275 children.len()
21276 );
21277 for (i, child) in children.iter().enumerate() {
21278 assert_eq!(
21279 child.node_type, "paragraph",
21280 "Child {i} should be a paragraph"
21281 );
21282 }
21283 }
21284
21285 #[test]
21286 fn issue_522_multiple_list_items_each_with_paragraphs() {
21287 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"}]}]}]}]}"#;
21289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21290 let md = adf_to_markdown(&doc).unwrap();
21291 let rt = markdown_to_adf(&md).unwrap();
21292
21293 let items = rt.content[0].content.as_ref().unwrap();
21294 assert_eq!(items.len(), 2, "Expected 2 list items");
21295
21296 let item1 = items[0].content.as_ref().unwrap();
21297 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21298
21299 let item2 = items[1].content.as_ref().unwrap();
21300 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21301 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21303 let types: Vec<&str> = item2_p1_inlines
21304 .iter()
21305 .map(|n| n.node_type.as_str())
21306 .collect();
21307 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21308 }
21309
21310 #[test]
21311 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21312 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"}]}]}]}"#;
21316 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21317 let md = adf_to_markdown(&doc).unwrap();
21318 let rt = markdown_to_adf(&md).unwrap();
21319
21320 let children = rt.content[0].content.as_ref().unwrap();
21321 assert_eq!(
21322 children.len(),
21323 3,
21324 "Expected 3 paragraphs in blockquote, got {}",
21325 children.len()
21326 );
21327 assert_eq!(children[0].node_type, "paragraph");
21328 assert_eq!(children[1].node_type, "paragraph");
21329 assert_eq!(children[2].node_type, "paragraph");
21330
21331 let text1 = children[1].content.as_ref().unwrap()[0]
21332 .text
21333 .as_deref()
21334 .unwrap();
21335 assert_eq!(text1, "second paragraph");
21336 let text2 = children[2].content.as_ref().unwrap()[0]
21337 .text
21338 .as_deref()
21339 .unwrap();
21340 assert_eq!(text2, "third paragraph");
21341 }
21342
21343 #[test]
21344 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21345 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"}]}]}]}"#;
21347 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21348 let md = adf_to_markdown(&doc).unwrap();
21349 let rt = markdown_to_adf(&md).unwrap();
21350
21351 let children = rt.content[0].content.as_ref().unwrap();
21352 assert_eq!(
21353 children.len(),
21354 2,
21355 "Expected 2 paragraphs in blockquote, got {}",
21356 children.len()
21357 );
21358 assert_eq!(children[0].node_type, "paragraph");
21359 assert_eq!(children[1].node_type, "paragraph");
21360 }
21361
21362 #[test]
21363 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21364 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"}]}]}]}"#;
21366 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21367 let md = adf_to_markdown(&doc).unwrap();
21368 let rt = markdown_to_adf(&md).unwrap();
21369
21370 let children = rt.content[0].content.as_ref().unwrap();
21371 assert_eq!(
21372 children.len(),
21373 3,
21374 "Expected 3 paragraphs in blockquote, got {}",
21375 children.len()
21376 );
21377 for child in children {
21378 assert_eq!(child.node_type, "paragraph");
21379 }
21380 }
21381
21382 #[test]
21383 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21384 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"}]}]}]}]}]}"#;
21387 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21388 let md = adf_to_markdown(&doc).unwrap();
21389 let rt = markdown_to_adf(&md).unwrap();
21390
21391 let children = rt.content[0].content.as_ref().unwrap();
21392 assert_eq!(children[0].node_type, "paragraph");
21393 assert_eq!(children[1].node_type, "bulletList");
21394 }
21395
21396 #[test]
21397 fn issue_531_blockquote_single_paragraph_unchanged() {
21398 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21401 let md = adf_to_markdown(&doc).unwrap();
21402 let rt = markdown_to_adf(&md).unwrap();
21403
21404 let children = rt.content[0].content.as_ref().unwrap();
21405 assert_eq!(children.len(), 1);
21406 assert_eq!(children[0].node_type, "paragraph");
21407 let text = children[0].content.as_ref().unwrap()[0]
21408 .text
21409 .as_deref()
21410 .unwrap();
21411 assert_eq!(text, "solo");
21412 }
21413
21414 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21419 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21420 let md = adf_to_markdown(&doc).unwrap();
21421 let rt = markdown_to_adf(&md).unwrap();
21422 let node = &rt.content[0].content.as_ref().unwrap()[0];
21423 let mark_types: Vec<&str> = node
21424 .marks
21425 .as_ref()
21426 .expect("should have marks")
21427 .iter()
21428 .map(|m| m.mark_type.as_str())
21429 .collect();
21430 assert_eq!(
21431 mark_types, expected_marks,
21432 "mark order mismatch for md={md}"
21433 );
21434 }
21435
21436 #[test]
21437 fn issue_554_code_and_text_color_preserved() {
21438 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21439 {"type":"text","text":"x","marks":[
21440 {"type":"textColor","attrs":{"color":"#008000"}},
21441 {"type":"code"}
21442 ]}
21443 ]}]}"##;
21444 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21445 }
21446
21447 #[test]
21448 fn issue_554_code_and_bg_color_preserved() {
21449 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21450 {"type":"text","text":"x","marks":[
21451 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21452 {"type":"code"}
21453 ]}
21454 ]}]}"##;
21455 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21456 }
21457
21458 #[test]
21459 fn issue_554_code_and_subsup_preserved() {
21460 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21461 {"type":"text","text":"x","marks":[
21462 {"type":"subsup","attrs":{"type":"sub"}},
21463 {"type":"code"}
21464 ]}
21465 ]}]}"#;
21466 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21467 }
21468
21469 #[test]
21470 fn issue_554_code_and_underline_preserved() {
21471 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21472 {"type":"text","text":"x","marks":[
21473 {"type":"underline"},
21474 {"type":"code"}
21475 ]}
21476 ]}]}"#;
21477 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21478 }
21479
21480 #[test]
21481 fn issue_554_code_textcolor_and_underline_preserved() {
21482 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21483 {"type":"text","text":"x","marks":[
21484 {"type":"textColor","attrs":{"color":"#008000"}},
21485 {"type":"underline"},
21486 {"type":"code"}
21487 ]}
21488 ]}]}"##;
21489 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21490 }
21491
21492 #[test]
21493 fn issue_554_textcolor_and_underline_preserved() {
21494 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21495 {"type":"text","text":"x","marks":[
21496 {"type":"textColor","attrs":{"color":"#008000"}},
21497 {"type":"underline"}
21498 ]}
21499 ]}]}"##;
21500 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21501 }
21502
21503 #[test]
21504 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21505 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21506 {"type":"text","text":"x","marks":[
21507 {"type":"underline"},
21508 {"type":"textColor","attrs":{"color":"#008000"}}
21509 ]}
21510 ]}]}"##;
21511 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21513 }
21514
21515 #[test]
21516 fn issue_554_textcolor_and_annotation_preserved() {
21517 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21518 {"type":"text","text":"x","marks":[
21519 {"type":"textColor","attrs":{"color":"#008000"}},
21520 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21521 ]}
21522 ]}]}"##;
21523 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21524 }
21525
21526 #[test]
21527 fn issue_554_bgcolor_and_underline_preserved() {
21528 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21529 {"type":"text","text":"x","marks":[
21530 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21531 {"type":"underline"}
21532 ]}
21533 ]}]}"##;
21534 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21535 }
21536
21537 #[test]
21538 fn issue_554_subsup_and_underline_preserved() {
21539 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21540 {"type":"text","text":"x","marks":[
21541 {"type":"subsup","attrs":{"type":"sub"}},
21542 {"type":"underline"}
21543 ]}
21544 ]}]}"#;
21545 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21546 }
21547
21548 #[test]
21549 fn issue_554_exact_reproducer_full_match() {
21550 let adf_json = r##"{
21553 "version": 1,
21554 "type": "doc",
21555 "content": [
21556 {
21557 "type": "paragraph",
21558 "content": [
21559 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21560 {"type":"text","text":"Approved","marks":[
21561 {"type":"textColor","attrs":{"color":"#008000"}}
21562 ]},
21563 {"type":"text","text":" — ready to proceed"}
21564 ]
21565 }
21566 ]
21567 }"##;
21568 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21569 let md = adf_to_markdown(&doc).unwrap();
21570 assert!(
21571 md.contains(":span[Approved]{color=#008000}"),
21572 "JFM should contain green span: {md}"
21573 );
21574 let rt = markdown_to_adf(&md).unwrap();
21575 let approved = rt.content[0]
21577 .content
21578 .as_ref()
21579 .unwrap()
21580 .iter()
21581 .find(|n| n.text.as_deref() == Some("Approved"))
21582 .expect("Approved text node");
21583 let marks = approved.marks.as_ref().expect("should have marks");
21584 let color_mark = marks
21585 .iter()
21586 .find(|m| m.mark_type == "textColor")
21587 .expect("textColor mark must be preserved");
21588 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21589 }
21590
21591 #[test]
21592 fn issue_554_textcolor_with_code_renders_span_around_code() {
21593 let doc = AdfDocument {
21596 version: 1,
21597 doc_type: "doc".to_string(),
21598 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21599 "fn main",
21600 vec![
21601 AdfMark::text_color("#008000"),
21602 AdfMark {
21603 mark_type: "code".to_string(),
21604 attrs: None,
21605 },
21606 ],
21607 )])],
21608 };
21609 let md = adf_to_markdown(&doc).unwrap();
21610 assert!(
21611 md.contains(":span[`fn main`]{color=#008000}"),
21612 "expected span-wrapped code, got: {md}"
21613 );
21614 }
21615
21616 #[test]
21617 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21618 let doc = AdfDocument {
21619 version: 1,
21620 doc_type: "doc".to_string(),
21621 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21622 "fn main",
21623 vec![
21624 AdfMark::underline(),
21625 AdfMark {
21626 mark_type: "code".to_string(),
21627 attrs: None,
21628 },
21629 ],
21630 )])],
21631 };
21632 let md = adf_to_markdown(&doc).unwrap();
21633 assert!(
21634 md.contains("[`fn main`]{underline}"),
21635 "expected bracketed-span around code, got: {md}"
21636 );
21637 }
21638
21639 #[test]
21642 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21643 let adf_json = r##"{
21648 "version": 1,
21649 "type": "doc",
21650 "content": [
21651 {
21652 "type": "paragraph",
21653 "content": [
21654 {"type":"text","text":"_ "},
21655 {"type":"text","text":"_Action:*","marks":[
21656 {"type":"textColor","attrs":{"color":"#008000"}}
21657 ]},
21658 {"type":"text","text":" Complete the setup process."}
21659 ]
21660 }
21661 ]
21662 }"##;
21663 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21664 let md = adf_to_markdown(&doc).unwrap();
21665 assert!(
21668 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21669 "underscores at node boundaries should be escaped: {md}"
21670 );
21671 let rt = markdown_to_adf(&md).unwrap();
21672 let para_content = rt.content[0].content.as_ref().unwrap();
21673 let colored = para_content
21675 .iter()
21676 .find(|n| {
21677 n.marks
21678 .as_deref()
21679 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21680 })
21681 .expect("textColor node must be preserved");
21682 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21683 let color_mark = colored
21684 .marks
21685 .as_ref()
21686 .unwrap()
21687 .iter()
21688 .find(|m| m.mark_type == "textColor")
21689 .unwrap();
21690 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21691 for n in para_content {
21693 if let Some(ms) = n.marks.as_deref() {
21694 assert!(
21695 !ms.iter().any(|m| m.mark_type == "em"),
21696 "no em mark should appear, got marks {:?}",
21697 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21698 );
21699 }
21700 }
21701 }
21702
21703 #[test]
21704 fn issue_554_underscore_intraword_left_unescaped() {
21705 let doc = AdfDocument {
21709 version: 1,
21710 doc_type: "doc".to_string(),
21711 content: vec![AdfNode::paragraph(vec![AdfNode::text(
21712 "call do_something_useful now",
21713 )])],
21714 };
21715 let md = adf_to_markdown(&doc).unwrap();
21716 assert!(
21717 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21718 "intraword underscores should not be escaped: {md}"
21719 );
21720 }
21721
21722 #[test]
21723 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21724 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21727 {"type":"text","text":"x","marks":[
21728 {"type":"underline"},
21729 {"type":"textColor","attrs":{"color":"#008000"}},
21730 {"type":"code"}
21731 ]}
21732 ]}]}"##;
21733 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21734 let md = adf_to_markdown(&doc).unwrap();
21735 assert!(
21737 md.starts_with('[') && md.contains("underline}"),
21738 "bracketed-span should wrap the span, got: {md}"
21739 );
21740 let rt = markdown_to_adf(&md).unwrap();
21741 let node = &rt.content[0].content.as_ref().unwrap()[0];
21742 let mark_types: Vec<&str> = node
21743 .marks
21744 .as_ref()
21745 .unwrap()
21746 .iter()
21747 .map(|m| m.mark_type.as_str())
21748 .collect();
21749 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21750 }
21751
21752 #[test]
21753 fn issue_554_textcolor_underline_link_all_preserved() {
21754 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21758 {"type":"text","text":"linked","marks":[
21759 {"type":"textColor","attrs":{"color":"#008000"}},
21760 {"type":"underline"},
21761 {"type":"link","attrs":{"href":"https://example.com"}}
21762 ]}
21763 ]}]}"##;
21764 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21765 let md = adf_to_markdown(&doc).unwrap();
21766 let rt = markdown_to_adf(&md).unwrap();
21767 let node = &rt.content[0].content.as_ref().unwrap()[0];
21768 let mark_types: Vec<&str> = node
21769 .marks
21770 .as_ref()
21771 .unwrap()
21772 .iter()
21773 .map(|m| m.mark_type.as_str())
21774 .collect();
21775 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21776 }
21777
21778 #[test]
21779 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21780 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21783 {"type":"text","text":"linked","marks":[
21784 {"type":"underline"},
21785 {"type":"textColor","attrs":{"color":"#008000"}},
21786 {"type":"link","attrs":{"href":"https://example.com"}}
21787 ]}
21788 ]}]}"##;
21789 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21790 let md = adf_to_markdown(&doc).unwrap();
21791 let rt = markdown_to_adf(&md).unwrap();
21792 let node = &rt.content[0].content.as_ref().unwrap()[0];
21793 let mark_types: Vec<&str> = node
21794 .marks
21795 .as_ref()
21796 .unwrap()
21797 .iter()
21798 .map(|m| m.mark_type.as_str())
21799 .collect();
21800 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21801 }
21802
21803 #[test]
21804 fn issue_554_link_underline_textcolor_link_outer() {
21805 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21809 {"type":"text","text":"linked","marks":[
21810 {"type":"link","attrs":{"href":"https://example.com"}},
21811 {"type":"underline"},
21812 {"type":"textColor","attrs":{"color":"#008000"}}
21813 ]}
21814 ]}]}"##;
21815 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21816 let md = adf_to_markdown(&doc).unwrap();
21817 assert!(
21818 md.starts_with('[') && md.contains("](https://example.com)"),
21819 "link should be outermost, got: {md}"
21820 );
21821 let rt = markdown_to_adf(&md).unwrap();
21822 let node = &rt.content[0].content.as_ref().unwrap()[0];
21823 let mark_types: Vec<&str> = node
21824 .marks
21825 .as_ref()
21826 .unwrap()
21827 .iter()
21828 .map(|m| m.mark_type.as_str())
21829 .collect();
21830 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21831 }
21832
21833 #[test]
21834 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21835 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21839 {"type":"text","text":"end_"},
21840 {"type":"text","text":"_start"}
21841 ]}]}"#;
21842 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21843 let md = adf_to_markdown(&doc).unwrap();
21844 let rt = markdown_to_adf(&md).unwrap();
21845 let combined: String = rt.content[0]
21847 .content
21848 .as_ref()
21849 .unwrap()
21850 .iter()
21851 .filter_map(|n| n.text.as_deref())
21852 .collect();
21853 assert_eq!(combined, "end__start");
21854 for n in rt.content[0].content.as_ref().unwrap() {
21856 if let Some(ms) = n.marks.as_deref() {
21857 assert!(!ms.iter().any(|m| m.mark_type == "em"));
21858 }
21859 }
21860 }
21861}