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(
1255 "DECIDED",
1256 vec![AdfNode::paragraph(inline_nodes)],
1257 ));
1258 }
1259 }
1260 items
1261}
1262
1263fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1271 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1272 Some(("TODO", rest))
1273 } else if let Some(rest) =
1274 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1275 {
1276 Some(("DONE", rest))
1277 } else {
1278 None
1279 }
1280}
1281
1282fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1286 let rest = text.strip_prefix(checkbox)?;
1287 if rest.is_empty() {
1288 Some(rest)
1289 } else {
1290 rest.strip_prefix(' ')
1291 }
1292}
1293
1294fn starts_with_task_marker(s: &str) -> bool {
1302 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1303 rest
1304 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1305 rest
1306 } else {
1307 return false;
1308 };
1309 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1310}
1311
1312fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1314 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1315 if digit_end == 0 {
1316 return None;
1317 }
1318 let rest = &line[digit_end..];
1319 let after_marker = rest.strip_prefix(". ")?;
1320 let num: u32 = line[..digit_end].parse().ok()?;
1321 Some((num, after_marker))
1322}
1323
1324fn has_trailing_hard_break(line: &str) -> bool {
1327 line.ends_with('\\') || line.ends_with(" ")
1328}
1329
1330fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1338 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1339}
1340
1341fn is_list_start(line: &str) -> bool {
1343 let trimmed = line.trim_start();
1344 trimmed.starts_with("- ")
1345 || trimmed.starts_with("* ")
1346 || trimmed.starts_with("+ ")
1347 || parse_ordered_list_marker(trimmed).is_some()
1348}
1349
1350fn escape_emphasis_markers(text: &str) -> String {
1363 escape_emphasis_with(text, false)
1364}
1365
1366fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1374 escape_emphasis_with(text, true)
1375}
1376
1377fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1383 let chars: Vec<char> = text.chars().collect();
1384 let mut out = String::with_capacity(text.len());
1385 let mut idx = 0;
1386 while idx < chars.len() {
1387 let ch = chars[idx];
1388 if ch == '*' {
1389 out.push('\\');
1390 out.push(ch);
1391 idx += 1;
1392 } else if ch == '_' {
1393 let run_start = idx;
1397 let mut run_end = idx;
1398 while run_end < chars.len() && chars[run_end] == '_' {
1399 run_end += 1;
1400 }
1401 let escape_run = if escape_underscore_always {
1402 true
1403 } else {
1404 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1405 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1406 !(before_alnum && after_alnum)
1407 };
1408 for _ in run_start..run_end {
1409 if escape_run {
1410 out.push('\\');
1411 }
1412 out.push('_');
1413 }
1414 idx = run_end;
1415 } else {
1416 out.push(ch);
1417 idx += 1;
1418 }
1419 }
1420 out
1421}
1422
1423fn escape_backticks(text: &str) -> String {
1429 let mut out = String::with_capacity(text.len());
1430 for ch in text.chars() {
1431 if ch == '`' {
1432 out.push('\\');
1433 }
1434 out.push(ch);
1435 }
1436 out
1437}
1438
1439fn inline_code_delimiter(text: &str) -> (usize, bool) {
1447 let mut max_run = 0usize;
1448 let mut current = 0usize;
1449 for ch in text.chars() {
1450 if ch == '`' {
1451 current += 1;
1452 if current > max_run {
1453 max_run = current;
1454 }
1455 } else {
1456 current = 0;
1457 }
1458 }
1459 let n = max_run + 1;
1460 let starts_bt = text.starts_with('`');
1461 let ends_bt = text.ends_with('`');
1462 let starts_sp = text.starts_with(' ');
1463 let ends_sp = text.ends_with(' ');
1464 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1465 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1466 (n, needs_pad)
1467}
1468
1469fn render_inline_code(text: &str, output: &mut String) {
1472 let (n, pad) = inline_code_delimiter(text);
1473 for _ in 0..n {
1474 output.push('`');
1475 }
1476 if pad {
1477 output.push(' ');
1478 }
1479 output.push_str(text);
1480 if pad {
1481 output.push(' ');
1482 }
1483 for _ in 0..n {
1484 output.push('`');
1485 }
1486}
1487
1488fn escape_pipes_in_cell(text: &str) -> String {
1495 let mut out = String::with_capacity(text.len());
1496 for ch in text.chars() {
1497 if ch == '|' {
1498 out.push('\\');
1499 }
1500 out.push(ch);
1501 }
1502 out
1503}
1504
1505fn escape_link_brackets(text: &str) -> String {
1510 let mut out = String::with_capacity(text.len());
1511 for ch in text.chars() {
1512 if ch == '[' || ch == ']' {
1513 out.push('\\');
1514 }
1515 out.push(ch);
1516 }
1517 out
1518}
1519
1520fn escape_bare_urls(text: &str) -> String {
1526 let mut result = String::with_capacity(text.len());
1527 for (i, ch) in text.char_indices() {
1528 if ch == 'h' {
1529 let rest = &text[i..];
1530 if rest.starts_with("http://") || rest.starts_with("https://") {
1531 result.push('\\');
1532 }
1533 }
1534 result.push(ch);
1535 }
1536 result
1537}
1538
1539fn url_safe_in_bracket_content(s: &str) -> bool {
1548 if s.contains('\n') {
1549 return false;
1550 }
1551 let mut depth: i32 = 1;
1552 for ch in s.chars() {
1553 match ch {
1554 '[' => depth += 1,
1555 ']' => {
1556 depth -= 1;
1557 if depth == 0 {
1558 return false;
1559 }
1560 }
1561 _ => {}
1562 }
1563 }
1564 true
1565}
1566
1567fn escape_emoji_shortcodes(text: &str) -> String {
1578 let mut result = String::with_capacity(text.len());
1579
1580 for (i, ch) in text.char_indices() {
1581 if ch == ':' {
1582 let after = i + 1;
1585 if after < text.len() {
1586 let name_end = text[after..]
1587 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1588 .map_or(text[after..].len(), |pos| pos);
1589 if name_end > 0
1590 && after + name_end < text.len()
1591 && text.as_bytes()[after + name_end] == b':'
1592 {
1593 result.push('\\');
1595 }
1596 }
1597 }
1598 result.push(ch);
1599 }
1600
1601 result
1602}
1603
1604fn escape_list_marker(line: &str) -> String {
1608 if let Some(dot_pos) = line.find(". ") {
1609 if parse_ordered_list_marker(line).is_some() {
1610 let mut s = String::with_capacity(line.len() + 1);
1611 s.push_str(&line[..dot_pos]);
1612 s.push('\\');
1613 s.push_str(&line[dot_pos..]);
1614 return s;
1615 }
1616 }
1617 for prefix in &["- ", "* ", "+ "] {
1618 if line.starts_with(prefix) {
1619 let mut s = String::with_capacity(line.len() + 1);
1620 s.push('\\');
1621 s.push_str(line);
1622 return s;
1623 }
1624 }
1625 line.to_string()
1626}
1627
1628fn is_code_fence_opener(line: &str) -> bool {
1635 if !line.starts_with("```") {
1636 return false;
1637 }
1638 !line[3..].contains('`')
1639}
1640
1641fn is_horizontal_rule(line: &str) -> bool {
1643 let trimmed = line.trim();
1644 trimmed.len() >= 3
1645 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1646 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1647 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1648}
1649
1650fn is_table_separator(line: &str) -> bool {
1652 let trimmed = line.trim();
1653 trimmed.contains('|')
1654 && trimmed
1655 .chars()
1656 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1657}
1658
1659fn parse_table_row(line: &str) -> Vec<String> {
1666 let trimmed = line.trim();
1667 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1668 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1669
1670 let mut cells: Vec<String> = Vec::new();
1671 let mut current = String::new();
1672 let mut chars = trimmed.chars().peekable();
1673 while let Some(ch) = chars.next() {
1674 if ch == '\\' && chars.peek() == Some(&'|') {
1675 current.push('|');
1676 chars.next();
1677 } else if ch == '|' {
1678 cells.push(std::mem::take(&mut current));
1679 } else {
1680 current.push(ch);
1681 }
1682 }
1683 cells.push(current);
1684
1685 cells
1686 .iter()
1687 .map(|s| {
1688 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1691 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1692 stripped.to_string()
1693 })
1694 .collect()
1695}
1696
1697fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1700 let trimmed = separator_line.trim();
1701 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1702 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1703
1704 trimmed
1705 .split('|')
1706 .map(|cell| {
1707 let cell = cell.trim();
1708 let starts_colon = cell.starts_with(':');
1709 let ends_colon = cell.ends_with(':');
1710 match (starts_colon, ends_colon) {
1711 (true, true) => Some("center"),
1712 (false, true) => Some("end"),
1713 _ => None, }
1715 })
1716 .collect()
1717}
1718
1719fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1721 if let Some(align) = alignment {
1722 para.marks = Some(vec![AdfMark::alignment(align)]);
1723 }
1724}
1725
1726fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1729 let trimmed = cell_text.trim_start();
1730 if !trimmed.starts_with('{') {
1731 return (cell_text.to_string(), None);
1732 }
1733 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1734 let remaining = trimmed[end_pos..].trim_start().to_string();
1735 let adf_attrs = build_cell_attrs(&attrs);
1736 (remaining, Some(adf_attrs))
1737 } else {
1738 (cell_text.to_string(), None)
1739 }
1740}
1741
1742fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1745 let line = line.trim();
1746 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1755 let after_img = line[img_end..].trim_start();
1756
1757 if after_img.starts_with('{') {
1758 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1759 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1761 let mut media_attrs = serde_json::json!({"type": "file"});
1762 if let Some(id) = attrs.get("id") {
1763 media_attrs["id"] = serde_json::Value::String(id.to_string());
1764 }
1765 if let Some(collection) = attrs.get("collection") {
1766 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1767 }
1768 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1769 media_attrs["occurrenceKey"] =
1770 serde_json::Value::String(occurrence_key.to_string());
1771 }
1772 if let Some(height) = attrs.get("height") {
1773 if let Some(h) = parse_numeric_attr(height) {
1774 media_attrs["height"] = h;
1775 }
1776 }
1777 if let Some(width) = attrs.get("width") {
1778 if let Some(w) = parse_numeric_attr(width) {
1779 media_attrs["width"] = w;
1780 }
1781 }
1782 if let Some(alt_text) = alt_opt {
1783 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1784 }
1785 if let Some(local_id) = attrs.get("localId") {
1786 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1787 }
1788 let mut ms_attrs = serde_json::json!({"layout": "center"});
1789 if let Some(layout) = attrs.get("layout") {
1790 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1791 }
1792 if let Some(ms_width) = attrs.get("mediaWidth") {
1793 if let Some(w) = parse_numeric_attr(ms_width) {
1794 ms_attrs["width"] = w;
1795 }
1796 }
1797 if let Some(wt) = attrs.get("widthType") {
1798 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1799 }
1800 if let Some(mode) = attrs.get("mode") {
1801 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1802 }
1803 let border_marks = build_border_marks(&attrs);
1804 let media_marks = if border_marks.is_empty() {
1805 None
1806 } else {
1807 Some(border_marks)
1808 };
1809 return Some(AdfNode {
1810 node_type: "mediaSingle".to_string(),
1811 attrs: Some(ms_attrs),
1812 content: Some(vec![AdfNode {
1813 node_type: "media".to_string(),
1814 attrs: Some(media_attrs),
1815 content: None,
1816 text: None,
1817 marks: media_marks,
1818 local_id: None,
1819 parameters: None,
1820 }]),
1821 text: None,
1822 marks: None,
1823 local_id: None,
1824 parameters: None,
1825 });
1826 }
1827
1828 let mut node = AdfNode::media_single(url, alt_opt);
1830 if let Some(ref mut node_attrs) = node.attrs {
1831 if let Some(layout) = attrs.get("layout") {
1832 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1833 }
1834 if let Some(width) = attrs.get("width") {
1835 if let Some(w) = parse_numeric_attr(width) {
1836 node_attrs["width"] = w;
1837 }
1838 }
1839 if let Some(wt) = attrs.get("widthType") {
1840 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1841 }
1842 if let Some(mode) = attrs.get("mode") {
1843 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1844 }
1845 }
1846 if let Some(ref mut content) = node.content {
1847 if let Some(media) = content.first_mut() {
1848 if let Some(local_id) = attrs.get("localId") {
1849 if let Some(ref mut media_attrs) = media.attrs {
1850 media_attrs["localId"] =
1851 serde_json::Value::String(local_id.to_string());
1852 }
1853 }
1854 let border_marks = build_border_marks(&attrs);
1855 if !border_marks.is_empty() {
1856 media.marks = Some(border_marks);
1857 }
1858 }
1859 }
1860 return Some(node);
1861 }
1862 }
1863
1864 Some(AdfNode::media_single(url, alt_opt))
1865}
1866
1867fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1869 let line = line.trim();
1870 if !line.starts_with("?;
1875 let alt = &line[2..alt_end];
1876 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1878 let url = &line[paren_start + 1..url_end];
1879
1880 Some((alt, url))
1881}
1882
1883fn parse_inline(text: &str) -> Vec<AdfNode> {
1891 parse_inline_impl(text, true)
1892}
1893
1894fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1901 parse_inline_impl(text, false)
1902}
1903
1904fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1909 let mut nodes = Vec::new();
1910 let mut chars = text.char_indices().peekable();
1911 let mut plain_start = 0;
1912
1913 while let Some(&(i, ch)) = chars.peek() {
1914 match ch {
1915 '*' | '_' => {
1916 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1917 flush_plain(text, plain_start, i, &mut nodes);
1918 let mark = if is_bold {
1919 AdfMark::strong()
1920 } else {
1921 AdfMark::em()
1922 };
1923 let inner = parse_inline_no_auto_cards(content);
1924 for mut node in inner {
1925 prepend_mark(&mut node, mark.clone());
1926 nodes.push(node);
1927 }
1928 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1930 chars.next();
1931 }
1932 plain_start = end;
1933 continue;
1934 }
1935 if ch == '_' {
1940 while chars.peek().is_some_and(|&(_, c)| c == '_') {
1941 chars.next();
1942 }
1943 } else {
1944 chars.next();
1945 }
1946 }
1947 '~' => {
1948 if let Some((end, content)) = try_parse_strikethrough(text, i) {
1949 flush_plain(text, plain_start, i, &mut nodes);
1950 let inner = parse_inline_no_auto_cards(content);
1951 for mut node in inner {
1952 prepend_mark(&mut node, AdfMark::strike());
1953 nodes.push(node);
1954 }
1955 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1956 chars.next();
1957 }
1958 plain_start = end;
1959 continue;
1960 }
1961 chars.next();
1962 }
1963 '`' => {
1964 if let Some((end, content)) = try_parse_inline_code(text, i) {
1965 flush_plain(text, plain_start, i, &mut nodes);
1966 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1967 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1968 chars.next();
1969 }
1970 plain_start = end;
1971 continue;
1972 }
1973 while chars.peek().is_some_and(|&(_, c)| c == '`') {
1976 chars.next();
1977 }
1978 }
1979 '[' => {
1980 if let Some((end, link_text, href)) = try_parse_link(text, i) {
1981 flush_plain(text, plain_start, i, &mut nodes);
1982 if link_text.starts_with("http://") || link_text.starts_with("https://") {
1983 nodes.push(AdfNode::text_with_marks(
1988 link_text,
1989 vec![AdfMark::link(href)],
1990 ));
1991 } else {
1992 let inner = parse_inline_no_auto_cards(link_text);
1993 for mut node in inner {
1994 prepend_mark(&mut node, AdfMark::link(href));
1995 nodes.push(node);
1996 }
1997 }
1998 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1999 chars.next();
2000 }
2001 plain_start = end;
2002 continue;
2003 }
2004 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2006 flush_plain(text, plain_start, i, &mut nodes);
2007 nodes.extend(span_nodes);
2008 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2009 chars.next();
2010 }
2011 plain_start = end;
2012 continue;
2013 }
2014 chars.next();
2015 }
2016 ':' => {
2017 if let Some(node) = try_dispatch_inline_directive(text, i) {
2019 flush_plain(text, plain_start, i, &mut nodes);
2020 let end = node.1;
2021 nodes.push(node.0);
2022 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2023 chars.next();
2024 }
2025 plain_start = end;
2026 continue;
2027 }
2028 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2030 flush_plain(text, plain_start, i, &mut nodes);
2031 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2032 nodes.push(emoji_node);
2033 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2034 chars.next();
2035 }
2036 plain_start = final_end;
2037 continue;
2038 }
2039 chars.next();
2040 }
2041 ' ' if text[i..].starts_with(" \n") => {
2042 flush_plain(text, plain_start, i, &mut nodes);
2045 nodes.push(AdfNode::hard_break());
2046 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2048 chars.next();
2049 }
2050 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2052 chars.next();
2053 }
2054 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2055 }
2056 '!' if text[i..].starts_with("![") => {
2057 chars.next();
2061 }
2062 'h' if auto_inline_card
2063 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2064 {
2065 if let Some((end, url)) = try_parse_bare_url(text, i) {
2066 flush_plain(text, plain_start, i, &mut nodes);
2067 nodes.push(AdfNode::inline_card(url));
2068 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2069 chars.next();
2070 }
2071 plain_start = end;
2072 continue;
2073 }
2074 chars.next();
2075 }
2076 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2077 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2078 {
2079 flush_plain(text, plain_start, i, &mut nodes);
2083 nodes.push(AdfNode::text("\n"));
2084 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2087 }
2088 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2089 flush_plain(text, plain_start, i, &mut nodes);
2094 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2099 chars.next(); }
2101 '\\' if text[i..].starts_with("\\\n") => {
2102 flush_plain(text, plain_start, i, &mut nodes);
2104 nodes.push(AdfNode::hard_break());
2105 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2108 chars.next();
2109 }
2110 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2111 }
2112 '\\' if i + 1 == text.len() => {
2113 flush_plain(text, plain_start, i, &mut nodes);
2115 nodes.push(AdfNode::hard_break());
2116 chars.next(); plain_start = text.len();
2118 }
2119 _ => {
2120 chars.next();
2121 }
2122 }
2123 }
2124
2125 if plain_start < text.len() {
2127 let remaining = &text[plain_start..];
2128 if !remaining.is_empty() {
2129 nodes.push(AdfNode::text(remaining));
2130 }
2131 }
2132
2133 merge_adjacent_text(&mut nodes);
2136
2137 nodes
2138}
2139
2140fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2142 let mut i = 0;
2143 while i + 1 < nodes.len() {
2144 if nodes[i].node_type == "text"
2145 && nodes[i + 1].node_type == "text"
2146 && nodes[i].marks.is_none()
2147 && nodes[i + 1].marks.is_none()
2148 {
2149 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2150 if let Some(ref mut t) = nodes[i].text {
2151 t.push_str(&next_text);
2152 }
2153 nodes.remove(i + 1);
2154 } else {
2155 i += 1;
2156 }
2157 }
2158}
2159
2160fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2162 if start < end {
2163 let plain = &text[start..end];
2164 if !plain.is_empty() {
2165 nodes.push(AdfNode::text(plain));
2166 }
2167 }
2168}
2169
2170#[cfg(test)]
2172fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2173 if let Some(ref mut marks) = node.marks {
2174 marks.push(mark);
2175 } else {
2176 node.marks = Some(vec![mark]);
2177 }
2178}
2179
2180fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2182 if let Some(ref mut marks) = node.marks {
2183 marks.insert(0, mark);
2184 } else {
2185 node.marks = Some(vec![mark]);
2186 }
2187}
2188
2189fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2194 let before = text[..delim_pos]
2195 .chars()
2196 .next_back()
2197 .is_some_and(char::is_alphanumeric);
2198 let after = text[delim_pos + len..]
2199 .chars()
2200 .next()
2201 .is_some_and(char::is_alphanumeric);
2202 before && after
2203}
2204
2205fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2209 let needle_bytes = needle.as_bytes();
2210 let hay_bytes = haystack.as_bytes();
2211 let mut i = 0;
2212 while i < hay_bytes.len() {
2213 if hay_bytes[i] == b'\\' {
2214 i += 2; continue;
2216 }
2217 if hay_bytes[i..].starts_with(needle_bytes) {
2218 return Some(i);
2219 }
2220 i += 1;
2221 }
2222 None
2223}
2224
2225fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2228 let hay_bytes = haystack.as_bytes();
2229 let mut i = 0;
2230 while i < hay_bytes.len() {
2231 if hay_bytes[i] == b'\\' {
2232 i += 2;
2233 continue;
2234 }
2235 if hay_bytes[i] == ch {
2236 return Some(i);
2237 }
2238 i += 1;
2239 }
2240 None
2241}
2242
2243fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2253 let rest = &text[i..];
2254
2255 if rest.starts_with("***") || rest.starts_with("___") {
2259 let is_underscore = rest.starts_with("___");
2260 if is_underscore && is_intraword_underscore(text, i, 3) {
2261 return None;
2262 }
2263 let triple = &rest[..3];
2264 let after = &rest[3..];
2265 if let Some(close) = find_unescaped(after, triple) {
2266 if close > 0 {
2267 let close_pos = i + 3 + close;
2268 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2269 return None;
2270 }
2271 let content = &rest[2..=3 + close];
2275 let end = i + 3 + close + 3;
2276 return Some((end, content, true));
2277 }
2278 }
2279 }
2280
2281 if rest.starts_with("**") || rest.starts_with("__") {
2283 let is_underscore = rest.starts_with("__");
2284 if is_underscore && is_intraword_underscore(text, i, 2) {
2285 return None;
2286 }
2287 let delimiter = &rest[..2];
2288 let after = &rest[2..];
2289 let close = find_unescaped(after, delimiter)?;
2290 if close == 0 {
2291 return None;
2292 }
2293 let close_pos = i + 2 + close;
2294 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2295 return None;
2296 }
2297 let content = &after[..close];
2298 let end = i + 2 + close + 2;
2299 return Some((end, content, true));
2300 }
2301
2302 if rest.starts_with('*') || rest.starts_with('_') {
2304 let delim_char = rest.as_bytes()[0];
2305 let is_underscore = delim_char == b'_';
2306 if is_underscore && is_intraword_underscore(text, i, 1) {
2307 return None;
2308 }
2309 let after = &rest[1..];
2310 let close = find_unescaped_char(after, delim_char)?;
2311 if close == 0 {
2312 return None;
2313 }
2314 let close_pos = i + 1 + close;
2315 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2316 return None;
2317 }
2318 let content = &after[..close];
2319 let end = i + 1 + close + 1;
2320 return Some((end, content, false));
2321 }
2322
2323 None
2324}
2325
2326fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2328 let rest = &text[i..];
2329 if !rest.starts_with("~~") {
2330 return None;
2331 }
2332 let after = &rest[2..];
2333 let close = after.find("~~")?;
2334 if close == 0 {
2335 return None;
2336 }
2337 let content = &after[..close];
2338 Some((i + 2 + close + 2, content))
2339}
2340
2341fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2348 let rest = &text[i..];
2349 let bytes = rest.as_bytes();
2350 if bytes.is_empty() || bytes[0] != b'`' {
2351 return None;
2352 }
2353 let mut opening = 0usize;
2354 while opening < bytes.len() && bytes[opening] == b'`' {
2355 opening += 1;
2356 }
2357
2358 let mut j = opening;
2359 while j < bytes.len() {
2360 if bytes[j] == b'`' {
2361 let run_start = j;
2362 while j < bytes.len() && bytes[j] == b'`' {
2363 j += 1;
2364 }
2365 if j - run_start == opening {
2366 let content = &rest[opening..run_start];
2367 let content = strip_code_span_padding(content);
2368 return Some((i + j, content));
2369 }
2370 } else {
2371 j += 1;
2372 }
2373 }
2374 None
2375}
2376
2377fn strip_code_span_padding(content: &str) -> &str {
2381 let bytes = content.as_bytes();
2382 if bytes.len() >= 2
2383 && bytes[0] == b' '
2384 && bytes[bytes.len() - 1] == b' '
2385 && content.bytes().any(|b| b != b' ')
2386 {
2387 &content[1..content.len() - 1]
2388 } else {
2389 content
2390 }
2391}
2392
2393fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2396 let rest = &text[i..];
2397 if !rest.starts_with('[') {
2398 return None;
2399 }
2400
2401 let mut depth: usize = 0;
2405 let mut bracket_close = None;
2406 let bs_bytes = rest.as_bytes();
2407 for (j, ch) in rest.char_indices() {
2408 match ch {
2409 '\\' if j + 1 < bs_bytes.len()
2410 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2411 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2412 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2413 depth -= 1;
2414 if depth == 0 {
2415 bracket_close = Some(j);
2416 break;
2417 }
2418 }
2419 _ => {}
2420 }
2421 }
2422 let bracket_close = bracket_close?;
2423 let after_bracket = &rest[bracket_close + 1..];
2425 if !after_bracket.starts_with('{') {
2426 return None;
2427 }
2428
2429 let span_text = &rest[1..bracket_close];
2430 let attrs_start = i + bracket_close + 1;
2431 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2432
2433 let mut marks = Vec::new();
2434 if attrs.has_flag("underline") {
2435 marks.push(AdfMark::underline());
2436 }
2437 let ann_ids = attrs.get_all("annotation-id");
2438 let ann_types = attrs.get_all("annotation-type");
2439 for (idx, ann_id) in ann_ids.iter().enumerate() {
2440 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2441 marks.push(AdfMark::annotation(ann_id, ann_type));
2442 }
2443
2444 if marks.is_empty() {
2445 return None; }
2447
2448 let inner = parse_inline_no_auto_cards(span_text);
2449 let result: Vec<AdfNode> = inner
2450 .into_iter()
2451 .map(|mut node| {
2452 let mut combined = marks.clone();
2455 if let Some(ref existing) = node.marks {
2456 combined.extend(existing.iter().cloned());
2457 }
2458 node.marks = Some(combined);
2459 node
2460 })
2461 .collect();
2462
2463 Some((attrs_end, result))
2464}
2465
2466fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2469 let d = try_parse_inline_directive(text, pos)?;
2470 let content = d.content.as_deref().unwrap_or("");
2471
2472 let node = match d.name.as_str() {
2473 "card" => {
2474 let url = d
2479 .attrs
2480 .as_ref()
2481 .and_then(|a| a.get("url"))
2482 .unwrap_or(content);
2483 let mut node = AdfNode::inline_card(url);
2484 pass_through_local_id(&d.attrs, &mut node);
2485 node
2486 }
2487 "status" => {
2488 let color = d
2489 .attrs
2490 .as_ref()
2491 .and_then(|a| a.get("color"))
2492 .unwrap_or("neutral");
2493 let mut node = AdfNode::status(content, color);
2494 if let Some(ref attrs) = d.attrs {
2496 if let Some(ref mut node_attrs) = node.attrs {
2497 if let Some(style) = attrs.get("style") {
2498 node_attrs["style"] = serde_json::Value::String(style.to_string());
2499 }
2500 if let Some(local_id) = attrs.get("localId") {
2501 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2502 }
2503 }
2504 }
2505 node
2506 }
2507 "date" => {
2508 let timestamp = d
2509 .attrs
2510 .as_ref()
2511 .and_then(|a| a.get("timestamp"))
2512 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2513 let mut node = AdfNode::date(×tamp);
2514 pass_through_local_id(&d.attrs, &mut node);
2515 node
2516 }
2517 "mention" => {
2518 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2519 let mut node = AdfNode::mention(id, content);
2520 if let Some(ref attrs) = d.attrs {
2522 if let (Some(ref mut node_attrs), true) = (
2523 &mut node.attrs,
2524 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2525 ) {
2526 if let Some(ut) = attrs.get("userType") {
2527 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2528 }
2529 if let Some(al) = attrs.get("accessLevel") {
2530 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2531 }
2532 }
2533 }
2534 pass_through_local_id(&d.attrs, &mut node);
2535 node
2536 }
2537 "span" => {
2538 let mut marks = Vec::new();
2539 if let Some(ref attrs) = d.attrs {
2540 if let Some(color) = attrs.get("color") {
2541 marks.push(AdfMark::text_color(color));
2542 }
2543 if let Some(bg) = attrs.get("bg") {
2544 marks.push(AdfMark::background_color(bg));
2545 }
2546 if attrs.has_flag("sub") {
2547 marks.push(AdfMark::subsup("sub"));
2548 }
2549 if attrs.has_flag("sup") {
2550 marks.push(AdfMark::subsup("sup"));
2551 }
2552 }
2553 if marks.is_empty() {
2554 AdfNode::text(content)
2555 } else {
2556 let inner = parse_inline_no_auto_cards(content);
2559 let mut nodes: Vec<AdfNode> = inner
2560 .into_iter()
2561 .map(|mut node| {
2562 let mut combined = marks.clone();
2563 if let Some(ref existing) = node.marks {
2564 combined.extend(existing.iter().cloned());
2565 }
2566 node.marks = Some(combined);
2567 node
2568 })
2569 .collect();
2570 nodes.remove(0)
2572 }
2573 }
2574 "placeholder" => AdfNode::placeholder(content),
2575 "media-inline" => {
2576 let mut json_attrs = serde_json::Map::new();
2577 if let Some(ref attrs) = d.attrs {
2578 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2579 if let Some(val) = attrs.get(key) {
2580 if *key == "width" || *key == "height" {
2581 if let Ok(n) = val.parse::<u64>() {
2582 json_attrs.insert(
2583 (*key).to_string(),
2584 serde_json::Value::Number(n.into()),
2585 );
2586 continue;
2587 }
2588 }
2589 json_attrs.insert(
2590 (*key).to_string(),
2591 serde_json::Value::String(val.to_string()),
2592 );
2593 }
2594 }
2595 if let Some(local_id) = attrs.get("localId") {
2596 json_attrs.insert(
2597 "localId".to_string(),
2598 serde_json::Value::String(local_id.to_string()),
2599 );
2600 }
2601 }
2602 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2603 }
2604 "extension" => {
2605 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2606 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2607 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2608 }
2609 _ => return None, };
2611
2612 Some((node, d.end_pos))
2613}
2614
2615fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2618 let rest = &text[i..];
2619 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2620 return None;
2621 }
2622 let end = rest
2624 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2625 .unwrap_or(rest.len());
2626 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2628 if url.len() <= "https://".len() {
2629 return None; }
2631 Some((i + url.len(), url))
2632}
2633
2634fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2637 let rest = &text[i..];
2638 if !rest.starts_with(':') {
2639 return None;
2640 }
2641 let after = &rest[1..];
2642 let name_end =
2643 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2644 if name_end == 0 {
2645 return None;
2646 }
2647 if after.as_bytes().get(name_end) != Some(&b':') {
2648 return None;
2649 }
2650 let name = &after[..name_end];
2651 Some((i + 1 + name_end + 1, name))
2652}
2653
2654fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2657 let mut chain_end = shortcode_end;
2662 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2663 chain_end = next_end;
2664 }
2665 if chain_end > shortcode_end {
2666 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2667 return (attr_end, build_emoji_node(&attrs, short_name));
2668 }
2669 }
2670
2671 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2672 (attr_end, build_emoji_node(&attrs, short_name))
2673 } else {
2674 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2675 }
2676}
2677
2678fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2681 let resolved_name = attrs
2682 .get("shortName")
2683 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2684 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2685 if let Some(id) = attrs.get("id") {
2686 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2687 }
2688 if let Some(t) = attrs.get("text") {
2689 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2690 }
2691 if let Some(lid) = attrs.get("localId") {
2692 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2693 }
2694 AdfNode {
2695 node_type: "emoji".to_string(),
2696 attrs: Some(emoji_attrs),
2697 content: None,
2698 text: None,
2699 marks: None,
2700 local_id: None,
2701 parameters: None,
2702 }
2703}
2704
2705fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2710 let mut depth: usize = 0;
2711 for (j, ch) in s[open..].char_indices() {
2712 match ch {
2713 '(' => depth += 1,
2714 ')' => {
2715 depth -= 1;
2716 if depth == 0 {
2717 return Some(open + j);
2718 }
2719 }
2720 _ => {}
2721 }
2722 }
2723 None
2724}
2725
2726fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2732 let rest = &text[i..];
2733 if !rest.starts_with('[') {
2734 return None;
2735 }
2736
2737 let mut depth: usize = 0;
2739 let mut text_end = None;
2740 let bytes = rest.as_bytes();
2741 for (j, ch) in rest.char_indices() {
2742 match ch {
2743 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2744 }
2746 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2747 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2748 depth -= 1;
2749 if depth == 0 {
2750 text_end = Some(j);
2751 break;
2752 }
2753 }
2754 _ => {}
2755 }
2756 }
2757
2758 let text_end = text_end?;
2759 let link_text = &rest[1..text_end];
2760 let after_bracket = &rest[text_end + 1..];
2762 if !after_bracket.starts_with('(') {
2763 return None;
2764 }
2765 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2767 let href = &rest[url_start + 1..url_end];
2768
2769 Some((i + url_end + 1, link_text, href))
2770}
2771
2772#[derive(Debug, Clone, Default)]
2776pub struct RenderOptions {
2777 pub strip_local_ids: bool,
2779}
2780
2781pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2783 adf_to_markdown_with_options(doc, &RenderOptions::default())
2784}
2785
2786pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2788 let mut output = String::new();
2789
2790 for (i, node) in doc.content.iter().enumerate() {
2791 if i > 0 {
2792 output.push('\n');
2793 }
2794 render_block_node(node, &mut output, opts);
2795 }
2796
2797 Ok(output)
2798}
2799
2800fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2804 if let Some(ref attrs) = dir_attrs {
2805 if let Some(local_id) = attrs.get("localId") {
2806 if let Some(ref mut node_attrs) = node.attrs {
2807 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2808 } else {
2809 node.attrs = Some(serde_json::json!({"localId": local_id}));
2810 }
2811 }
2812 }
2813}
2814
2815fn pass_through_expand_params(
2818 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2819 node: &mut AdfNode,
2820) {
2821 if let Some(ref attrs) = dir_attrs {
2822 if let Some(local_id) = attrs.get("localId") {
2823 node.local_id = Some(local_id.to_string());
2824 }
2825 if let Some(params_str) = attrs.get("params") {
2826 if let Ok(params) = serde_json::from_str(params_str) {
2827 node.parameters = Some(params);
2828 }
2829 }
2830 }
2831}
2832
2833fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2843 let trimmed = text.trim_end();
2844 if !trimmed.ends_with('}') {
2845 return (text, None, None);
2846 }
2847 if let Some(brace_pos) = trimmed.rfind('{') {
2852 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2853 return (text, None, None);
2854 }
2855 let attr_str = &trimmed[brace_pos..];
2856 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2857 let local_id = attrs.get("localId").map(str::to_string);
2858 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2859 if local_id.is_some() || para_local_id.is_some() {
2860 let before = trimmed[..brace_pos]
2861 .strip_suffix(' ')
2862 .unwrap_or(&trimmed[..brace_pos]);
2863 return (before, local_id, para_local_id);
2864 }
2865 }
2866 }
2867 (text, None, None)
2868}
2869
2870fn parse_list_item_first_line(
2877 item_text: &str,
2878 sub_lines: Vec<String>,
2879 local_id: Option<String>,
2880 para_local_id: Option<String>,
2881) -> Result<AdfNode> {
2882 if item_text.starts_with("```") {
2883 let mut all_lines = vec![item_text.to_string()];
2885 all_lines.extend(sub_lines);
2886 let combined = all_lines.join("\n");
2887 let nested = MarkdownParser::new(&combined).parse_blocks()?;
2888 Ok(list_item_with_local_id(nested, local_id, para_local_id))
2889 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2890 if sub_lines.is_empty() {
2892 Ok(list_item_with_local_id(
2893 vec![media],
2894 local_id,
2895 para_local_id,
2896 ))
2897 } else {
2898 let sub_text = sub_lines.join("\n");
2899 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2900 let mut content = vec![media];
2901 content.append(&mut nested);
2902 Ok(list_item_with_local_id(content, local_id, para_local_id))
2903 }
2904 } else {
2905 let first_node = AdfNode::paragraph(parse_inline(item_text));
2906 if sub_lines.is_empty() {
2907 Ok(list_item_with_local_id(
2908 vec![first_node],
2909 local_id,
2910 para_local_id,
2911 ))
2912 } else {
2913 let sub_text = sub_lines.join("\n");
2914 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2915 let mut content = vec![first_node];
2916 content.append(&mut nested);
2917 Ok(list_item_with_local_id(content, local_id, para_local_id))
2918 }
2919 }
2920}
2921
2922fn list_item_with_local_id(
2923 mut content: Vec<AdfNode>,
2924 local_id: Option<String>,
2925 para_local_id: Option<String>,
2926) -> AdfNode {
2927 if let Some(id) = ¶_local_id {
2928 if let Some(first) = content.first_mut() {
2929 if first.node_type == "paragraph" {
2930 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2931 node_attrs["localId"] = serde_json::Value::String(id.clone());
2932 }
2933 }
2934 }
2935 let mut item = AdfNode::list_item(content);
2936 if let Some(id) = local_id {
2937 item.attrs = Some(serde_json::json!({"localId": id}));
2938 }
2939 item
2940}
2941
2942fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2943 if opts.strip_local_ids {
2944 return;
2945 }
2946 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2947 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2948 parts.push(format_kv("localId", local_id));
2949 }
2950 }
2951}
2952
2953fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2955 for (i, child) in children.iter().enumerate() {
2956 if i > 0 {
2957 output.push('\n');
2958 }
2959 render_block_node(child, output, opts);
2960 }
2961}
2962
2963fn fmt_f64_attr(v: f64) -> String {
2966 if v.fract() == 0.0 {
2967 format!("{}", v as i64)
2968 } else {
2969 v.to_string()
2970 }
2971}
2972
2973fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
2980 if s.contains('.') || s.contains('e') || s.contains('E') {
2981 s.parse::<f64>().ok().map(serde_json::Value::from)
2982 } else {
2983 s.parse::<i64>().ok().map(serde_json::Value::from)
2984 }
2985}
2986
2987fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
2995 if let Some(n) = v.as_i64() {
2996 return Some(n.to_string());
2997 }
2998 if let Some(n) = v.as_u64() {
2999 return Some(n.to_string());
3000 }
3001 if let Some(n) = v.as_f64() {
3002 if n.fract() == 0.0 && n.is_finite() {
3003 return Some(format!("{n:.1}"));
3004 }
3005 return Some(n.to_string());
3006 }
3007 None
3008}
3009
3010fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3012 match node.node_type.as_str() {
3013 "paragraph" => {
3014 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3015 let dir_attrs = {
3017 let mut parts = Vec::new();
3018 if let Some(ref attrs) = node.attrs {
3019 maybe_push_local_id(attrs, &mut parts, opts);
3020 }
3021 if parts.is_empty() {
3022 String::new()
3023 } else {
3024 format!("{{{}}}", parts.join(" "))
3025 }
3026 };
3027 if is_empty {
3028 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3029 } else {
3030 let mut buf = String::new();
3032 render_inline_content(node, &mut buf, opts);
3033 if buf.trim().is_empty() && !buf.is_empty() {
3034 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3037 } else {
3038 let mut is_first_line = true;
3043 for line in buf.split('\n') {
3044 if is_first_line {
3045 if is_list_start(line) {
3046 output.push_str(&escape_list_marker(line));
3047 } else {
3048 output.push_str(line);
3049 }
3050 is_first_line = false;
3051 } else {
3052 output.push('\n');
3053 if !line.is_empty() {
3054 output.push_str(" ");
3055 }
3056 output.push_str(line);
3057 }
3058 }
3059 output.push('\n');
3060 }
3061 }
3062 }
3063 "heading" => {
3064 let level = node
3065 .attrs
3066 .as_ref()
3067 .and_then(|a| a.get("level"))
3068 .and_then(serde_json::Value::as_u64)
3069 .unwrap_or(1);
3070 for _ in 0..level {
3071 output.push('#');
3072 }
3073 output.push(' ');
3074 let mut buf = String::new();
3075 render_inline_content(node, &mut buf, opts);
3076 let mut is_first_line = true;
3079 for line in buf.split('\n') {
3080 if is_first_line {
3081 output.push_str(line);
3082 is_first_line = false;
3083 } else {
3084 output.push('\n');
3085 if !line.is_empty() {
3086 output.push_str(" ");
3087 }
3088 output.push_str(line);
3089 }
3090 }
3091 output.push('\n');
3092 }
3093 "codeBlock" => {
3094 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3095 let language = language_value
3096 .and_then(serde_json::Value::as_str)
3097 .unwrap_or("");
3098 output.push_str("```");
3099 if language.is_empty() && language_value.is_some() {
3100 output.push_str("\"\"");
3103 } else {
3104 output.push_str(language);
3105 }
3106 output.push('\n');
3107 if let Some(ref content) = node.content {
3108 for child in content {
3109 if let Some(ref text) = child.text {
3110 output.push_str(text);
3111 }
3112 }
3113 }
3114 output.push_str("\n```\n");
3115 }
3116 "blockquote" => {
3117 if let Some(ref content) = node.content {
3118 for (i, child) in content.iter().enumerate() {
3119 if i > 0
3123 && child.node_type == "paragraph"
3124 && content[i - 1].node_type == "paragraph"
3125 {
3126 output.push_str(">\n");
3127 }
3128 let mut inner = String::new();
3129 render_block_node(child, &mut inner, opts);
3130 for line in inner.lines() {
3131 output.push_str("> ");
3132 output.push_str(line);
3133 output.push('\n');
3134 }
3135 }
3136 }
3137 }
3138 "bulletList" => {
3139 if let Some(ref items) = node.content {
3140 for item in items {
3141 output.push_str("- ");
3142 let content_start = output.len();
3143 render_list_item_content(item, output, opts);
3144 if starts_with_task_marker(&output[content_start..]) {
3150 output.insert(content_start, '\\');
3151 }
3152 }
3153 }
3154 }
3155 "orderedList" => {
3156 let start = node
3157 .attrs
3158 .as_ref()
3159 .and_then(|a| a.get("order"))
3160 .and_then(serde_json::Value::as_u64)
3161 .unwrap_or(1);
3162 if let Some(ref items) = node.content {
3163 for (i, item) in items.iter().enumerate() {
3164 let num = start + i as u64;
3165 output.push_str(&format!("{num}. "));
3166 render_list_item_content(item, output, opts);
3167 }
3168 }
3169 }
3170 "taskList" => {
3171 if let Some(ref items) = node.content {
3172 for item in items {
3173 if item.node_type == "taskList" {
3174 let mut nested = String::new();
3178 render_block_node(item, &mut nested, opts);
3179 for line in nested.lines() {
3180 output.push_str(" ");
3181 output.push_str(line);
3182 output.push('\n');
3183 }
3184 } else {
3185 let state = item
3186 .attrs
3187 .as_ref()
3188 .and_then(|a| a.get("state"))
3189 .and_then(serde_json::Value::as_str)
3190 .unwrap_or("TODO");
3191 if state == "DONE" {
3192 output.push_str("- [x] ");
3193 } else {
3194 output.push_str("- [ ] ");
3195 }
3196 render_list_item_content(item, output, opts);
3197 }
3198 }
3199 }
3200 }
3201 "rule" => {
3202 output.push_str("---\n");
3203 }
3204 "table" => {
3205 render_table(node, output, opts);
3206 }
3207 "mediaSingle" => {
3208 if let Some(ref content) = node.content {
3209 for child in content {
3210 if child.node_type == "media" {
3211 render_media(child, node.attrs.as_ref(), output, opts);
3212 }
3213 }
3214 for child in content {
3215 if child.node_type == "caption" {
3216 let mut cap_parts = Vec::new();
3217 if let Some(ref attrs) = child.attrs {
3218 maybe_push_local_id(attrs, &mut cap_parts, opts);
3219 }
3220 if cap_parts.is_empty() {
3221 output.push_str(":::caption\n");
3222 } else {
3223 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3224 }
3225 if let Some(ref caption_content) = child.content {
3226 for inline in caption_content {
3227 render_inline_node(inline, output, opts);
3228 }
3229 output.push('\n');
3230 }
3231 output.push_str(":::\n");
3232 }
3233 }
3234 }
3235 }
3236 "blockCard" => {
3237 if let Some(ref attrs) = node.attrs {
3238 let url = attrs
3239 .get("url")
3240 .and_then(serde_json::Value::as_str)
3241 .unwrap_or("");
3242 let mut attr_parts = Vec::new();
3243 if url_safe_in_bracket_content(url) {
3244 output.push_str(&format!("::card[{url}]"));
3245 } else {
3246 output.push_str("::card[]");
3248 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3249 attr_parts.push(format!("url=\"{escaped}\""));
3250 }
3251 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3252 attr_parts.push(format!("layout={layout}"));
3253 }
3254 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3255 attr_parts.push(format!("width={width}"));
3256 }
3257 if !attr_parts.is_empty() {
3258 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3259 }
3260 output.push('\n');
3261 }
3262 }
3263 "embedCard" => {
3264 if let Some(ref attrs) = node.attrs {
3265 let url = attrs
3266 .get("url")
3267 .and_then(serde_json::Value::as_str)
3268 .unwrap_or("");
3269 output.push_str(&format!("::embed[{url}]"));
3270 let mut attr_parts = Vec::new();
3271 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3272 attr_parts.push(format!("layout={layout}"));
3273 }
3274 if let Some(h) = attrs
3275 .get("originalHeight")
3276 .and_then(serde_json::Value::as_f64)
3277 {
3278 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3279 }
3280 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3281 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3282 }
3283 if !attr_parts.is_empty() {
3284 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3285 }
3286 output.push('\n');
3287 }
3288 }
3289 "extension" => {
3290 if let Some(ref attrs) = node.attrs {
3291 let ext_type = attrs
3292 .get("extensionType")
3293 .and_then(serde_json::Value::as_str)
3294 .unwrap_or("");
3295 let ext_key = attrs
3296 .get("extensionKey")
3297 .and_then(serde_json::Value::as_str)
3298 .unwrap_or("");
3299 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3300 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3301 attr_parts.push(format!("layout={layout}"));
3302 }
3303 if let Some(params) = attrs.get("parameters") {
3304 if let Ok(json_str) = serde_json::to_string(params) {
3305 attr_parts.push(format!("params='{json_str}'"));
3306 }
3307 }
3308 maybe_push_local_id(attrs, &mut attr_parts, opts);
3309 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3310 }
3311 }
3312 "panel" => {
3313 let panel_type = node
3314 .attrs
3315 .as_ref()
3316 .and_then(|a| a.get("panelType"))
3317 .and_then(serde_json::Value::as_str)
3318 .unwrap_or("info");
3319 let mut attr_parts = vec![format!("type={panel_type}")];
3320 if let Some(ref attrs) = node.attrs {
3321 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3322 attr_parts.push(format!("icon=\"{icon}\""));
3323 }
3324 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3325 attr_parts.push(format!("color=\"{color}\""));
3326 }
3327 }
3328 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3329 if let Some(ref content) = node.content {
3330 render_block_children(content, output, opts);
3331 }
3332 output.push_str(":::\n");
3333 }
3334 "expand" | "nestedExpand" => {
3335 let directive_name = if node.node_type == "nestedExpand" {
3336 "nested-expand"
3337 } else {
3338 "expand"
3339 };
3340 let mut attr_parts = Vec::new();
3341 if let Some(t) = node
3342 .attrs
3343 .as_ref()
3344 .and_then(|a| a.get("title"))
3345 .and_then(serde_json::Value::as_str)
3346 {
3347 attr_parts.push(format!("title=\"{t}\""));
3348 }
3349 if let Some(ref lid) = node.local_id {
3351 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3352 attr_parts.push(format!("localId={lid}"));
3353 }
3354 } else if let Some(ref attrs) = node.attrs {
3355 maybe_push_local_id(attrs, &mut attr_parts, opts);
3356 }
3357 if let Some(ref params) = node.parameters {
3359 if let Ok(json_str) = serde_json::to_string(params) {
3360 attr_parts.push(format!("params='{json_str}'"));
3361 }
3362 }
3363 if attr_parts.is_empty() {
3364 output.push_str(&format!(":::{directive_name}\n"));
3365 } else {
3366 output.push_str(&format!(
3367 ":::{directive_name}{{{}}}\n",
3368 attr_parts.join(" ")
3369 ));
3370 }
3371 if let Some(ref content) = node.content {
3372 render_block_children(content, output, opts);
3373 }
3374 output.push_str(":::\n");
3375 }
3376 "layoutSection" => {
3377 output.push_str("::::layout\n");
3378 if let Some(ref content) = node.content {
3379 for child in content {
3380 if child.node_type == "layoutColumn" {
3381 let width_str = child
3382 .attrs
3383 .as_ref()
3384 .and_then(|a| a.get("width"))
3385 .and_then(fmt_numeric_attr)
3386 .unwrap_or_else(|| "50".to_string());
3387 let mut parts = vec![format!("width={width_str}")];
3388 if let Some(ref attrs) = child.attrs {
3389 maybe_push_local_id(attrs, &mut parts, opts);
3390 }
3391 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3392 if let Some(ref col_content) = child.content {
3393 render_block_children(col_content, output, opts);
3394 }
3395 output.push_str(":::\n");
3396 }
3397 }
3398 }
3399 output.push_str("::::\n");
3400 }
3401 "decisionList" => {
3402 output.push_str(":::decisions\n");
3403 if let Some(ref content) = node.content {
3404 for item in content {
3405 output.push_str("- <> ");
3406 render_list_item_content(item, output, opts);
3407 }
3408 }
3409 output.push_str(":::\n");
3410 }
3411 "bodiedExtension" => {
3412 if let Some(ref attrs) = node.attrs {
3413 let ext_type = attrs
3414 .get("extensionType")
3415 .and_then(serde_json::Value::as_str)
3416 .unwrap_or("");
3417 let ext_key = attrs
3418 .get("extensionKey")
3419 .and_then(serde_json::Value::as_str)
3420 .unwrap_or("");
3421 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3422 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3423 attr_parts.push(format!("layout={layout}"));
3424 }
3425 if let Some(params) = attrs.get("parameters") {
3426 if let Ok(json_str) = serde_json::to_string(params) {
3427 attr_parts.push(format!("params='{json_str}'"));
3428 }
3429 }
3430 maybe_push_local_id(attrs, &mut attr_parts, opts);
3431 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3432 if let Some(ref content) = node.content {
3433 render_block_children(content, output, opts);
3434 }
3435 output.push_str(":::\n");
3436 }
3437 }
3438 _ => {
3439 if let Ok(json) = serde_json::to_string_pretty(node) {
3441 output.push_str("```adf-unsupported\n");
3442 output.push_str(&json);
3443 output.push_str("\n```\n");
3444 }
3445 }
3446 }
3447
3448 let mut parts = Vec::new();
3450 if let Some(ref marks) = node.marks {
3451 for mark in marks {
3452 match mark.mark_type.as_str() {
3453 "alignment" => {
3454 if let Some(align) = mark
3455 .attrs
3456 .as_ref()
3457 .and_then(|a| a.get("align"))
3458 .and_then(serde_json::Value::as_str)
3459 {
3460 parts.push(format!("align={align}"));
3461 }
3462 }
3463 "indentation" => {
3464 if let Some(level) = mark
3465 .attrs
3466 .as_ref()
3467 .and_then(|a| a.get("level"))
3468 .and_then(serde_json::Value::as_u64)
3469 {
3470 parts.push(format!("indent={level}"));
3471 }
3472 }
3473 "breakout" => {
3474 if let Some(mode) = mark
3475 .attrs
3476 .as_ref()
3477 .and_then(|a| a.get("mode"))
3478 .and_then(serde_json::Value::as_str)
3479 {
3480 parts.push(format!("breakout={mode}"));
3481 }
3482 if let Some(width) = mark
3483 .attrs
3484 .as_ref()
3485 .and_then(|a| a.get("width"))
3486 .and_then(serde_json::Value::as_u64)
3487 {
3488 parts.push(format!("breakoutWidth={width}"));
3489 }
3490 }
3491 _ => {}
3492 }
3493 }
3494 }
3495 let para_used_directive = node.node_type == "paragraph" && {
3499 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3500 if is_empty {
3501 true
3502 } else {
3503 let mut buf = String::new();
3504 render_inline_content(node, &mut buf, opts);
3505 buf.trim().is_empty() && !buf.is_empty()
3506 }
3507 };
3508 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3509 if let Some(ref attrs) = node.attrs {
3510 maybe_push_local_id(attrs, &mut parts, opts);
3511 }
3512 }
3513 if node.node_type == "orderedList" {
3518 if let Some(ref attrs) = node.attrs {
3519 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3520 parts.push("order=1".to_string());
3521 }
3522 }
3523 }
3524 if !parts.is_empty() {
3525 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3526 }
3527}
3528
3529fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3537 let Some(ref content) = item.content else {
3538 let bare = AdfNode::text("");
3540 emit_list_item_local_ids(item, &bare, output, opts);
3541 output.push('\n');
3542 return;
3543 };
3544 if content.is_empty() {
3545 let bare = AdfNode::text("");
3546 emit_list_item_local_ids(item, &bare, output, opts);
3547 output.push('\n');
3548 return;
3549 }
3550 let first = &content[0];
3551 let rest_start;
3552 if first.node_type == "paragraph" {
3553 let mut buf = String::new();
3554 render_inline_content(first, &mut buf, opts);
3555 let buf = buf.trim_end_matches('\n');
3561 let mut is_first_line = true;
3564 for line in buf.split('\n') {
3565 if is_first_line {
3566 output.push_str(line);
3567 is_first_line = false;
3568 } else {
3569 output.push('\n');
3570 if !line.is_empty() {
3571 output.push_str(" ");
3572 }
3573 output.push_str(line);
3574 }
3575 }
3576 emit_list_item_local_ids(item, first, output, opts);
3578 output.push('\n');
3579 rest_start = 1;
3580 } else if is_inline_node_type(&first.node_type) {
3581 rest_start = content
3583 .iter()
3584 .position(|c| !is_inline_node_type(&c.node_type))
3585 .unwrap_or(content.len());
3586 let mut buf = String::new();
3587 for child in &content[..rest_start] {
3588 render_inline_node(child, &mut buf, opts);
3589 }
3590 let buf = buf.trim_end_matches('\n');
3593 let mut is_first_line = true;
3594 for line in buf.split('\n') {
3595 if is_first_line {
3596 output.push_str(line);
3597 is_first_line = false;
3598 } else {
3599 output.push('\n');
3600 if !line.is_empty() {
3601 output.push_str(" ");
3602 }
3603 output.push_str(line);
3604 }
3605 }
3606 let bare = AdfNode::text("");
3608 emit_list_item_local_ids(item, &bare, output, opts);
3609 output.push('\n');
3610 } else if first.node_type == "taskItem" {
3613 let bare = AdfNode::text("");
3618 emit_list_item_local_ids(item, &bare, output, opts);
3619 output.push('\n');
3620 for child in content {
3621 if child.node_type == "taskItem" {
3622 let state = child
3623 .attrs
3624 .as_ref()
3625 .and_then(|a| a.get("state"))
3626 .and_then(serde_json::Value::as_str)
3627 .unwrap_or("TODO");
3628 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3629 output.push_str(" ");
3630 output.push_str(marker);
3631 render_list_item_content(child, output, opts);
3632 } else {
3633 let mut nested = String::new();
3634 render_block_node(child, &mut nested, opts);
3635 for line in nested.lines() {
3636 output.push_str(" ");
3637 output.push_str(line);
3638 output.push('\n');
3639 }
3640 }
3641 }
3642 return;
3643 } else {
3644 let mut buf = String::new();
3650 render_block_node(first, &mut buf, opts);
3651 let bare = AdfNode::text("");
3652 let mut is_first = true;
3653 for line in buf.lines() {
3654 if is_first {
3655 output.push_str(line);
3656 emit_list_item_local_ids(item, &bare, output, opts);
3657 output.push('\n');
3658 is_first = false;
3659 } else {
3660 output.push_str(" ");
3661 output.push_str(line);
3662 output.push('\n');
3663 }
3664 }
3665 rest_start = 1;
3666 }
3667 let rest = &content[rest_start..];
3668 for (i, child) in rest.iter().enumerate() {
3669 if child.node_type == "paragraph" {
3673 let prev_is_para = if i == 0 {
3674 first.node_type == "paragraph"
3677 } else {
3678 rest[i - 1].node_type == "paragraph"
3679 };
3680 if prev_is_para {
3681 output.push_str(" \n");
3682 }
3683 }
3684 let mut nested = String::new();
3685 render_block_node(child, &mut nested, opts);
3686 for line in nested.lines() {
3687 output.push_str(" ");
3688 output.push_str(line);
3689 output.push('\n');
3690 }
3691 }
3692}
3693
3694fn is_inline_node_type(node_type: &str) -> bool {
3696 matches!(
3697 node_type,
3698 "text"
3699 | "hardBreak"
3700 | "inlineCard"
3701 | "emoji"
3702 | "mention"
3703 | "status"
3704 | "date"
3705 | "placeholder"
3706 | "mediaInline"
3707 )
3708}
3709
3710fn emit_list_item_local_ids(
3713 item: &AdfNode,
3714 paragraph: &AdfNode,
3715 output: &mut String,
3716 opts: &RenderOptions,
3717) {
3718 if opts.strip_local_ids {
3719 return;
3720 }
3721 let mut parts = Vec::new();
3722 if let Some(ref attrs) = item.attrs {
3723 maybe_push_local_id(attrs, &mut parts, opts);
3724 }
3725 if paragraph.node_type == "paragraph" {
3726 let has_real_id = paragraph
3727 .attrs
3728 .as_ref()
3729 .and_then(|a| a.get("localId"))
3730 .and_then(serde_json::Value::as_str)
3731 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3732 if let Some(local_id) = has_real_id {
3733 parts.push(format!("paraLocalId={local_id}"));
3734 } else if item.node_type == "taskItem" {
3735 parts.push("paraLocalId=_".to_string());
3739 }
3740 }
3741 if !parts.is_empty() {
3742 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3743 }
3744}
3745
3746fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3748 let Some(ref rows) = node.content else {
3749 return;
3750 };
3751
3752 if table_qualifies_for_pipe_syntax(rows) {
3753 render_pipe_table(node, rows, output, opts);
3754 } else {
3755 render_directive_table(node, rows, output, opts);
3756 }
3757}
3758
3759fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3766 if rows.iter().any(|n| n.node_type == "caption") {
3768 return false;
3769 }
3770 let mut first_row_has_header = false;
3771 for (row_idx, row) in rows.iter().enumerate() {
3772 let Some(ref cells) = row.content else {
3773 continue;
3774 };
3775 for cell in cells {
3776 if row_idx > 0 && cell.node_type == "tableHeader" {
3778 return false;
3779 }
3780 if row_idx == 0 && cell.node_type == "tableHeader" {
3781 first_row_has_header = true;
3782 }
3783 let Some(ref content) = cell.content else {
3785 continue;
3786 };
3787 if content.len() != 1 || content[0].node_type != "paragraph" {
3788 return false;
3789 }
3790 if cell_contains_hard_break(&content[0]) {
3793 return false;
3794 }
3795 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3798 return false;
3799 }
3800 if content[0]
3803 .attrs
3804 .as_ref()
3805 .and_then(|a| a.get("localId"))
3806 .is_some()
3807 {
3808 return false;
3809 }
3810 }
3811 }
3812 first_row_has_header
3815}
3816
3817fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3819 paragraph
3820 .content
3821 .as_ref()
3822 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3823}
3824
3825fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3827 for (row_idx, row) in rows.iter().enumerate() {
3828 let Some(ref cells) = row.content else {
3829 continue;
3830 };
3831
3832 output.push('|');
3833 for cell in cells {
3834 output.push(' ');
3835 let mut cell_buf = String::new();
3836 render_cell_attrs_prefix(cell, &mut cell_buf);
3837 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3838 output.push_str(&escape_pipes_in_cell(&cell_buf));
3839 output.push_str(" |");
3840 }
3841 output.push('\n');
3842
3843 if row_idx == 0 {
3845 output.push('|');
3846 for cell in cells {
3847 let align = get_cell_paragraph_alignment(cell);
3848 match align {
3849 Some("center") => output.push_str(" :---: |"),
3850 Some("end") => output.push_str(" ---: |"),
3851 _ => output.push_str(" --- |"),
3852 }
3853 }
3854 output.push('\n');
3855 }
3856 }
3857
3858 render_table_level_attrs(node, output, opts);
3860}
3861
3862fn render_directive_table(
3864 node: &AdfNode,
3865 rows: &[AdfNode],
3866 output: &mut String,
3867 opts: &RenderOptions,
3868) {
3869 let mut attr_parts = Vec::new();
3871 if let Some(ref attrs) = node.attrs {
3872 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3873 attr_parts.push(format!("layout={layout}"));
3874 }
3875 if let Some(numbered) = attrs
3876 .get("isNumberColumnEnabled")
3877 .and_then(serde_json::Value::as_bool)
3878 {
3879 if numbered {
3880 attr_parts.push("numbered".to_string());
3881 } else {
3882 attr_parts.push("numbered=false".to_string());
3883 }
3884 }
3885 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3886 let tw_str = if tw.fract() == 0.0 {
3887 (tw as u64).to_string()
3888 } else {
3889 tw.to_string()
3890 };
3891 attr_parts.push(format!("width={tw_str}"));
3892 }
3893 maybe_push_local_id(attrs, &mut attr_parts, opts);
3894 }
3895 if attr_parts.is_empty() {
3896 output.push_str("::::table\n");
3897 } else {
3898 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3899 }
3900
3901 for row in rows {
3902 if row.node_type == "caption" {
3903 let mut cap_parts = Vec::new();
3904 if let Some(ref attrs) = row.attrs {
3905 maybe_push_local_id(attrs, &mut cap_parts, opts);
3906 }
3907 if cap_parts.is_empty() {
3908 output.push_str(":::caption\n");
3909 } else {
3910 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3911 }
3912 if let Some(ref content) = row.content {
3913 for child in content {
3914 render_inline_node(child, output, opts);
3915 }
3916 output.push('\n');
3917 }
3918 output.push_str(":::\n");
3919 continue;
3920 }
3921 let Some(ref cells) = row.content else {
3922 continue;
3923 };
3924 let mut tr_attrs = Vec::new();
3926 if let Some(ref attrs) = row.attrs {
3927 maybe_push_local_id(attrs, &mut tr_attrs, opts);
3928 }
3929 if tr_attrs.is_empty() {
3930 output.push_str(":::tr\n");
3931 } else {
3932 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3933 }
3934 for cell in cells {
3935 let directive_name = if cell.node_type == "tableHeader" {
3936 "th"
3937 } else {
3938 "td"
3939 };
3940 let mut cell_attr_str = build_cell_attrs_string(cell);
3941 if let Some(ref attrs) = cell.attrs {
3943 let mut lid_parts = Vec::new();
3944 maybe_push_local_id(attrs, &mut lid_parts, opts);
3945 if !lid_parts.is_empty() {
3946 if !cell_attr_str.is_empty() {
3947 cell_attr_str.push(' ');
3948 }
3949 cell_attr_str.push_str(&lid_parts.join(" "));
3950 }
3951 }
3952 if let Some(ref marks) = cell.marks {
3954 for mark in marks {
3955 if mark.mark_type == "border" {
3956 if let Some(ref attrs) = mark.attrs {
3957 if let Some(color) =
3958 attrs.get("color").and_then(serde_json::Value::as_str)
3959 {
3960 if !cell_attr_str.is_empty() {
3961 cell_attr_str.push(' ');
3962 }
3963 cell_attr_str.push_str(&format!("border-color={color}"));
3964 }
3965 if let Some(size) =
3966 attrs.get("size").and_then(serde_json::Value::as_u64)
3967 {
3968 if !cell_attr_str.is_empty() {
3969 cell_attr_str.push(' ');
3970 }
3971 cell_attr_str.push_str(&format!("border-size={size}"));
3972 }
3973 }
3974 }
3975 }
3976 }
3977 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3978 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3979 output.push_str(&format!(":::{directive_name}\n"));
3980 } else {
3981 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3982 }
3983 if let Some(ref content) = cell.content {
3984 render_block_children(content, output, opts);
3985 }
3986 output.push_str(":::\n");
3987 }
3988 output.push_str(":::\n");
3989 }
3990
3991 output.push_str("::::\n");
3992}
3993
3994fn needs_attr_quoting(value: &str) -> bool {
3998 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3999}
4000
4001fn build_cell_attrs_string(cell: &AdfNode) -> String {
4003 let Some(ref attrs) = cell.attrs else {
4004 return String::new();
4005 };
4006 let mut parts = Vec::new();
4007 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4008 parts.push(format!("colspan={colspan}"));
4009 }
4010 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4011 parts.push(format!("rowspan={rowspan}"));
4012 }
4013 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4014 if needs_attr_quoting(bg) {
4015 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4016 parts.push(format!("bg=\"{escaped}\""));
4017 } else {
4018 parts.push(format!("bg={bg}"));
4019 }
4020 }
4021 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4022 let widths: Vec<String> = colwidth
4023 .iter()
4024 .filter_map(|v| {
4025 if let Some(n) = v.as_u64() {
4028 Some(n.to_string())
4029 } else if let Some(n) = v.as_f64() {
4030 if n.fract() == 0.0 {
4031 format!("{n:.1}")
4032 } else {
4033 n.to_string()
4034 }
4035 .into()
4036 } else {
4037 None
4038 }
4039 })
4040 .collect();
4041 if !widths.is_empty() {
4042 parts.push(format!("colwidth={}", widths.join(",")));
4043 }
4044 }
4045 parts.join(" ")
4046}
4047
4048fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4050 let Some(ref _attrs) = cell.attrs else {
4051 return;
4052 };
4053 let attr_str = build_cell_attrs_string(cell);
4054 if attr_str.is_empty() {
4055 output.push_str("{} ");
4056 } else {
4057 output.push_str(&format!("{{{attr_str}}} "));
4058 }
4059}
4060
4061fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4063 let content = cell.content.as_ref()?;
4064 let para = content.first()?;
4065 let marks = para.marks.as_ref()?;
4066 marks.iter().find_map(|m| {
4067 if m.mark_type == "alignment" {
4068 m.attrs
4069 .as_ref()
4070 .and_then(|a| a.get("align"))
4071 .and_then(serde_json::Value::as_str)
4072 } else {
4073 None
4074 }
4075 })
4076}
4077
4078fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4080 if let Some(ref attrs) = node.attrs {
4081 let mut parts = Vec::new();
4082 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4083 parts.push(format!("layout={layout}"));
4084 }
4085 if let Some(numbered) = attrs
4086 .get("isNumberColumnEnabled")
4087 .and_then(serde_json::Value::as_bool)
4088 {
4089 if numbered {
4090 parts.push("numbered".to_string());
4091 } else {
4092 parts.push("numbered=false".to_string());
4093 }
4094 }
4095 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4096 parts.push(format!("width={tw_str}"));
4097 }
4098 maybe_push_local_id(attrs, &mut parts, opts);
4099 if !parts.is_empty() {
4100 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4101 }
4102 }
4103}
4104
4105fn render_inline_content_from_first_paragraph(
4107 cell: &AdfNode,
4108 output: &mut String,
4109 opts: &RenderOptions,
4110) {
4111 if let Some(ref content) = cell.content {
4112 if let Some(first) = content.first() {
4113 if first.node_type == "paragraph" {
4114 render_inline_content(first, output, opts);
4115 }
4116 }
4117 }
4118}
4119
4120fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4122 if let Some(ref marks) = marks {
4123 for mark in marks {
4124 if mark.mark_type == "border" {
4125 if let Some(ref attrs) = mark.attrs {
4126 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4127 parts.push(format!("border-color={color}"));
4128 }
4129 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4130 parts.push(format!("border-size={size}"));
4131 }
4132 }
4133 }
4134 }
4135 }
4136}
4137
4138fn render_media(
4140 node: &AdfNode,
4141 parent_attrs: Option<&serde_json::Value>,
4142 output: &mut String,
4143 opts: &RenderOptions,
4144) {
4145 if let Some(ref attrs) = node.attrs {
4146 let media_type = attrs
4147 .get("type")
4148 .and_then(serde_json::Value::as_str)
4149 .unwrap_or("external");
4150 let alt = attrs
4151 .get("alt")
4152 .and_then(serde_json::Value::as_str)
4153 .unwrap_or("");
4154
4155 if media_type == "file" {
4156 output.push_str(&format!("![{alt}]()"));
4158 let mut parts = vec!["type=file".to_string()];
4159 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4160 parts.push(format_kv("id", id));
4161 }
4162 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4163 parts.push(format_kv("collection", collection));
4164 }
4165 if let Some(occurrence_key) = attrs
4166 .get("occurrenceKey")
4167 .and_then(serde_json::Value::as_str)
4168 {
4169 parts.push(format_kv("occurrenceKey", occurrence_key));
4170 }
4171 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4172 parts.push(format!("height={height_str}"));
4173 }
4174 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4175 parts.push(format!("width={width_str}"));
4176 }
4177 maybe_push_local_id(attrs, &mut parts, opts);
4178 if let Some(p_attrs) = parent_attrs {
4180 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4181 if layout != "center" {
4182 parts.push(format!("layout={layout}"));
4183 }
4184 }
4185 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4186 parts.push(format!("mediaWidth={ms_width_str}"));
4187 }
4188 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4189 parts.push(format!("widthType={wt}"));
4190 }
4191 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4192 parts.push(format!("mode={mode}"));
4193 }
4194 }
4195 push_border_mark_attrs(&node.marks, &mut parts);
4196 output.push_str(&format!("{{{}}}", parts.join(" ")));
4197 } else {
4198 let url = attrs
4200 .get("url")
4201 .and_then(serde_json::Value::as_str)
4202 .unwrap_or("");
4203 output.push_str(&format!(""));
4204
4205 {
4207 let mut parts = Vec::new();
4208 if let Some(p_attrs) = parent_attrs {
4209 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4210 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4211 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4212 if let Some(l) = layout {
4213 if l != "center" {
4214 parts.push(format!("layout={l}"));
4215 }
4216 }
4217 if let Some(w) = width_str {
4218 parts.push(format!("width={w}"));
4219 }
4220 if let Some(wt) = width_type {
4221 parts.push(format!("widthType={wt}"));
4222 }
4223 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4224 parts.push(format!("mode={mode}"));
4225 }
4226 }
4227 maybe_push_local_id(attrs, &mut parts, opts);
4228 push_border_mark_attrs(&node.marks, &mut parts);
4229 if !parts.is_empty() {
4230 output.push_str(&format!("{{{}}}", parts.join(" ")));
4231 }
4232 }
4233 }
4234
4235 output.push('\n');
4236 }
4237}
4238
4239fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4241 if let Some(ref content) = node.content {
4242 for child in content {
4243 render_inline_node(child, output, opts);
4244 }
4245 }
4246}
4247
4248fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4250 match node.node_type.as_str() {
4251 "text" => {
4252 let text = node.text.as_deref().unwrap_or("");
4253 let marks = node.marks.as_deref().unwrap_or(&[]);
4254 let has_code = marks.iter().any(|m| m.mark_type == "code");
4255 let owned;
4260 let text = if !has_code {
4261 owned = text.replace('\\', "\\\\");
4262 owned.as_str()
4263 } else {
4264 text
4265 };
4266 let owned_nl;
4271 let text = if text.contains('\n') {
4272 owned_nl = text.replace('\n', "\\n");
4273 owned_nl.as_str()
4274 } else {
4275 text
4276 };
4277 let owned_ts;
4283 let text = if !has_code && text.ends_with(" ") {
4284 let mut s = text.to_string();
4285 s.insert(s.len() - 1, '\\');
4287 owned_ts = s;
4288 owned_ts.as_str()
4289 } else {
4290 text
4291 };
4292 render_marked_text(text, marks, output);
4293 }
4294 "hardBreak" => {
4295 output.push_str("\\\n");
4296 }
4297 other => {
4298 let mut body = String::new();
4302 render_non_text_inline_body(other, node, &mut body, opts);
4303
4304 let annotations: Vec<&AdfMark> = node
4305 .marks
4306 .as_deref()
4307 .unwrap_or(&[])
4308 .iter()
4309 .filter(|m| m.mark_type == "annotation")
4310 .collect();
4311
4312 if annotations.is_empty() {
4313 output.push_str(&body);
4314 } else {
4315 let mut attr_parts = Vec::new();
4316 for ann in &annotations {
4317 if let Some(ref attrs) = ann.attrs {
4318 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4319 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4320 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4321 }
4322 if let Some(at) = attrs
4323 .get("annotationType")
4324 .and_then(serde_json::Value::as_str)
4325 {
4326 attr_parts.push(format!("annotation-type={at}"));
4327 }
4328 }
4329 }
4330 output.push('[');
4331 output.push_str(&body);
4332 output.push_str("]{");
4333 output.push_str(&attr_parts.join(" "));
4334 output.push('}');
4335 }
4336 }
4337 }
4338}
4339
4340fn render_non_text_inline_body(
4342 node_type: &str,
4343 node: &AdfNode,
4344 output: &mut String,
4345 opts: &RenderOptions,
4346) {
4347 match node_type {
4348 "inlineCard" => {
4349 if let Some(ref attrs) = node.attrs {
4350 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4351 let mut attr_parts = Vec::new();
4352 if url_safe_in_bracket_content(url) {
4353 output.push_str(":card[");
4354 output.push_str(url);
4355 output.push(']');
4356 } else {
4357 output.push_str(":card[]");
4361 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4362 attr_parts.push(format!("url=\"{escaped}\""));
4363 }
4364 maybe_push_local_id(attrs, &mut attr_parts, opts);
4365 if !attr_parts.is_empty() {
4366 output.push('{');
4367 output.push_str(&attr_parts.join(" "));
4368 output.push('}');
4369 }
4370 }
4371 }
4372 }
4373 "emoji" => {
4374 if let Some(ref attrs) = node.attrs {
4375 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4376 {
4377 output.push(':');
4378 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4379 let name = name.strip_suffix(':').unwrap_or(name);
4380 output.push_str(name);
4381 output.push(':');
4382
4383 let mut parts = Vec::new();
4384 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4385 parts.push(format!("shortName=\"{escaped_sn}\""));
4386 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4387 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4388 parts.push(format!("id=\"{escaped}\""));
4389 }
4390 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4391 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4392 parts.push(format!("text=\"{escaped}\""));
4393 }
4394 maybe_push_local_id(attrs, &mut parts, opts);
4395 output.push('{');
4396 output.push_str(&parts.join(" "));
4397 output.push('}');
4398 }
4399 }
4400 }
4401 "status" => {
4402 if let Some(ref attrs) = node.attrs {
4403 let text = attrs
4404 .get("text")
4405 .and_then(serde_json::Value::as_str)
4406 .unwrap_or("");
4407 let color = attrs
4408 .get("color")
4409 .and_then(serde_json::Value::as_str)
4410 .unwrap_or("neutral");
4411 let mut attr_parts = vec![format!("color={color}")];
4412 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4413 attr_parts.push(format!("style={style}"));
4414 }
4415 maybe_push_local_id(attrs, &mut attr_parts, opts);
4416 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4417 }
4418 }
4419 "date" => {
4420 if let Some(ref attrs) = node.attrs {
4421 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4422 {
4423 let display = epoch_ms_to_iso_date(timestamp);
4424 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4425 maybe_push_local_id(attrs, &mut attr_parts, opts);
4426 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4427 }
4428 }
4429 }
4430 "mention" => {
4431 if let Some(ref attrs) = node.attrs {
4432 let id = attrs
4433 .get("id")
4434 .and_then(serde_json::Value::as_str)
4435 .unwrap_or("");
4436 let text = attrs
4437 .get("text")
4438 .and_then(serde_json::Value::as_str)
4439 .unwrap_or("");
4440 let mut attr_parts = vec![format!("id={id}")];
4441 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4442 attr_parts.push(format!("userType={ut}"));
4443 }
4444 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4445 attr_parts.push(format!("accessLevel={al}"));
4446 }
4447 maybe_push_local_id(attrs, &mut attr_parts, opts);
4448 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4449 }
4450 }
4451 "placeholder" => {
4452 if let Some(ref attrs) = node.attrs {
4453 let text = attrs
4454 .get("text")
4455 .and_then(serde_json::Value::as_str)
4456 .unwrap_or("");
4457 output.push_str(&format!(":placeholder[{text}]"));
4458 }
4459 }
4460 "inlineExtension" => {
4461 if let Some(ref attrs) = node.attrs {
4462 let ext_type = attrs
4463 .get("extensionType")
4464 .and_then(serde_json::Value::as_str)
4465 .unwrap_or("");
4466 let ext_key = attrs
4467 .get("extensionKey")
4468 .and_then(serde_json::Value::as_str)
4469 .unwrap_or("");
4470 let fallback = node.text.as_deref().unwrap_or("");
4471 output.push_str(&format!(
4472 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4473 ));
4474 }
4475 }
4476 "mediaInline" => {
4477 if let Some(ref attrs) = node.attrs {
4478 let mut attr_parts = Vec::new();
4479 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4480 attr_parts.push(format_kv("type", media_type));
4481 }
4482 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4483 attr_parts.push(format_kv("id", id));
4484 }
4485 if let Some(collection) =
4486 attrs.get("collection").and_then(serde_json::Value::as_str)
4487 {
4488 attr_parts.push(format_kv("collection", collection));
4489 }
4490 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4491 attr_parts.push(format_kv("url", url));
4492 }
4493 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4494 attr_parts.push(format_kv("alt", alt));
4495 }
4496 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4497 attr_parts.push(format!("width={width}"));
4498 }
4499 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4500 attr_parts.push(format!("height={height}"));
4501 }
4502 maybe_push_local_id(attrs, &mut attr_parts, opts);
4503 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4504 }
4505 }
4506 _ => {
4507 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4508 }
4509 }
4510}
4511
4512fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4525 if marks.iter().any(|m| m.mark_type == "code") {
4526 render_code_marked_text(text, marks, output);
4527 return;
4528 }
4529
4530 let has_link = marks.iter().any(|m| m.mark_type == "link");
4531 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4532 let has_em = marks.iter().any(|m| m.mark_type == "em");
4533
4534 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4538 let escaped = escape_emphasis_markers(text);
4539 let escaped = escape_emoji_shortcodes(&escaped);
4540 let escaped = escape_backticks(&escaped);
4541 let escaped = escape_bare_urls(&escaped);
4542 output.push_str("***");
4543 output.push_str(&escaped);
4544 output.push_str("***");
4545 return;
4546 }
4547
4548 let em_delim = if has_strong && has_em { "_" } else { "*" };
4552
4553 let escaped = if em_delim == "_" {
4556 escape_emphasis_markers_with_underscore(text)
4557 } else {
4558 escape_emphasis_markers(text)
4559 };
4560 let escaped = escape_emoji_shortcodes(&escaped);
4561 let escaped = escape_backticks(&escaped);
4562 let escaped = escape_bare_urls(&escaped);
4570 let escaped = if has_link {
4571 escape_link_brackets(&escaped)
4572 } else {
4573 escaped
4574 };
4575
4576 let mut wrappers: Vec<(String, String)> = Vec::new();
4582 let mut i = 0;
4583 while i < marks.len() {
4584 match marks[i].mark_type.as_str() {
4585 "em" => {
4586 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4587 i += 1;
4588 }
4589 "strong" => {
4590 wrappers.push(("**".to_string(), "**".to_string()));
4591 i += 1;
4592 }
4593 "strike" => {
4594 wrappers.push(("~~".to_string(), "~~".to_string()));
4595 i += 1;
4596 }
4597 "link" => {
4598 let href = link_href(&marks[i]);
4599 wrappers.push(("[".to_string(), format!("]({href})")));
4600 i += 1;
4601 }
4602 "textColor" | "backgroundColor" | "subsup" => {
4603 let start = i;
4604 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4605 i += 1;
4606 }
4607 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4608 }
4609 "underline" | "annotation" => {
4610 let start = i;
4611 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4612 i += 1;
4613 }
4614 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4615 }
4616 _ => {
4617 i += 1;
4618 }
4619 }
4620 }
4621
4622 let mut result = escaped;
4624 for (open, close) in wrappers.iter().rev() {
4625 result.insert_str(0, open);
4626 result.push_str(close);
4627 }
4628 output.push_str(&result);
4629}
4630
4631fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4639 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4640
4641 let mut code_str = String::new();
4642 if let Some(link_mark) = link_mark {
4643 let href = link_href(link_mark);
4644 code_str.push('[');
4645 render_inline_code(text, &mut code_str);
4646 code_str.push_str("](");
4647 code_str.push_str(href);
4648 code_str.push(')');
4649 } else {
4650 render_inline_code(text, &mut code_str);
4651 }
4652
4653 let mut wrappers: Vec<(String, String)> = Vec::new();
4656 let mut i = 0;
4657 while i < marks.len() {
4658 match marks[i].mark_type.as_str() {
4659 "textColor" | "backgroundColor" | "subsup" => {
4660 let start = i;
4661 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4662 i += 1;
4663 }
4664 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4665 }
4666 "underline" | "annotation" => {
4667 let start = i;
4668 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4669 i += 1;
4670 }
4671 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4672 }
4673 _ => {
4674 i += 1;
4675 }
4676 }
4677 }
4678
4679 let mut result = code_str;
4681 for (open, close) in wrappers.iter().rev() {
4682 result.insert_str(0, open);
4683 result.push_str(close);
4684 }
4685 output.push_str(&result);
4686}
4687
4688fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4690 match mark.mark_type.as_str() {
4691 "textColor" => {
4692 if let Some(c) = mark
4693 .attrs
4694 .as_ref()
4695 .and_then(|a| a.get("color"))
4696 .and_then(serde_json::Value::as_str)
4697 {
4698 attrs.push(format!("color={c}"));
4699 }
4700 }
4701 "backgroundColor" => {
4702 if let Some(c) = mark
4703 .attrs
4704 .as_ref()
4705 .and_then(|a| a.get("color"))
4706 .and_then(serde_json::Value::as_str)
4707 {
4708 attrs.push(format!("bg={c}"));
4709 }
4710 }
4711 "subsup" => {
4712 if let Some(kind) = mark
4713 .attrs
4714 .as_ref()
4715 .and_then(|a| a.get("type"))
4716 .and_then(serde_json::Value::as_str)
4717 {
4718 attrs.push(kind.to_string());
4719 }
4720 }
4721 _ => {}
4722 }
4723}
4724
4725fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4727 match mark.mark_type.as_str() {
4728 "underline" => attrs.push("underline".to_string()),
4729 "annotation" => {
4730 if let Some(ref a) = mark.attrs {
4731 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4732 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4733 attrs.push(format!("annotation-id=\"{escaped}\""));
4734 }
4735 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4736 attrs.push(format!("annotation-type={at}"));
4737 }
4738 }
4739 }
4740 _ => {}
4741 }
4742}
4743
4744fn is_span_attr_mark(mark_type: &str) -> bool {
4745 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4746}
4747
4748fn is_bracketed_span_mark(mark_type: &str) -> bool {
4749 matches!(mark_type, "underline" | "annotation")
4750}
4751
4752fn span_attr_order(mark_type: &str) -> u8 {
4756 match mark_type {
4757 "textColor" => 0,
4758 "backgroundColor" => 1,
4759 "subsup" => 2,
4760 _ => u8::MAX,
4761 }
4762}
4763
4764fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4769 let mut prev = 0;
4770 for m in run {
4771 let order = span_attr_order(&m.mark_type);
4772 if order == u8::MAX || order < prev {
4773 return false;
4774 }
4775 prev = order;
4776 }
4777 true
4778}
4779
4780fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4785 let mut seen_annotation = false;
4786 for m in run {
4787 match m.mark_type.as_str() {
4788 "underline" => {
4789 if seen_annotation {
4790 return false;
4791 }
4792 }
4793 "annotation" => seen_annotation = true,
4794 _ => return false,
4795 }
4796 }
4797 true
4798}
4799
4800fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4804 if span_run_is_canonical(run) {
4805 let mut attrs = Vec::new();
4806 for m in run {
4807 collect_span_attr(m, &mut attrs);
4808 }
4809 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4810 return;
4811 }
4812 for m in run {
4813 let mut attrs = Vec::new();
4814 collect_span_attr(m, &mut attrs);
4815 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4816 }
4817}
4818
4819fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4823 if bracketed_run_is_canonical(run) {
4824 let mut attrs = Vec::new();
4825 for m in run {
4826 collect_bracketed_attr(m, &mut attrs);
4827 }
4828 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4829 return;
4830 }
4831 for m in run {
4832 let mut attrs = Vec::new();
4833 collect_bracketed_attr(m, &mut attrs);
4834 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4835 }
4836}
4837
4838fn link_href(mark: &AdfMark) -> &str {
4840 mark.attrs
4841 .as_ref()
4842 .and_then(|a| a.get("href"))
4843 .and_then(serde_json::Value::as_str)
4844 .unwrap_or("")
4845}
4846
4847#[cfg(test)]
4848#[allow(clippy::unwrap_used, clippy::expect_used)]
4849mod tests {
4850 use super::*;
4851
4852 #[test]
4855 fn paragraph() {
4856 let doc = markdown_to_adf("Hello world").unwrap();
4857 assert_eq!(doc.content.len(), 1);
4858 assert_eq!(doc.content[0].node_type, "paragraph");
4859 }
4860
4861 #[test]
4862 fn heading_levels() {
4863 for level in 1..=6 {
4864 let hashes = "#".repeat(level);
4865 let md = format!("{hashes} Title");
4866 let doc = markdown_to_adf(&md).unwrap();
4867 assert_eq!(doc.content[0].node_type, "heading");
4868 let attrs = doc.content[0].attrs.as_ref().unwrap();
4869 assert_eq!(attrs["level"], level as u64);
4870 }
4871 }
4872
4873 #[test]
4874 fn code_block() {
4875 let md = "```rust\nfn main() {}\n```";
4876 let doc = markdown_to_adf(md).unwrap();
4877 assert_eq!(doc.content[0].node_type, "codeBlock");
4878 let attrs = doc.content[0].attrs.as_ref().unwrap();
4879 assert_eq!(attrs["language"], "rust");
4880 }
4881
4882 #[test]
4883 fn code_block_no_language() {
4884 let md = "```\nsome code\n```";
4885 let doc = markdown_to_adf(md).unwrap();
4886 assert_eq!(doc.content[0].node_type, "codeBlock");
4887 assert!(doc.content[0].attrs.is_none());
4888 }
4889
4890 #[test]
4891 fn code_block_empty_language() {
4892 let md = "```\"\"\nsome code\n```";
4893 let doc = markdown_to_adf(md).unwrap();
4894 assert_eq!(doc.content[0].node_type, "codeBlock");
4895 let attrs = doc.content[0].attrs.as_ref().unwrap();
4896 assert_eq!(attrs["language"], "");
4897 }
4898
4899 #[test]
4900 fn horizontal_rule() {
4901 let doc = markdown_to_adf("---").unwrap();
4902 assert_eq!(doc.content[0].node_type, "rule");
4903 }
4904
4905 #[test]
4906 fn horizontal_rule_stars() {
4907 let doc = markdown_to_adf("***").unwrap();
4908 assert_eq!(doc.content[0].node_type, "rule");
4909 }
4910
4911 #[test]
4912 fn blockquote() {
4913 let md = "> This is a quote\n> Second line";
4914 let doc = markdown_to_adf(md).unwrap();
4915 assert_eq!(doc.content[0].node_type, "blockquote");
4916 }
4917
4918 #[test]
4919 fn bullet_list() {
4920 let md = "- Item 1\n- Item 2\n- Item 3";
4921 let doc = markdown_to_adf(md).unwrap();
4922 assert_eq!(doc.content[0].node_type, "bulletList");
4923 let items = doc.content[0].content.as_ref().unwrap();
4924 assert_eq!(items.len(), 3);
4925 }
4926
4927 #[test]
4928 fn ordered_list() {
4929 let md = "1. First\n2. Second\n3. Third";
4930 let doc = markdown_to_adf(md).unwrap();
4931 assert_eq!(doc.content[0].node_type, "orderedList");
4932 let items = doc.content[0].content.as_ref().unwrap();
4933 assert_eq!(items.len(), 3);
4934 }
4935
4936 #[test]
4937 fn task_list() {
4938 let md = "- [ ] Todo item\n- [x] Done item";
4939 let doc = markdown_to_adf(md).unwrap();
4940 assert_eq!(doc.content[0].node_type, "taskList");
4941 let items = doc.content[0].content.as_ref().unwrap();
4942 assert_eq!(items.len(), 2);
4943 assert_eq!(items[0].node_type, "taskItem");
4944 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4945 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4946 }
4947
4948 #[test]
4949 fn task_list_uppercase_x() {
4950 let md = "- [X] Done item";
4951 let doc = markdown_to_adf(md).unwrap();
4952 assert_eq!(doc.content[0].node_type, "taskList");
4953 let item = &doc.content[0].content.as_ref().unwrap()[0];
4954 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4955 }
4956
4957 #[test]
4960 fn task_list_empty_todo_no_trailing_space() {
4961 let md = "- [ ]";
4962 let doc = markdown_to_adf(md).unwrap();
4963 assert_eq!(doc.content[0].node_type, "taskList");
4964 let items = doc.content[0].content.as_ref().unwrap();
4965 assert_eq!(items.len(), 1);
4966 assert_eq!(items[0].node_type, "taskItem");
4967 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4968 assert!(items[0].content.is_none());
4969 }
4970
4971 #[test]
4973 fn task_list_empty_done_no_trailing_space() {
4974 let md = "- [x]\n- [X]";
4975 let doc = markdown_to_adf(md).unwrap();
4976 assert_eq!(doc.content[0].node_type, "taskList");
4977 let items = doc.content[0].content.as_ref().unwrap();
4978 assert_eq!(items.len(), 2);
4979 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
4980 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4981 }
4982
4983 #[test]
4986 fn task_list_body_has_no_leading_space() {
4987 let md = "- [ ] Buy groceries";
4988 let doc = markdown_to_adf(md).unwrap();
4989 let item = &doc.content[0].content.as_ref().unwrap()[0];
4990 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
4991 assert_eq!(text, "Buy groceries");
4992 }
4993
4994 #[test]
4998 fn round_trip_empty_task_items_stripped_trailing_spaces() {
4999 let json = r#"{
5000 "version": 1,
5001 "type": "doc",
5002 "content": [{
5003 "type": "taskList",
5004 "attrs": {"localId": "abc"},
5005 "content": [
5006 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5007 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5008 ]
5009 }]
5010 }"#;
5011 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5012 let md = adf_to_markdown(&doc).unwrap();
5013 let stripped: String = md
5014 .lines()
5015 .map(|l| l.trim_end())
5016 .collect::<Vec<_>>()
5017 .join("\n");
5018 let parsed = markdown_to_adf(&stripped).unwrap();
5019 assert_eq!(parsed.content[0].node_type, "taskList");
5020 let items = parsed.content[0].content.as_ref().unwrap();
5021 assert_eq!(items.len(), 2);
5022 assert_eq!(items[0].node_type, "taskItem");
5023 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5024 assert_eq!(items[1].node_type, "taskItem");
5025 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5026 }
5027
5028 #[test]
5029 fn try_parse_task_marker_accepts_bare_checkbox() {
5030 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5031 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5032 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5033 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5034 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5035 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5036 assert_eq!(try_parse_task_marker("[x]foo"), None);
5037 assert_eq!(try_parse_task_marker("[y] foo"), None);
5038 }
5039
5040 #[test]
5041 fn starts_with_task_marker_matches_parser() {
5042 assert!(starts_with_task_marker("[ ]"));
5045 assert!(starts_with_task_marker("[x]"));
5046 assert!(starts_with_task_marker("[X]"));
5047 assert!(starts_with_task_marker("[ ] foo"));
5048 assert!(starts_with_task_marker("[x] foo\n"));
5049 assert!(starts_with_task_marker("[ ]\n"));
5050 assert!(!starts_with_task_marker("[ ]foo"));
5052 assert!(!starts_with_task_marker("[y] foo"));
5053 assert!(!starts_with_task_marker("foo [ ] bar"));
5054 assert!(!starts_with_task_marker(""));
5055 }
5056
5057 #[test]
5061 fn round_trip_bullet_list_with_literal_checkbox_text() {
5062 let json = r#"{
5063 "version": 1,
5064 "type": "doc",
5065 "content": [{
5066 "type": "bulletList",
5067 "content": [{
5068 "type": "listItem",
5069 "content": [{
5070 "type": "paragraph",
5071 "content": [
5072 {"type": "text", "text": "[ ] Review the "},
5073 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5074 {"type": "text", "text": " file"}
5075 ]
5076 }]
5077 }]
5078 }]
5079 }"#;
5080 let original: AdfDocument = serde_json::from_str(json).unwrap();
5081 let md = adf_to_markdown(&original).unwrap();
5082 assert!(
5084 md.contains(r"- \[ ] Review the "),
5085 "rendered markdown: {md:?}"
5086 );
5087 let parsed = markdown_to_adf(&md).unwrap();
5088 assert_eq!(parsed.content[0].node_type, "bulletList");
5089 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5090 assert_eq!(item.node_type, "listItem");
5091 let para = &item.content.as_ref().unwrap()[0];
5092 assert_eq!(para.node_type, "paragraph");
5093 let text_nodes = para.content.as_ref().unwrap();
5094 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5095 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5096 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5097 }
5098
5099 #[test]
5101 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5102 let json = r#"{
5103 "version": 1,
5104 "type": "doc",
5105 "content": [{
5106 "type": "bulletList",
5107 "content": [{
5108 "type": "listItem",
5109 "content": [{
5110 "type": "paragraph",
5111 "content": [{"type": "text", "text": "[x] not actually done"}]
5112 }]
5113 }]
5114 }]
5115 }"#;
5116 let original: AdfDocument = serde_json::from_str(json).unwrap();
5117 let md = adf_to_markdown(&original).unwrap();
5118 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5119 let parsed = markdown_to_adf(&md).unwrap();
5120 assert_eq!(parsed.content[0].node_type, "bulletList");
5121 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5122 let para = &item.content.as_ref().unwrap()[0];
5123 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5124 assert_eq!(text, "[x] not actually done");
5125 }
5126
5127 #[test]
5129 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5130 let json = r#"{
5131 "version": 1,
5132 "type": "doc",
5133 "content": [{
5134 "type": "bulletList",
5135 "content": [{
5136 "type": "listItem",
5137 "content": [{
5138 "type": "paragraph",
5139 "content": [{"type": "text", "text": "[ ]"}]
5140 }]
5141 }]
5142 }]
5143 }"#;
5144 let original: AdfDocument = serde_json::from_str(json).unwrap();
5145 let md = adf_to_markdown(&original).unwrap();
5146 let parsed = markdown_to_adf(&md).unwrap();
5147 assert_eq!(parsed.content[0].node_type, "bulletList");
5148 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5149 let para = &item.content.as_ref().unwrap()[0];
5150 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5151 assert_eq!(text, "[ ]");
5152 }
5153
5154 #[test]
5157 fn bullet_list_non_task_bracket_text_not_escaped() {
5158 let json = r#"{
5159 "version": 1,
5160 "type": "doc",
5161 "content": [{
5162 "type": "bulletList",
5163 "content": [{
5164 "type": "listItem",
5165 "content": [{
5166 "type": "paragraph",
5167 "content": [{"type": "text", "text": "[?] unsure"}]
5168 }]
5169 }]
5170 }]
5171 }"#;
5172 let original: AdfDocument = serde_json::from_str(json).unwrap();
5173 let md = adf_to_markdown(&original).unwrap();
5174 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5175 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5176 }
5177
5178 #[test]
5181 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5182 let json = r#"{
5183 "version": 1,
5184 "type": "doc",
5185 "content": [{
5186 "type": "bulletList",
5187 "content": [{
5188 "type": "listItem",
5189 "content": [
5190 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5191 {"type": "bulletList", "content": [{
5192 "type": "listItem",
5193 "content": [{
5194 "type": "paragraph",
5195 "content": [{"type": "text", "text": "[ ] inner literal"}]
5196 }]
5197 }]}
5198 ]
5199 }]
5200 }]
5201 }"#;
5202 let original: AdfDocument = serde_json::from_str(json).unwrap();
5203 let md = adf_to_markdown(&original).unwrap();
5204 let parsed = markdown_to_adf(&md).unwrap();
5205 let outer = &parsed.content[0];
5206 assert_eq!(outer.node_type, "bulletList");
5207 let outer_item = &outer.content.as_ref().unwrap()[0];
5208 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5209 assert_eq!(inner_list.node_type, "bulletList");
5210 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5211 assert_eq!(inner_item.node_type, "listItem");
5212 let para = &inner_item.content.as_ref().unwrap()[0];
5213 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5214 assert_eq!(text, "[ ] inner literal");
5215 }
5216
5217 #[test]
5218 fn adf_task_list_to_markdown() {
5219 let doc = AdfDocument {
5220 version: 1,
5221 doc_type: "doc".to_string(),
5222 content: vec![AdfNode::task_list(vec![
5223 AdfNode::task_item(
5224 "TODO",
5225 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5226 ),
5227 AdfNode::task_item(
5228 "DONE",
5229 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5230 ),
5231 ])],
5232 };
5233 let md = adf_to_markdown(&doc).unwrap();
5234 assert!(md.contains("- [ ] Todo"));
5235 assert!(md.contains("- [x] Done"));
5236 }
5237
5238 #[test]
5239 fn round_trip_task_list() {
5240 let md = "- [ ] Todo item\n- [x] Done item\n";
5241 let doc = markdown_to_adf(md).unwrap();
5242 let result = adf_to_markdown(&doc).unwrap();
5243 assert!(result.contains("- [ ] Todo item"));
5244 assert!(result.contains("- [x] Done item"));
5245 }
5246
5247 #[test]
5249 fn adf_task_item_unwrapped_inline_content() {
5250 let json = r#"{
5252 "version": 1,
5253 "type": "doc",
5254 "content": [{
5255 "type": "taskList",
5256 "attrs": {"localId": "list-001"},
5257 "content": [{
5258 "type": "taskItem",
5259 "attrs": {"localId": "task-001", "state": "TODO"},
5260 "content": [{"type": "text", "text": "Do something"}]
5261 }]
5262 }]
5263 }"#;
5264 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5265 let md = adf_to_markdown(&doc).unwrap();
5266 assert!(md.contains("- [ ] Do something"), "got: {md}");
5267 assert!(!md.contains("adf-unsupported"), "got: {md}");
5268 }
5269
5270 #[test]
5272 fn adf_task_list_multiple_unwrapped_items() {
5273 let json = r#"{
5274 "version": 1,
5275 "type": "doc",
5276 "content": [{
5277 "type": "taskList",
5278 "attrs": {"localId": "list-001"},
5279 "content": [
5280 {
5281 "type": "taskItem",
5282 "attrs": {"localId": "task-001", "state": "TODO"},
5283 "content": [{"type": "text", "text": "First task"}]
5284 },
5285 {
5286 "type": "taskItem",
5287 "attrs": {"localId": "task-002", "state": "DONE"},
5288 "content": [{"type": "text", "text": "Second task"}]
5289 }
5290 ]
5291 }]
5292 }"#;
5293 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5294 let md = adf_to_markdown(&doc).unwrap();
5295 assert!(md.contains("- [ ] First task"), "got: {md}");
5296 assert!(md.contains("- [x] Second task"), "got: {md}");
5297 assert!(!md.contains("adf-unsupported"), "got: {md}");
5298 }
5299
5300 #[test]
5302 fn adf_task_item_unwrapped_inline_with_marks() {
5303 let json = r#"{
5304 "version": 1,
5305 "type": "doc",
5306 "content": [{
5307 "type": "taskList",
5308 "attrs": {"localId": "list-001"},
5309 "content": [{
5310 "type": "taskItem",
5311 "attrs": {"localId": "task-001", "state": "TODO"},
5312 "content": [
5313 {"type": "text", "text": "Buy "},
5314 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5315 {"type": "text", "text": " today"}
5316 ]
5317 }]
5318 }]
5319 }"#;
5320 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5321 let md = adf_to_markdown(&doc).unwrap();
5322 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5323 }
5324
5325 #[test]
5327 fn adf_task_item_unwrapped_preserves_local_id() {
5328 let json = r#"{
5329 "version": 1,
5330 "type": "doc",
5331 "content": [{
5332 "type": "taskList",
5333 "attrs": {"localId": "list-001"},
5334 "content": [{
5335 "type": "taskItem",
5336 "attrs": {"localId": "task-001", "state": "TODO"},
5337 "content": [{"type": "text", "text": "Do something"}]
5338 }]
5339 }]
5340 }"#;
5341 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5342 let md = adf_to_markdown(&doc).unwrap();
5343 assert!(md.contains("{localId=task-001}"), "got: {md}");
5344 assert!(md.contains("{localId=list-001}"), "got: {md}");
5345 }
5346
5347 #[test]
5349 fn round_trip_task_list_unwrapped_inline() {
5350 let json = r#"{
5351 "version": 1,
5352 "type": "doc",
5353 "content": [{
5354 "type": "taskList",
5355 "attrs": {"localId": "list-001"},
5356 "content": [
5357 {
5358 "type": "taskItem",
5359 "attrs": {"localId": "task-001", "state": "TODO"},
5360 "content": [{"type": "text", "text": "Do something"}]
5361 },
5362 {
5363 "type": "taskItem",
5364 "attrs": {"localId": "task-002", "state": "DONE"},
5365 "content": [{"type": "text", "text": "Already done"}]
5366 }
5367 ]
5368 }]
5369 }"#;
5370 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5371 let md = adf_to_markdown(&doc).unwrap();
5372
5373 let doc2 = markdown_to_adf(&md).unwrap();
5375 assert_eq!(doc2.content[0].node_type, "taskList");
5376
5377 let items = doc2.content[0].content.as_ref().unwrap();
5378 assert_eq!(items.len(), 2);
5379 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5380 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5381
5382 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5384 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5385 assert_eq!(
5386 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5387 "list-001"
5388 );
5389 }
5390
5391 #[test]
5393 fn adf_task_item_unwrapped_inline_then_block() {
5394 let json = r#"{
5395 "version": 1,
5396 "type": "doc",
5397 "content": [{
5398 "type": "taskList",
5399 "attrs": {"localId": "list-001"},
5400 "content": [{
5401 "type": "taskItem",
5402 "attrs": {"localId": "task-001", "state": "TODO"},
5403 "content": [
5404 {"type": "text", "text": "Parent task"},
5405 {
5406 "type": "bulletList",
5407 "content": [{
5408 "type": "listItem",
5409 "content": [{
5410 "type": "paragraph",
5411 "content": [{"type": "text", "text": "sub-item"}]
5412 }]
5413 }]
5414 }
5415 ]
5416 }]
5417 }]
5418 }"#;
5419 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5420 let md = adf_to_markdown(&doc).unwrap();
5421 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5422 assert!(md.contains(" - sub-item"), "got: {md}");
5423 assert!(!md.contains("adf-unsupported"), "got: {md}");
5424 }
5425
5426 #[test]
5428 fn adf_task_item_empty_content() {
5429 let json = r#"{
5430 "version": 1,
5431 "type": "doc",
5432 "content": [{
5433 "type": "taskList",
5434 "attrs": {"localId": "list-001"},
5435 "content": [{
5436 "type": "taskItem",
5437 "attrs": {"localId": "task-001", "state": "TODO"},
5438 "content": []
5439 }]
5440 }]
5441 }"#;
5442 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5443 let md = adf_to_markdown(&doc).unwrap();
5444 assert!(md.contains("- [ ] "), "got: {md}");
5445 assert!(!md.contains("adf-unsupported"), "got: {md}");
5446 }
5447
5448 #[test]
5451 fn adf_nested_task_item_renders_without_corruption() {
5452 let json = r#"{
5453 "type": "doc",
5454 "version": 1,
5455 "content": [{
5456 "type": "taskList",
5457 "attrs": {"localId": ""},
5458 "content": [
5459 {
5460 "type": "taskItem",
5461 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5462 "content": [{"type": "text", "text": "Normal task"}]
5463 },
5464 {
5465 "type": "taskItem",
5466 "attrs": {"localId": ""},
5467 "content": [
5468 {
5469 "type": "taskItem",
5470 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5471 "content": [{"type": "text", "text": "Nested task one"}]
5472 },
5473 {
5474 "type": "taskItem",
5475 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5476 "content": [{"type": "text", "text": "Nested task two"}]
5477 }
5478 ]
5479 }
5480 ]
5481 }]
5482 }"#;
5483 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5484 let md = adf_to_markdown(&doc).unwrap();
5485 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5487 assert!(!md.contains("adf-unsupported"), "got: {md}");
5489 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5490 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5491 }
5492
5493 #[test]
5495 fn round_trip_nested_task_item() {
5496 let json = r#"{
5497 "type": "doc",
5498 "version": 1,
5499 "content": [{
5500 "type": "taskList",
5501 "attrs": {"localId": ""},
5502 "content": [
5503 {
5504 "type": "taskItem",
5505 "attrs": {"localId": "task-001", "state": "TODO"},
5506 "content": [{"type": "text", "text": "Normal task"}]
5507 },
5508 {
5509 "type": "taskItem",
5510 "attrs": {"localId": ""},
5511 "content": [
5512 {
5513 "type": "taskItem",
5514 "attrs": {"localId": "task-002", "state": "TODO"},
5515 "content": [{"type": "text", "text": "Nested one"}]
5516 },
5517 {
5518 "type": "taskItem",
5519 "attrs": {"localId": "task-003", "state": "DONE"},
5520 "content": [{"type": "text", "text": "Nested two"}]
5521 }
5522 ]
5523 }
5524 ]
5525 }]
5526 }"#;
5527 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5528 let md = adf_to_markdown(&doc).unwrap();
5529 let doc2 = markdown_to_adf(&md).unwrap();
5530
5531 assert_eq!(doc2.content[0].node_type, "taskList");
5533 let items = doc2.content[0].content.as_ref().unwrap();
5534 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5535
5536 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5538 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5539 let first_content = items[0].content.as_ref().unwrap();
5540 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5541
5542 let container = &items[1];
5544 assert_eq!(container.node_type, "taskItem");
5545 let c_attrs = container.attrs.as_ref().unwrap();
5546 assert!(
5547 c_attrs.get("state").is_none(),
5548 "container should have no state attr, got: {c_attrs:?}"
5549 );
5550
5551 let container_content = container.content.as_ref().unwrap();
5553 assert_eq!(
5554 container_content.len(),
5555 2,
5556 "expected 2 bare taskItem children"
5557 );
5558 assert_eq!(container_content[0].node_type, "taskItem");
5559 assert_eq!(
5560 container_content[0].attrs.as_ref().unwrap()["state"],
5561 "TODO"
5562 );
5563 assert_eq!(
5564 container_content[0].attrs.as_ref().unwrap()["localId"],
5565 "task-002"
5566 );
5567 assert_eq!(container_content[1].node_type, "taskItem");
5568 assert_eq!(
5569 container_content[1].attrs.as_ref().unwrap()["state"],
5570 "DONE"
5571 );
5572 assert_eq!(
5573 container_content[1].attrs.as_ref().unwrap()["localId"],
5574 "task-003"
5575 );
5576 }
5577
5578 #[test]
5580 fn adf_nested_task_item_preserves_local_ids() {
5581 let json = r#"{
5582 "type": "doc",
5583 "version": 1,
5584 "content": [{
5585 "type": "taskList",
5586 "attrs": {"localId": "list-001"},
5587 "content": [{
5588 "type": "taskItem",
5589 "attrs": {"localId": "container-001", "state": "TODO"},
5590 "content": [{
5591 "type": "taskItem",
5592 "attrs": {"localId": "child-001", "state": "DONE"},
5593 "content": [{"type": "text", "text": "Nested child"}]
5594 }]
5595 }]
5596 }]
5597 }"#;
5598 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5599 let md = adf_to_markdown(&doc).unwrap();
5600 assert!(
5602 md.contains("localId=container-001"),
5603 "container localId missing: {md}"
5604 );
5605 assert!(
5607 md.contains("localId=child-001"),
5608 "child localId missing: {md}"
5609 );
5610 assert!(!md.contains("adf-unsupported"), "got: {md}");
5611 }
5612
5613 #[test]
5616 fn adf_nested_task_item_mixed_with_block_node() {
5617 let json = r#"{
5618 "type": "doc",
5619 "version": 1,
5620 "content": [{
5621 "type": "taskList",
5622 "attrs": {"localId": ""},
5623 "content": [{
5624 "type": "taskItem",
5625 "attrs": {"localId": "", "state": "TODO"},
5626 "content": [
5627 {
5628 "type": "taskItem",
5629 "attrs": {"localId": "", "state": "TODO"},
5630 "content": [{"type": "text", "text": "A nested task"}]
5631 },
5632 {
5633 "type": "paragraph",
5634 "content": [{"type": "text", "text": "Stray paragraph"}]
5635 }
5636 ]
5637 }]
5638 }]
5639 }"#;
5640 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5641 let md = adf_to_markdown(&doc).unwrap();
5642 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5643 assert!(md.contains(" Stray paragraph"), "got: {md}");
5644 assert!(!md.contains("adf-unsupported"), "got: {md}");
5645 }
5646
5647 #[test]
5651 fn task_item_with_text_and_nested_sub_content() {
5652 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5653 let doc = markdown_to_adf(md).unwrap();
5654 assert_eq!(doc.content[0].node_type, "taskList");
5655 let items = doc.content[0].content.as_ref().unwrap();
5656 assert_eq!(items.len(), 2, "got: {items:?}");
5659 let parent = &items[0];
5660 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5661 let parent_content = parent.content.as_ref().unwrap();
5662 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5663 assert_eq!(items[1].node_type, "taskList");
5665 let nested = items[1].content.as_ref().unwrap();
5666 assert_eq!(nested.len(), 1);
5667 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5668 }
5669
5670 #[test]
5674 fn task_item_empty_with_non_tasklist_sub_content() {
5675 let md = "- [ ] \n Some paragraph text\n";
5676 let doc = markdown_to_adf(md).unwrap();
5677 assert_eq!(doc.content[0].node_type, "taskList");
5678 let items = doc.content[0].content.as_ref().unwrap();
5679 assert_eq!(items.len(), 1);
5680 let item = &items[0];
5681 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5682 let content = item.content.as_ref().unwrap();
5683 assert_eq!(content[0].node_type, "paragraph");
5685 }
5686
5687 #[test]
5689 fn adf_nested_task_item_single_child() {
5690 let json = r#"{
5691 "type": "doc",
5692 "version": 1,
5693 "content": [{
5694 "type": "taskList",
5695 "attrs": {"localId": ""},
5696 "content": [{
5697 "type": "taskItem",
5698 "attrs": {"localId": "", "state": "TODO"},
5699 "content": [{
5700 "type": "taskItem",
5701 "attrs": {"localId": "", "state": "DONE"},
5702 "content": [{"type": "text", "text": "Only child"}]
5703 }]
5704 }]
5705 }]
5706 }"#;
5707 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5708 let md = adf_to_markdown(&doc).unwrap();
5709 assert!(md.contains(" - [x] Only child"), "got: {md}");
5710 assert!(!md.contains("adf-unsupported"), "got: {md}");
5711 }
5712
5713 #[test]
5716 fn adf_nested_tasklist_sibling_renders_indented() {
5717 let json = r#"{
5718 "version": 1,
5719 "type": "doc",
5720 "content": [{
5721 "type": "taskList",
5722 "attrs": {"localId": ""},
5723 "content": [
5724 {
5725 "type": "taskItem",
5726 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5727 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5728 },
5729 {
5730 "type": "taskList",
5731 "attrs": {"localId": ""},
5732 "content": [{
5733 "type": "taskItem",
5734 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5735 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5736 }]
5737 },
5738 {
5739 "type": "taskItem",
5740 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5741 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5742 }
5743 ]
5744 }]
5745 }"#;
5746 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5747 let md = adf_to_markdown(&doc).unwrap();
5748 assert!(md.contains("- [ ] parent task one"), "got: {md}");
5750 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
5751 assert!(md.contains("- [ ] parent task two"), "got: {md}");
5752 }
5753
5754 #[test]
5756 fn round_trip_nested_tasklist_preserves_type() {
5757 let json = r#"{
5758 "version": 1,
5759 "type": "doc",
5760 "content": [{
5761 "type": "taskList",
5762 "attrs": {"localId": ""},
5763 "content": [
5764 {
5765 "type": "taskItem",
5766 "attrs": {"localId": "", "state": "TODO"},
5767 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5768 },
5769 {
5770 "type": "taskList",
5771 "attrs": {"localId": ""},
5772 "content": [{
5773 "type": "taskItem",
5774 "attrs": {"localId": "", "state": "TODO"},
5775 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5776 }]
5777 },
5778 {
5779 "type": "taskItem",
5780 "attrs": {"localId": "", "state": "TODO"},
5781 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5782 }
5783 ]
5784 }]
5785 }"#;
5786 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5787 let md = adf_to_markdown(&doc).unwrap();
5788 let rt_doc = markdown_to_adf(&md).unwrap();
5789 assert_eq!(rt_doc.content[0].node_type, "taskList");
5791 let items = rt_doc.content[0].content.as_ref().unwrap();
5792 assert_eq!(items.len(), 3, "got: {items:?}");
5795 assert_eq!(items[0].node_type, "taskItem");
5796 assert_eq!(
5797 items[1].node_type, "taskList",
5798 "nested taskList should survive round-trip"
5799 );
5800 assert_eq!(items[2].node_type, "taskItem");
5801 let nested_items = items[1].content.as_ref().unwrap();
5802 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5803 }
5804
5805 #[test]
5807 fn adf_nested_tasklist_done_state() {
5808 let json = r#"{
5809 "version": 1,
5810 "type": "doc",
5811 "content": [{
5812 "type": "taskList",
5813 "attrs": {"localId": ""},
5814 "content": [
5815 {
5816 "type": "taskItem",
5817 "attrs": {"localId": "", "state": "TODO"},
5818 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5819 },
5820 {
5821 "type": "taskList",
5822 "attrs": {"localId": ""},
5823 "content": [{
5824 "type": "taskItem",
5825 "attrs": {"localId": "", "state": "DONE"},
5826 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5827 }]
5828 }
5829 ]
5830 }]
5831 }"#;
5832 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5833 let md = adf_to_markdown(&doc).unwrap();
5834 assert!(md.contains(" - [x] done child"), "got: {md}");
5835 let rt_doc = markdown_to_adf(&md).unwrap();
5837 let items = rt_doc.content[0].content.as_ref().unwrap();
5838 assert_eq!(
5839 items[1].node_type, "taskList",
5840 "nested taskList should survive round-trip"
5841 );
5842 let nested_item = &items[1].content.as_ref().unwrap()[0];
5843 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5844 }
5845
5846 #[test]
5848 fn adf_multiple_nested_tasklists() {
5849 let json = r#"{
5850 "version": 1,
5851 "type": "doc",
5852 "content": [{
5853 "type": "taskList",
5854 "attrs": {"localId": ""},
5855 "content": [
5856 {
5857 "type": "taskItem",
5858 "attrs": {"localId": "", "state": "TODO"},
5859 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5860 },
5861 {
5862 "type": "taskList",
5863 "attrs": {"localId": ""},
5864 "content": [{
5865 "type": "taskItem",
5866 "attrs": {"localId": "", "state": "TODO"},
5867 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5868 }]
5869 },
5870 {
5871 "type": "taskItem",
5872 "attrs": {"localId": "", "state": "TODO"},
5873 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5874 },
5875 {
5876 "type": "taskList",
5877 "attrs": {"localId": ""},
5878 "content": [{
5879 "type": "taskItem",
5880 "attrs": {"localId": "", "state": "DONE"},
5881 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5882 }]
5883 }
5884 ]
5885 }]
5886 }"#;
5887 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5888 let md = adf_to_markdown(&doc).unwrap();
5889 assert!(md.contains("- [ ] first parent"), "got: {md}");
5890 assert!(md.contains(" - [ ] child A"), "got: {md}");
5891 assert!(md.contains("- [ ] second parent"), "got: {md}");
5892 assert!(md.contains(" - [x] child B"), "got: {md}");
5893 }
5894
5895 #[test]
5898 fn round_trip_nested_tasklist_stable() {
5899 let json = r#"{
5900 "version": 1,
5901 "type": "doc",
5902 "content": [{
5903 "type": "taskList",
5904 "attrs": {"localId": ""},
5905 "content": [
5906 {
5907 "type": "taskItem",
5908 "attrs": {"localId": "", "state": "TODO"},
5909 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5910 },
5911 {
5912 "type": "taskList",
5913 "attrs": {"localId": ""},
5914 "content": [{
5915 "type": "taskItem",
5916 "attrs": {"localId": "", "state": "TODO"},
5917 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5918 }]
5919 }
5920 ]
5921 }]
5922 }"#;
5923 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5924 let md1 = adf_to_markdown(&doc).unwrap();
5926 let rt1 = markdown_to_adf(&md1).unwrap();
5927 let md2 = adf_to_markdown(&rt1).unwrap();
5929 let rt2 = markdown_to_adf(&md2).unwrap();
5930 assert_eq!(md1, md2, "markdown should be stable across round-trips");
5932 let rt1_json = serde_json::to_string(&rt1).unwrap();
5934 let rt2_json = serde_json::to_string(&rt2).unwrap();
5935 assert_eq!(
5936 rt1_json, rt2_json,
5937 "ADF should be stable across round-trips"
5938 );
5939 }
5940
5941 #[test]
5946 fn task_item_mixed_sub_content_splits_siblings() {
5947 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
5948 let doc = markdown_to_adf(md).unwrap();
5949 let items = doc.content[0].content.as_ref().unwrap();
5950 assert_eq!(items.len(), 2, "got: {items:?}");
5952 assert_eq!(items[0].node_type, "taskItem");
5953 let parent_content = items[0].content.as_ref().unwrap();
5954 assert!(
5956 parent_content.iter().any(|n| n.node_type == "paragraph"),
5957 "non-taskList sub-content should stay as child: {parent_content:?}"
5958 );
5959 assert_eq!(items[1].node_type, "taskList");
5961 }
5962
5963 #[test]
5967 fn empty_task_item_mixed_sub_content_none_arm() {
5968 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
5969 let doc = markdown_to_adf(md).unwrap();
5970 let items = doc.content[0].content.as_ref().unwrap();
5971 assert_eq!(items.len(), 2, "got: {items:?}");
5973 assert_eq!(items[0].node_type, "taskItem");
5974 let parent_content = items[0].content.as_ref().unwrap();
5975 assert!(
5976 parent_content.iter().any(|n| n.node_type == "paragraph"),
5977 "paragraph should be assigned to taskItem: {parent_content:?}"
5978 );
5979 assert_eq!(items[1].node_type, "taskList");
5980 }
5981
5982 #[test]
5987 fn task_item_text_with_non_tasklist_sub_content_only() {
5988 let md = "- [ ] My task\n Extra paragraph content\n";
5989 let doc = markdown_to_adf(md).unwrap();
5990 let items = doc.content[0].content.as_ref().unwrap();
5991 assert_eq!(items.len(), 1, "got: {items:?}");
5993 assert_eq!(items[0].node_type, "taskItem");
5994 let content = items[0].content.as_ref().unwrap();
5995 assert!(
5997 content.iter().any(|n| n.node_type == "paragraph"),
5998 "paragraph sub-content should be a child of taskItem: {content:?}"
5999 );
6000 }
6001
6002 #[test]
6005 fn adf_list_item_leading_block_node() {
6006 let json = r#"{
6007 "version": 1,
6008 "type": "doc",
6009 "content": [{
6010 "type": "bulletList",
6011 "content": [{
6012 "type": "listItem",
6013 "content": [{
6014 "type": "codeBlock",
6015 "attrs": {"language": "rust"},
6016 "content": [{"type": "text", "text": "let x = 1;"}]
6017 }]
6018 }]
6019 }]
6020 }"#;
6021 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6022 let md = adf_to_markdown(&doc).unwrap();
6023 assert!(md.contains("```rust"), "got: {md}");
6024 assert!(md.contains("let x = 1;"), "got: {md}");
6025 for line in md.lines() {
6028 if line.starts_with("- ") {
6029 continue; }
6031 if line.trim().is_empty() {
6032 continue;
6033 }
6034 assert!(
6035 line.starts_with(" "),
6036 "continuation line not indented: {line:?}"
6037 );
6038 }
6039 }
6040
6041 #[test]
6044 fn code_block_in_list_item_backtick_roundtrip() {
6045 let json = r#"{
6046 "version": 1,
6047 "type": "doc",
6048 "content": [{
6049 "type": "bulletList",
6050 "content": [{
6051 "type": "listItem",
6052 "content": [{
6053 "type": "codeBlock",
6054 "attrs": {"language": ""},
6055 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6056 }]
6057 }]
6058 }]
6059 }"#;
6060 let original: AdfDocument = serde_json::from_str(json).unwrap();
6061 let md = adf_to_markdown(&original).unwrap();
6062 let roundtripped = markdown_to_adf(&md).unwrap();
6063 let list = &roundtripped.content[0];
6064 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6065 let item = &list.content.as_ref().unwrap()[0];
6066 let first_child = &item.content.as_ref().unwrap()[0];
6067 assert_eq!(
6068 first_child.node_type, "codeBlock",
6069 "expected codeBlock, got: {}",
6070 first_child.node_type
6071 );
6072 let text = first_child.content.as_ref().unwrap()[0]
6073 .text
6074 .as_deref()
6075 .unwrap();
6076 assert_eq!(text, "error: some value with a backtick ` at end");
6077 }
6078
6079 #[test]
6081 fn code_block_with_language_in_list_item_roundtrip() {
6082 let json = r#"{
6083 "version": 1,
6084 "type": "doc",
6085 "content": [{
6086 "type": "bulletList",
6087 "content": [{
6088 "type": "listItem",
6089 "content": [{
6090 "type": "codeBlock",
6091 "attrs": {"language": "rust"},
6092 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6093 }]
6094 }]
6095 }]
6096 }"#;
6097 let original: AdfDocument = serde_json::from_str(json).unwrap();
6098 let md = adf_to_markdown(&original).unwrap();
6099 let roundtripped = markdown_to_adf(&md).unwrap();
6100 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6101 let code = &item.content.as_ref().unwrap()[0];
6102 assert_eq!(code.node_type, "codeBlock");
6103 let lang = code
6104 .attrs
6105 .as_ref()
6106 .and_then(|a| a.get("language"))
6107 .and_then(serde_json::Value::as_str)
6108 .unwrap_or("");
6109 assert_eq!(lang, "rust");
6110 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6111 assert!(text.contains("println!"), "code content: {text}");
6112 }
6113
6114 #[test]
6116 fn code_block_in_ordered_list_item_roundtrip() {
6117 let json = r#"{
6118 "version": 1,
6119 "type": "doc",
6120 "content": [{
6121 "type": "orderedList",
6122 "attrs": {"order": 1},
6123 "content": [{
6124 "type": "listItem",
6125 "content": [{
6126 "type": "codeBlock",
6127 "attrs": {"language": ""},
6128 "content": [{"type": "text", "text": "backtick ` here"}]
6129 }]
6130 }]
6131 }]
6132 }"#;
6133 let original: AdfDocument = serde_json::from_str(json).unwrap();
6134 let md = adf_to_markdown(&original).unwrap();
6135 let roundtripped = markdown_to_adf(&md).unwrap();
6136 let list = &roundtripped.content[0];
6137 assert_eq!(list.node_type, "orderedList");
6138 let item = &list.content.as_ref().unwrap()[0];
6139 let code = &item.content.as_ref().unwrap()[0];
6140 assert_eq!(code.node_type, "codeBlock");
6141 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6142 assert_eq!(text, "backtick ` here");
6143 }
6144
6145 #[test]
6147 fn code_block_then_paragraph_in_list_item() {
6148 let json = r#"{
6149 "version": 1,
6150 "type": "doc",
6151 "content": [{
6152 "type": "bulletList",
6153 "content": [{
6154 "type": "listItem",
6155 "content": [
6156 {
6157 "type": "codeBlock",
6158 "attrs": {"language": ""},
6159 "content": [{"type": "text", "text": "code with ` backtick"}]
6160 },
6161 {
6162 "type": "paragraph",
6163 "content": [{"type": "text", "text": "description"}]
6164 }
6165 ]
6166 }]
6167 }]
6168 }"#;
6169 let original: AdfDocument = serde_json::from_str(json).unwrap();
6170 let md = adf_to_markdown(&original).unwrap();
6171 let roundtripped = markdown_to_adf(&md).unwrap();
6172 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6173 let children = item.content.as_ref().unwrap();
6174 assert_eq!(children[0].node_type, "codeBlock");
6175 assert_eq!(children[1].node_type, "paragraph");
6176 }
6177
6178 #[test]
6180 fn code_block_multiple_backticks_in_list_item() {
6181 let json = r#"{
6182 "version": 1,
6183 "type": "doc",
6184 "content": [{
6185 "type": "bulletList",
6186 "content": [{
6187 "type": "listItem",
6188 "content": [{
6189 "type": "codeBlock",
6190 "attrs": {"language": ""},
6191 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6192 }]
6193 }]
6194 }]
6195 }"#;
6196 let original: AdfDocument = serde_json::from_str(json).unwrap();
6197 let md = adf_to_markdown(&original).unwrap();
6198 let roundtripped = markdown_to_adf(&md).unwrap();
6199 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6200 let code = &item.content.as_ref().unwrap()[0];
6201 assert_eq!(code.node_type, "codeBlock");
6202 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6203 assert_eq!(text, "a ` b `` c ``` d");
6204 }
6205
6206 #[test]
6209 fn media_first_child_with_sub_content_in_list_item() {
6210 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6211 {"type":"listItem","content":[
6212 {"type":"mediaSingle","attrs":{"layout":"center"},
6213 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6214 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6215 ]}
6216 ]}]}"#;
6217 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6218 let md = adf_to_markdown(&doc).unwrap();
6219 let rt = markdown_to_adf(&md).unwrap();
6220 let item = &rt.content[0].content.as_ref().unwrap()[0];
6221 let children = item.content.as_ref().unwrap();
6222 assert_eq!(
6223 children.len(),
6224 2,
6225 "expected 2 children, got {}",
6226 children.len()
6227 );
6228 assert_eq!(children[0].node_type, "mediaSingle");
6229 let media = &children[0].content.as_ref().unwrap()[0];
6230 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6231 assert_eq!(children[1].node_type, "paragraph");
6232 }
6233
6234 #[test]
6235 fn inline_bold() {
6236 let doc = markdown_to_adf("Some **bold** text").unwrap();
6237 let content = doc.content[0].content.as_ref().unwrap();
6238 assert!(content.len() >= 3);
6239 let bold_node = &content[1];
6240 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6241 let marks = bold_node.marks.as_ref().unwrap();
6242 assert_eq!(marks[0].mark_type, "strong");
6243 }
6244
6245 #[test]
6246 fn inline_italic() {
6247 let doc = markdown_to_adf("Some *italic* text").unwrap();
6248 let content = doc.content[0].content.as_ref().unwrap();
6249 let italic_node = &content[1];
6250 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6251 let marks = italic_node.marks.as_ref().unwrap();
6252 assert_eq!(marks[0].mark_type, "em");
6253 }
6254
6255 #[test]
6256 fn inline_code() {
6257 let doc = markdown_to_adf("Use `code` here").unwrap();
6258 let content = doc.content[0].content.as_ref().unwrap();
6259 let code_node = &content[1];
6260 assert_eq!(code_node.text.as_deref(), Some("code"));
6261 let marks = code_node.marks.as_ref().unwrap();
6262 assert_eq!(marks[0].mark_type, "code");
6263 }
6264
6265 #[test]
6269 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6270 let doc = AdfDocument {
6271 version: 1,
6272 doc_type: "doc".to_string(),
6273 content: vec![AdfNode::paragraph(vec![
6274 AdfNode::text("Run "),
6275 AdfNode::text_with_marks(
6276 "ADD `custom_threshold` TEXT NOT NULL",
6277 vec![AdfMark::code()],
6278 ),
6279 AdfNode::text(" to update the schema."),
6280 ])],
6281 };
6282 let md = adf_to_markdown(&doc).unwrap();
6283 assert!(
6284 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6285 "expected double-backtick delimiters, got: {md}"
6286 );
6287 }
6288
6289 #[test]
6292 fn inline_code_double_backtick_delimiters_parse() {
6293 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6294 let content = doc.content[0].content.as_ref().unwrap();
6295 assert_eq!(content.len(), 3, "content: {content:?}");
6296 let code_node = &content[1];
6297 assert_eq!(
6298 code_node.text.as_deref(),
6299 Some("ADD `custom_threshold` TEXT NOT NULL")
6300 );
6301 let marks = code_node.marks.as_ref().unwrap();
6302 assert_eq!(marks[0].mark_type, "code");
6303 }
6304
6305 #[test]
6308 fn inline_code_with_backtick_roundtrip() {
6309 let json = r#"{
6310 "version": 1,
6311 "type": "doc",
6312 "content": [{
6313 "type": "paragraph",
6314 "content": [
6315 {"type": "text", "text": "Run "},
6316 {
6317 "type": "text",
6318 "text": "ADD `custom_threshold` TEXT NOT NULL",
6319 "marks": [{"type": "code"}]
6320 },
6321 {"type": "text", "text": " to update the schema."}
6322 ]
6323 }]
6324 }"#;
6325 let original: AdfDocument = serde_json::from_str(json).unwrap();
6326 let md = adf_to_markdown(&original).unwrap();
6327 let roundtripped = markdown_to_adf(&md).unwrap();
6328 let para = &roundtripped.content[0];
6329 let children = para.content.as_ref().unwrap();
6330 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6331 assert_eq!(children[0].text.as_deref(), Some("Run "));
6332 assert_eq!(
6333 children[1].text.as_deref(),
6334 Some("ADD `custom_threshold` TEXT NOT NULL")
6335 );
6336 let marks = children[1].marks.as_ref().unwrap();
6337 assert_eq!(marks.len(), 1);
6338 assert_eq!(marks[0].mark_type, "code");
6339 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6340 }
6341
6342 #[test]
6347 fn inline_code_with_double_backtick_roundtrip() {
6348 let doc = AdfDocument {
6349 version: 1,
6350 doc_type: "doc".to_string(),
6351 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6352 "x `` y",
6353 vec![AdfMark::code()],
6354 )])],
6355 };
6356 let md = adf_to_markdown(&doc).unwrap();
6357 let roundtripped = markdown_to_adf(&md).unwrap();
6358 let content = roundtripped.content[0].content.as_ref().unwrap();
6359 assert_eq!(content.len(), 1);
6360 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6361 let marks = content[0].marks.as_ref().unwrap();
6362 assert_eq!(marks[0].mark_type, "code");
6363 }
6364
6365 #[test]
6368 fn inline_code_leading_backtick_roundtrip() {
6369 let doc = AdfDocument {
6370 version: 1,
6371 doc_type: "doc".to_string(),
6372 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6373 "`start",
6374 vec![AdfMark::code()],
6375 )])],
6376 };
6377 let md = adf_to_markdown(&doc).unwrap();
6378 let roundtripped = markdown_to_adf(&md).unwrap();
6379 let content = roundtripped.content[0].content.as_ref().unwrap();
6380 assert_eq!(content[0].text.as_deref(), Some("`start"));
6381 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6382 }
6383
6384 #[test]
6386 fn inline_code_trailing_backtick_roundtrip() {
6387 let doc = AdfDocument {
6388 version: 1,
6389 doc_type: "doc".to_string(),
6390 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6391 "end`",
6392 vec![AdfMark::code()],
6393 )])],
6394 };
6395 let md = adf_to_markdown(&doc).unwrap();
6396 let roundtripped = markdown_to_adf(&md).unwrap();
6397 let content = roundtripped.content[0].content.as_ref().unwrap();
6398 assert_eq!(content[0].text.as_deref(), Some("end`"));
6399 }
6400
6401 #[test]
6404 fn inline_code_space_padded_content_roundtrip() {
6405 let doc = AdfDocument {
6406 version: 1,
6407 doc_type: "doc".to_string(),
6408 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6409 " foo ",
6410 vec![AdfMark::code()],
6411 )])],
6412 };
6413 let md = adf_to_markdown(&doc).unwrap();
6414 let roundtripped = markdown_to_adf(&md).unwrap();
6415 let content = roundtripped.content[0].content.as_ref().unwrap();
6416 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6417 }
6418
6419 #[test]
6422 fn inline_code_all_spaces_roundtrip() {
6423 let doc = AdfDocument {
6424 version: 1,
6425 doc_type: "doc".to_string(),
6426 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6427 " ",
6428 vec![AdfMark::code()],
6429 )])],
6430 };
6431 let md = adf_to_markdown(&doc).unwrap();
6432 let roundtripped = markdown_to_adf(&md).unwrap();
6433 let content = roundtripped.content[0].content.as_ref().unwrap();
6434 assert_eq!(content[0].text.as_deref(), Some(" "));
6435 }
6436
6437 #[test]
6440 fn inline_code_with_link_and_backtick_roundtrip() {
6441 let doc = AdfDocument {
6442 version: 1,
6443 doc_type: "doc".to_string(),
6444 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6445 "fn `inner`",
6446 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6447 )])],
6448 };
6449 let md = adf_to_markdown(&doc).unwrap();
6450 assert!(
6451 md.contains("`` fn `inner` ``"),
6452 "expected padded double-backtick delimiters inside link, got: {md}"
6453 );
6454 let roundtripped = markdown_to_adf(&md).unwrap();
6455 let content = roundtripped.content[0].content.as_ref().unwrap();
6456 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6457 let mark_types: Vec<&str> = content[0]
6458 .marks
6459 .as_ref()
6460 .unwrap()
6461 .iter()
6462 .map(|m| m.mark_type.as_str())
6463 .collect();
6464 assert!(mark_types.contains(&"code"));
6465 assert!(mark_types.contains(&"link"));
6466 }
6467
6468 #[test]
6470 fn inline_code_unmatched_run_is_plain_text() {
6471 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6472 let content = doc.content[0].content.as_ref().unwrap();
6473 assert_eq!(content.len(), 1);
6474 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6475 assert!(content[0].marks.is_none());
6476 }
6477
6478 #[test]
6482 fn inline_code_mismatched_delimiters_is_plain_text() {
6483 let doc = markdown_to_adf("``foo` bar").unwrap();
6484 let content = doc.content[0].content.as_ref().unwrap();
6485 assert_eq!(content.len(), 1);
6486 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6487 assert!(content[0].marks.is_none());
6488 }
6489
6490 #[test]
6491 fn inline_code_delimiter_chooses_correct_length() {
6492 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6493 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6494 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6495 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6496 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6497 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6498 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6499 assert_eq!(inline_code_delimiter(" "), (1, false));
6500 assert_eq!(inline_code_delimiter(" "), (1, false));
6501 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6502 }
6503
6504 #[test]
6505 fn try_parse_inline_code_strips_paired_spaces() {
6506 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6507 assert_eq!(end, 11);
6508 assert_eq!(content, "`foo`");
6509 }
6510
6511 #[test]
6512 fn try_parse_inline_code_all_space_content_is_preserved() {
6513 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6514 assert_eq!(content, " ");
6515 }
6516
6517 #[test]
6518 fn try_parse_inline_code_single_run_matches_first_close() {
6519 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6520 assert_eq!(end, 5);
6521 assert_eq!(content, "foo");
6522 }
6523
6524 #[test]
6525 fn try_parse_inline_code_no_match_returns_none() {
6526 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6527 assert!(try_parse_inline_code("plain text", 0).is_none());
6528 }
6529
6530 #[test]
6531 fn is_code_fence_opener_rejects_info_with_backtick() {
6532 assert!(is_code_fence_opener("```"));
6533 assert!(is_code_fence_opener("```rust"));
6534 assert!(is_code_fence_opener("```\"\""));
6535 assert!(!is_code_fence_opener("```x `` y```"));
6536 assert!(!is_code_fence_opener("``not-enough"));
6537 assert!(!is_code_fence_opener("no fence"));
6538 }
6539
6540 #[test]
6541 fn inline_strikethrough() {
6542 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6543 let content = doc.content[0].content.as_ref().unwrap();
6544 let strike_node = &content[1];
6545 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6546 let marks = strike_node.marks.as_ref().unwrap();
6547 assert_eq!(marks[0].mark_type, "strike");
6548 }
6549
6550 #[test]
6551 fn inline_link() {
6552 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6553 let content = doc.content[0].content.as_ref().unwrap();
6554 let link_node = &content[1];
6555 assert_eq!(link_node.text.as_deref(), Some("here"));
6556 let marks = link_node.marks.as_ref().unwrap();
6557 assert_eq!(marks[0].mark_type, "link");
6558 }
6559
6560 #[test]
6561 fn block_image() {
6562 let md = "";
6563 let doc = markdown_to_adf(md).unwrap();
6564 assert_eq!(doc.content[0].node_type, "mediaSingle");
6565 }
6566
6567 #[test]
6568 fn table() {
6569 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6570 let doc = markdown_to_adf(md).unwrap();
6571 assert_eq!(doc.content[0].node_type, "table");
6572 let rows = doc.content[0].content.as_ref().unwrap();
6573 assert_eq!(rows.len(), 2); }
6575
6576 #[test]
6579 fn adf_paragraph_to_markdown() {
6580 let doc = AdfDocument {
6581 version: 1,
6582 doc_type: "doc".to_string(),
6583 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6584 };
6585 let md = adf_to_markdown(&doc).unwrap();
6586 assert_eq!(md.trim(), "Hello world");
6587 }
6588
6589 #[test]
6590 fn adf_heading_to_markdown() {
6591 let doc = AdfDocument {
6592 version: 1,
6593 doc_type: "doc".to_string(),
6594 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6595 };
6596 let md = adf_to_markdown(&doc).unwrap();
6597 assert_eq!(md.trim(), "## Title");
6598 }
6599
6600 #[test]
6601 fn adf_bold_to_markdown() {
6602 let doc = AdfDocument {
6603 version: 1,
6604 doc_type: "doc".to_string(),
6605 content: vec![AdfNode::paragraph(vec![
6606 AdfNode::text("Normal "),
6607 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6608 AdfNode::text(" text"),
6609 ])],
6610 };
6611 let md = adf_to_markdown(&doc).unwrap();
6612 assert_eq!(md.trim(), "Normal **bold** text");
6613 }
6614
6615 #[test]
6616 fn adf_code_block_to_markdown() {
6617 let doc = AdfDocument {
6618 version: 1,
6619 doc_type: "doc".to_string(),
6620 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6621 };
6622 let md = adf_to_markdown(&doc).unwrap();
6623 assert!(md.contains("```rust"));
6624 assert!(md.contains("let x = 1;"));
6625 assert!(md.contains("```"));
6626 }
6627
6628 #[test]
6629 fn adf_rule_to_markdown() {
6630 let doc = AdfDocument {
6631 version: 1,
6632 doc_type: "doc".to_string(),
6633 content: vec![AdfNode::rule()],
6634 };
6635 let md = adf_to_markdown(&doc).unwrap();
6636 assert!(md.contains("---"));
6637 }
6638
6639 #[test]
6640 fn adf_bullet_list_to_markdown() {
6641 let doc = AdfDocument {
6642 version: 1,
6643 doc_type: "doc".to_string(),
6644 content: vec![AdfNode::bullet_list(vec![
6645 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6646 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6647 ])],
6648 };
6649 let md = adf_to_markdown(&doc).unwrap();
6650 assert!(md.contains("- A"));
6651 assert!(md.contains("- B"));
6652 }
6653
6654 #[test]
6655 fn adf_link_to_markdown() {
6656 let doc = AdfDocument {
6657 version: 1,
6658 doc_type: "doc".to_string(),
6659 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6660 "click",
6661 vec![AdfMark::link("https://example.com")],
6662 )])],
6663 };
6664 let md = adf_to_markdown(&doc).unwrap();
6665 assert_eq!(md.trim(), "[click](https://example.com)");
6666 }
6667
6668 #[test]
6669 fn unsupported_block_preserved_as_json() {
6670 let doc = AdfDocument {
6671 version: 1,
6672 doc_type: "doc".to_string(),
6673 content: vec![AdfNode {
6674 node_type: "unknownBlock".to_string(),
6675 attrs: Some(serde_json::json!({"key": "value"})),
6676 content: None,
6677 text: None,
6678 marks: None,
6679 local_id: None,
6680 parameters: None,
6681 }],
6682 };
6683 let md = adf_to_markdown(&doc).unwrap();
6684 assert!(md.contains("```adf-unsupported"));
6685 assert!(md.contains("\"unknownBlock\""));
6686 }
6687
6688 #[test]
6689 fn unsupported_block_round_trips() {
6690 let original = AdfDocument {
6691 version: 1,
6692 doc_type: "doc".to_string(),
6693 content: vec![AdfNode {
6694 node_type: "unknownBlock".to_string(),
6695 attrs: Some(serde_json::json!({"key": "value"})),
6696 content: None,
6697 text: None,
6698 marks: None,
6699 local_id: None,
6700 parameters: None,
6701 }],
6702 };
6703 let md = adf_to_markdown(&original).unwrap();
6704 let restored = markdown_to_adf(&md).unwrap();
6705 assert_eq!(restored.content[0].node_type, "unknownBlock");
6706 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6707 }
6708
6709 #[test]
6712 fn round_trip_simple_document() {
6713 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6714 let adf = markdown_to_adf(md).unwrap();
6715 let restored = adf_to_markdown(&adf).unwrap();
6716
6717 assert!(restored.contains("# Hello"));
6718 assert!(restored.contains("**bold**"));
6719 assert!(restored.contains("*italic*"));
6720 assert!(restored.contains("- Item 1"));
6721 assert!(restored.contains("- Item 2"));
6722 }
6723
6724 #[test]
6725 fn round_trip_code_block() {
6726 let md = "```python\nprint('hello')\n```\n";
6727 let adf = markdown_to_adf(md).unwrap();
6728 let restored = adf_to_markdown(&adf).unwrap();
6729
6730 assert!(restored.contains("```python"));
6731 assert!(restored.contains("print('hello')"));
6732 }
6733
6734 #[test]
6735 fn round_trip_code_block_no_attrs() {
6736 let adf_json = r#"{"version":1,"type":"doc","content":[
6737 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6738 ]}"#;
6739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6740 assert!(doc.content[0].attrs.is_none());
6741 let md = adf_to_markdown(&doc).unwrap();
6742 let round_tripped = markdown_to_adf(&md).unwrap();
6743 assert!(round_tripped.content[0].attrs.is_none());
6744 }
6745
6746 #[test]
6747 fn round_trip_code_block_empty_language() {
6748 let adf_json = r#"{"version":1,"type":"doc","content":[
6749 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6750 ]}"#;
6751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6752 let attrs = doc.content[0].attrs.as_ref().unwrap();
6753 assert_eq!(attrs["language"], "");
6754 let md = adf_to_markdown(&doc).unwrap();
6755 let round_tripped = markdown_to_adf(&md).unwrap();
6756 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6757 assert_eq!(rt_attrs["language"], "");
6758 }
6759
6760 #[test]
6761 fn round_trip_code_block_with_language() {
6762 let adf_json = r#"{"version":1,"type":"doc","content":[
6763 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6764 ]}"#;
6765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6766 let md = adf_to_markdown(&doc).unwrap();
6767 let round_tripped = markdown_to_adf(&md).unwrap();
6768 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6769 assert_eq!(rt_attrs["language"], "python");
6770 }
6771
6772 #[test]
6773 fn multiple_paragraphs() {
6774 let md = "First paragraph.\n\nSecond paragraph.\n";
6775 let adf = markdown_to_adf(md).unwrap();
6776 assert_eq!(adf.content.len(), 2);
6777 assert_eq!(adf.content[0].node_type, "paragraph");
6778 assert_eq!(adf.content[1].node_type, "paragraph");
6779 }
6780
6781 #[test]
6784 fn horizontal_rule_underscores() {
6785 let doc = markdown_to_adf("___").unwrap();
6786 assert_eq!(doc.content[0].node_type, "rule");
6787 }
6788
6789 #[test]
6790 fn not_a_horizontal_rule_too_short() {
6791 let doc = markdown_to_adf("--").unwrap();
6792 assert_eq!(doc.content[0].node_type, "paragraph");
6793 }
6794
6795 #[test]
6796 fn bullet_list_star_marker() {
6797 let md = "* Apple\n* Banana";
6798 let doc = markdown_to_adf(md).unwrap();
6799 assert_eq!(doc.content[0].node_type, "bulletList");
6800 let items = doc.content[0].content.as_ref().unwrap();
6801 assert_eq!(items.len(), 2);
6802 }
6803
6804 #[test]
6805 fn bullet_list_plus_marker() {
6806 let md = "+ One\n+ Two";
6807 let doc = markdown_to_adf(md).unwrap();
6808 assert_eq!(doc.content[0].node_type, "bulletList");
6809 }
6810
6811 #[test]
6812 fn ordered_list_non_one_start() {
6813 let md = "5. Fifth\n6. Sixth";
6814 let doc = markdown_to_adf(md).unwrap();
6815 let node = &doc.content[0];
6816 assert_eq!(node.node_type, "orderedList");
6817 let attrs = node.attrs.as_ref().unwrap();
6818 assert_eq!(attrs["order"], 5);
6819 }
6820
6821 #[test]
6822 fn ordered_list_start_at_one_omits_order_attr() {
6823 let md = "1. First\n2. Second";
6827 let doc = markdown_to_adf(md).unwrap();
6828 let node = &doc.content[0];
6829 assert_eq!(node.node_type, "orderedList");
6830 assert!(
6831 node.attrs.is_none(),
6832 "attrs should be omitted when order=1, got: {:?}",
6833 node.attrs
6834 );
6835 }
6836
6837 #[test]
6838 fn blockquote_bare_marker() {
6839 let md = ">quoted text";
6841 let doc = markdown_to_adf(md).unwrap();
6842 assert_eq!(doc.content[0].node_type, "blockquote");
6843 }
6844
6845 #[test]
6846 fn image_no_alt() {
6847 let md = "";
6848 let doc = markdown_to_adf(md).unwrap();
6849 let node = &doc.content[0];
6850 assert_eq!(node.node_type, "mediaSingle");
6851 let media = &node.content.as_ref().unwrap()[0];
6853 let attrs = media.attrs.as_ref().unwrap();
6854 assert!(attrs.get("alt").is_none());
6855 }
6856
6857 #[test]
6858 fn image_with_alt() {
6859 let md = "";
6860 let doc = markdown_to_adf(md).unwrap();
6861 let media = &doc.content[0].content.as_ref().unwrap()[0];
6862 let attrs = media.attrs.as_ref().unwrap();
6863 assert_eq!(attrs["alt"], "A photo");
6864 }
6865
6866 #[test]
6867 fn table_multi_body_rows() {
6868 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6869 let doc = markdown_to_adf(md).unwrap();
6870 let rows = doc.content[0].content.as_ref().unwrap();
6871 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
6874 assert_eq!(header_cells[0].node_type, "tableHeader");
6875 let body_cells = rows[1].content.as_ref().unwrap();
6877 assert_eq!(body_cells[0].node_type, "tableCell");
6878 }
6879
6880 #[test]
6881 fn table_no_separator_is_not_table() {
6882 let md = "| not | a table |";
6884 let doc = markdown_to_adf(md).unwrap();
6885 assert_eq!(doc.content[0].node_type, "paragraph");
6886 }
6887
6888 #[test]
6889 fn inline_underscore_bold() {
6890 let doc = markdown_to_adf("Some __bold__ text").unwrap();
6891 let content = doc.content[0].content.as_ref().unwrap();
6892 let bold_node = &content[1];
6893 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6894 let marks = bold_node.marks.as_ref().unwrap();
6895 assert_eq!(marks[0].mark_type, "strong");
6896 }
6897
6898 #[test]
6899 fn inline_underscore_italic() {
6900 let doc = markdown_to_adf("Some _italic_ text").unwrap();
6901 let content = doc.content[0].content.as_ref().unwrap();
6902 let italic_node = &content[1];
6903 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6904 let marks = italic_node.marks.as_ref().unwrap();
6905 assert_eq!(marks[0].mark_type, "em");
6906 }
6907
6908 #[test]
6909 fn intraword_underscore_not_emphasis() {
6910 let doc = markdown_to_adf("call do_something_useful now").unwrap();
6912 let content = doc.content[0].content.as_ref().unwrap();
6913 assert_eq!(content.len(), 1, "should be a single text node");
6914 assert_eq!(
6915 content[0].text.as_deref(),
6916 Some("call do_something_useful now")
6917 );
6918 assert!(content[0].marks.is_none());
6919 }
6920
6921 #[test]
6922 fn intraword_underscore_multiple() {
6923 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
6925 let content = doc.content[0].content.as_ref().unwrap();
6926 assert_eq!(content.len(), 1);
6927 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
6928 assert!(content[0].marks.is_none());
6929 }
6930
6931 #[test]
6932 fn intraword_double_underscore_not_bold() {
6933 let doc = markdown_to_adf("foo__bar__baz").unwrap();
6935 let content = doc.content[0].content.as_ref().unwrap();
6936 assert_eq!(content.len(), 1);
6937 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
6938 assert!(content[0].marks.is_none());
6939 }
6940
6941 #[test]
6942 fn intraword_triple_underscore_not_bold_italic() {
6943 let doc = markdown_to_adf("x___y___z").unwrap();
6945 let content = doc.content[0].content.as_ref().unwrap();
6946 assert_eq!(content.len(), 1);
6947 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
6948 assert!(content[0].marks.is_none());
6949 }
6950
6951 #[test]
6952 fn underscore_emphasis_still_works_with_spaces() {
6953 let doc = markdown_to_adf("some _italic_ here").unwrap();
6955 let content = doc.content[0].content.as_ref().unwrap();
6956 assert_eq!(content.len(), 3);
6957 assert_eq!(content[1].text.as_deref(), Some("italic"));
6958 let marks = content[1].marks.as_ref().unwrap();
6959 assert_eq!(marks[0].mark_type, "em");
6960 }
6961
6962 #[test]
6963 fn underscore_bold_still_works_with_spaces() {
6964 let doc = markdown_to_adf("some __bold__ here").unwrap();
6966 let content = doc.content[0].content.as_ref().unwrap();
6967 assert_eq!(content.len(), 3);
6968 assert_eq!(content[1].text.as_deref(), Some("bold"));
6969 let marks = content[1].marks.as_ref().unwrap();
6970 assert_eq!(marks[0].mark_type, "strong");
6971 }
6972
6973 #[test]
6974 fn intraword_underscore_closing_only() {
6975 let doc = markdown_to_adf("_foo_bar").unwrap();
6977 let content = doc.content[0].content.as_ref().unwrap();
6978 assert_eq!(content.len(), 1);
6979 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
6980 }
6981
6982 #[test]
6983 fn intraword_double_underscore_closing_only() {
6984 let doc = markdown_to_adf("__foo__bar").unwrap();
6986 let content = doc.content[0].content.as_ref().unwrap();
6987 assert_eq!(content.len(), 1);
6988 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
6989 }
6990
6991 #[test]
6992 fn intraword_triple_underscore_closing_only() {
6993 let doc = markdown_to_adf("___foo___bar").unwrap();
6995 let content = doc.content[0].content.as_ref().unwrap();
6996 assert_eq!(content.len(), 1);
6997 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6998 }
6999
7000 #[test]
7001 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7002 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7004 let content = doc.content[0].content.as_ref().unwrap();
7005 assert!(content.len() > 1 || content[0].marks.is_some());
7007 }
7008
7009 #[test]
7010 fn intraword_underscore_at_start_of_text() {
7011 let doc = markdown_to_adf("_italic_ word").unwrap();
7013 let content = doc.content[0].content.as_ref().unwrap();
7014 assert_eq!(content[0].text.as_deref(), Some("italic"));
7015 let marks = content[0].marks.as_ref().unwrap();
7016 assert_eq!(marks[0].mark_type, "em");
7017 }
7018
7019 #[test]
7020 fn intraword_underscore_at_end_of_text() {
7021 let doc = markdown_to_adf("word _italic_").unwrap();
7023 let content = doc.content[0].content.as_ref().unwrap();
7024 let last = content.last().unwrap();
7025 assert_eq!(last.text.as_deref(), Some("italic"));
7026 let marks = last.marks.as_ref().unwrap();
7027 assert_eq!(marks[0].mark_type, "em");
7028 }
7029
7030 #[test]
7031 fn intraword_underscore_opening_only() {
7032 let doc = markdown_to_adf("a_b c_d").unwrap();
7035 let content = doc.content[0].content.as_ref().unwrap();
7036 assert_eq!(content.len(), 1);
7037 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7038 }
7039
7040 #[test]
7041 fn intraword_underscore_roundtrip() {
7042 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7044 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7045 let jfm = adf_to_markdown(&adf).unwrap();
7046 let roundtripped = markdown_to_adf(&jfm).unwrap();
7047 let content = roundtripped.content[0].content.as_ref().unwrap();
7048 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7049 assert_eq!(
7050 content[0].text.as_deref(),
7051 Some("call the do_something_useful function")
7052 );
7053 assert!(content[0].marks.is_none());
7054 }
7055
7056 #[test]
7057 fn asterisk_emphasis_roundtrip() {
7058 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7060 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7061 let jfm = adf_to_markdown(&adf).unwrap();
7062 let roundtripped = markdown_to_adf(&jfm).unwrap();
7063 let content = roundtripped.content[0].content.as_ref().unwrap();
7064 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7065 assert_eq!(
7066 content[0].text.as_deref(),
7067 Some("Status: *confirmed* and active")
7068 );
7069 assert!(content[0].marks.is_none());
7070 }
7071
7072 #[test]
7073 fn double_asterisk_roundtrip() {
7074 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7076 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7077 let jfm = adf_to_markdown(&adf).unwrap();
7078 let roundtripped = markdown_to_adf(&jfm).unwrap();
7079 let content = roundtripped.content[0].content.as_ref().unwrap();
7080 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7081 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7082 assert!(content[0].marks.is_none());
7083 }
7084
7085 #[test]
7086 fn asterisk_with_em_mark_roundtrip() {
7087 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7089 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7090 let jfm = adf_to_markdown(&adf).unwrap();
7091 let roundtripped = markdown_to_adf(&jfm).unwrap();
7092 let content = roundtripped.content[0].content.as_ref().unwrap();
7093 let em_node = content.iter().find(|n| {
7095 n.marks
7096 .as_ref()
7097 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7098 });
7099 assert!(em_node.is_some(), "should have an em-marked node");
7100 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7101 }
7102
7103 #[test]
7104 fn lone_asterisk_roundtrip() {
7105 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7107 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7108 let jfm = adf_to_markdown(&adf).unwrap();
7109 let roundtripped = markdown_to_adf(&jfm).unwrap();
7110 let content = roundtripped.content[0].content.as_ref().unwrap();
7111 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7112 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7113 }
7114
7115 #[test]
7116 fn escape_emphasis_markers_unit() {
7117 assert_eq!(escape_emphasis_markers("hello"), "hello");
7118 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7119 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7120 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7121 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7122 assert_eq!(escape_emphasis_markers(""), "");
7123 }
7124
7125 #[test]
7126 fn escape_emphasis_markers_underscore_intraword() {
7127 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7130 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7131 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7132 assert_eq!(
7133 escape_emphasis_markers("call do_something_useful"),
7134 "call do_something_useful"
7135 );
7136 }
7137
7138 #[test]
7139 fn escape_emphasis_markers_underscore_at_boundary() {
7140 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7143 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7144 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7145 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7146 assert_eq!(escape_emphasis_markers("_"), r"\_");
7147 }
7148
7149 #[test]
7150 fn escape_emphasis_markers_underscore_with_punctuation() {
7151 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7153 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7154 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7155 }
7156
7157 #[test]
7158 fn find_unescaped_skips_backslash_escaped() {
7159 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7161 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7163 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7165 assert_eq!(find_unescaped("", "**"), None);
7167 }
7168
7169 #[test]
7170 fn find_unescaped_char_skips_backslash_escaped() {
7171 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7173 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7175 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7177 assert_eq!(find_unescaped_char("", b'*'), None);
7179 }
7180
7181 #[test]
7182 fn double_asterisk_in_strong_mark_roundtrip() {
7183 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7185 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7186 let jfm = adf_to_markdown(&adf).unwrap();
7187 let roundtripped = markdown_to_adf(&jfm).unwrap();
7188 let content = roundtripped.content[0].content.as_ref().unwrap();
7189 let strong_node = content.iter().find(|n| {
7190 n.marks
7191 .as_ref()
7192 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7193 });
7194 assert!(strong_node.is_some(), "should have a strong-marked node");
7195 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7196 }
7197
7198 #[test]
7199 fn backtick_code_roundtrip() {
7200 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7202 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7203 let jfm = adf_to_markdown(&adf).unwrap();
7204 let roundtripped = markdown_to_adf(&jfm).unwrap();
7205 let content = roundtripped.content[0].content.as_ref().unwrap();
7206 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7207 assert_eq!(
7208 content[0].text.as_deref(),
7209 Some("Set `max_retries` to 3 in the config")
7210 );
7211 assert!(content[0].marks.is_none());
7212 }
7213
7214 #[test]
7215 fn multiple_backtick_spans_roundtrip() {
7216 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7218 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7219 let jfm = adf_to_markdown(&adf).unwrap();
7220 let roundtripped = markdown_to_adf(&jfm).unwrap();
7221 let content = roundtripped.content[0].content.as_ref().unwrap();
7222 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7223 assert_eq!(
7224 content[0].text.as_deref(),
7225 Some("Use `foo` and `bar` together")
7226 );
7227 assert!(content[0].marks.is_none());
7228 }
7229
7230 #[test]
7231 fn lone_backtick_roundtrip() {
7232 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7234 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7235 let jfm = adf_to_markdown(&adf).unwrap();
7236 let roundtripped = markdown_to_adf(&jfm).unwrap();
7237 let content = roundtripped.content[0].content.as_ref().unwrap();
7238 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7239 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7240 assert!(content[0].marks.is_none());
7241 }
7242
7243 #[test]
7244 fn backtick_with_code_mark_roundtrip() {
7245 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7247 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7248 let jfm = adf_to_markdown(&adf).unwrap();
7249 assert_eq!(jfm.trim(), "`max_retries`");
7250 let roundtripped = markdown_to_adf(&jfm).unwrap();
7251 let content = roundtripped.content[0].content.as_ref().unwrap();
7252 let code_node = content.iter().find(|n| {
7253 n.marks
7254 .as_ref()
7255 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7256 });
7257 assert!(code_node.is_some(), "should have a code-marked node");
7258 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7259 }
7260
7261 #[test]
7262 fn backtick_with_em_mark_roundtrip() {
7263 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7265 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7266 let jfm = adf_to_markdown(&adf).unwrap();
7267 let roundtripped = markdown_to_adf(&jfm).unwrap();
7268 let content = roundtripped.content[0].content.as_ref().unwrap();
7269 let em_node = content.iter().find(|n| {
7270 n.marks
7271 .as_ref()
7272 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7273 });
7274 assert!(em_node.is_some(), "should have an em-marked node");
7275 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7276 }
7277
7278 #[test]
7279 fn escape_pipes_in_cell_unit() {
7280 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7281 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7282 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7283 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7284 assert_eq!(escape_pipes_in_cell(""), "");
7285 assert_eq!(
7286 escape_pipes_in_cell("`parser.decode[T|json]`"),
7287 r"`parser.decode[T\|json]`"
7288 );
7289 }
7290
7291 #[test]
7292 fn escape_backticks_unit() {
7293 assert_eq!(escape_backticks("hello"), "hello");
7294 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7295 assert_eq!(escape_backticks("no ticks"), "no ticks");
7296 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7297 assert_eq!(escape_backticks(""), "");
7298 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7299 }
7300
7301 #[test]
7304 fn backslash_in_text_roundtrip() {
7305 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7307 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7308 let jfm = adf_to_markdown(&adf).unwrap();
7309 let roundtripped = markdown_to_adf(&jfm).unwrap();
7310 let content = roundtripped.content[0].content.as_ref().unwrap();
7311 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7312 assert_eq!(
7313 content[0].text.as_deref(),
7314 Some(r"The path is C:\Users\admin\file.txt")
7315 );
7316 }
7317
7318 #[test]
7319 fn backslash_emitted_as_double_backslash() {
7320 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7321 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7322 let jfm = adf_to_markdown(&adf).unwrap();
7323 assert!(
7324 jfm.contains(r"a\\b"),
7325 "JFM should contain escaped backslash: {jfm}"
7326 );
7327 }
7328
7329 #[test]
7330 fn consecutive_backslashes_roundtrip() {
7331 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7332 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7333 let jfm = adf_to_markdown(&adf).unwrap();
7334 let roundtripped = markdown_to_adf(&jfm).unwrap();
7335 let content = roundtripped.content[0].content.as_ref().unwrap();
7336 assert_eq!(
7337 content[0].text.as_deref(),
7338 Some(r"a\\b"),
7339 "consecutive backslashes should survive round-trip"
7340 );
7341 }
7342
7343 #[test]
7344 fn backslash_with_strong_mark_roundtrip() {
7345 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7346 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7347 let jfm = adf_to_markdown(&adf).unwrap();
7348 let roundtripped = markdown_to_adf(&jfm).unwrap();
7349 let content = roundtripped.content[0].content.as_ref().unwrap();
7350 let strong_node = content.iter().find(|n| {
7351 n.marks
7352 .as_ref()
7353 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7354 });
7355 assert!(strong_node.is_some(), "should have a strong-marked node");
7356 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7357 }
7358
7359 #[test]
7360 fn backslash_with_code_mark_not_escaped() {
7361 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7363 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7364 let jfm = adf_to_markdown(&adf).unwrap();
7365 assert_eq!(jfm.trim(), r"`C:\Users`");
7366 let roundtripped = markdown_to_adf(&jfm).unwrap();
7367 let content = roundtripped.content[0].content.as_ref().unwrap();
7368 let code_node = content.iter().find(|n| {
7369 n.marks
7370 .as_ref()
7371 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7372 });
7373 assert!(code_node.is_some(), "should have a code-marked node");
7374 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7375 }
7376
7377 #[test]
7378 fn backslash_before_special_chars_roundtrip() {
7379 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7381 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7382 let jfm = adf_to_markdown(&adf).unwrap();
7383 let roundtripped = markdown_to_adf(&jfm).unwrap();
7384 let content = roundtripped.content[0].content.as_ref().unwrap();
7385 assert_eq!(
7386 content[0].text.as_deref(),
7387 Some(r"\*not bold\*"),
7388 "backslash before special char should survive round-trip"
7389 );
7390 }
7391
7392 #[test]
7393 fn backslash_and_newline_in_text_roundtrip() {
7394 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7396 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7397 let jfm = adf_to_markdown(&adf).unwrap();
7398 let roundtripped = markdown_to_adf(&jfm).unwrap();
7399 let content = roundtripped.content[0].content.as_ref().unwrap();
7400 assert_eq!(
7401 content[0].text.as_deref(),
7402 Some("C:\\path\nline2"),
7403 "backslash and newline should both survive round-trip"
7404 );
7405 }
7406
7407 #[test]
7408 fn lone_backslash_roundtrip() {
7409 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7410 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7411 let jfm = adf_to_markdown(&adf).unwrap();
7412 let roundtripped = markdown_to_adf(&jfm).unwrap();
7413 let content = roundtripped.content[0].content.as_ref().unwrap();
7414 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7415 }
7416
7417 #[test]
7418 fn trailing_backslash_in_text_roundtrip() {
7419 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7421 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7422 let jfm = adf_to_markdown(&adf).unwrap();
7423 let roundtripped = markdown_to_adf(&jfm).unwrap();
7424 let content = roundtripped.content[0].content.as_ref().unwrap();
7425 assert_eq!(
7426 content[0].text.as_deref(),
7427 Some(r"end\"),
7428 "trailing backslash should survive round-trip"
7429 );
7430 }
7431
7432 #[test]
7433 fn escape_bare_urls_unit() {
7434 assert_eq!(escape_bare_urls("hello"), "hello");
7435 assert_eq!(escape_bare_urls(""), "");
7436 assert_eq!(
7437 escape_bare_urls("https://example.com"),
7438 r"\https://example.com"
7439 );
7440 assert_eq!(
7441 escape_bare_urls("http://example.com"),
7442 r"\http://example.com"
7443 );
7444 assert_eq!(
7445 escape_bare_urls("see https://a.com and https://b.com"),
7446 r"see \https://a.com and \https://b.com"
7447 );
7448 assert_eq!(escape_bare_urls("http header"), "http header");
7450 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7451 }
7452
7453 #[test]
7454 fn heading_not_valid_without_space() {
7455 let doc = markdown_to_adf("#Title").unwrap();
7457 assert_eq!(doc.content[0].node_type, "paragraph");
7458 }
7459
7460 #[test]
7461 fn heading_level_too_high() {
7462 let doc = markdown_to_adf("####### Not a heading").unwrap();
7464 assert_eq!(doc.content[0].node_type, "paragraph");
7465 }
7466
7467 #[test]
7468 fn empty_document() {
7469 let doc = markdown_to_adf("").unwrap();
7470 assert!(doc.content.is_empty());
7471 }
7472
7473 #[test]
7474 fn only_blank_lines() {
7475 let doc = markdown_to_adf("\n\n\n").unwrap();
7476 assert!(doc.content.is_empty());
7477 }
7478
7479 #[test]
7480 fn code_block_unterminated() {
7481 let md = "```rust\nfn main() {}";
7483 let doc = markdown_to_adf(md).unwrap();
7484 assert_eq!(doc.content[0].node_type, "codeBlock");
7485 }
7486
7487 #[test]
7488 fn mixed_document() {
7489 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7490 let doc = markdown_to_adf(md).unwrap();
7491 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7492 assert_eq!(
7493 types,
7494 vec![
7495 "heading",
7496 "paragraph",
7497 "bulletList",
7498 "codeBlock",
7499 "blockquote",
7500 "rule",
7501 "orderedList",
7502 ]
7503 );
7504 }
7505
7506 #[test]
7509 fn adf_ordered_list_to_markdown() {
7510 let doc = AdfDocument {
7511 version: 1,
7512 doc_type: "doc".to_string(),
7513 content: vec![AdfNode::ordered_list(
7514 vec![
7515 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7516 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7517 ],
7518 None,
7519 )],
7520 };
7521 let md = adf_to_markdown(&doc).unwrap();
7522 assert!(md.contains("1. First"));
7523 assert!(md.contains("2. Second"));
7524 }
7525
7526 #[test]
7527 fn adf_ordered_list_custom_start() {
7528 let doc = AdfDocument {
7529 version: 1,
7530 doc_type: "doc".to_string(),
7531 content: vec![AdfNode::ordered_list(
7532 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7533 AdfNode::text("Third"),
7534 ])])],
7535 Some(3),
7536 )],
7537 };
7538 let md = adf_to_markdown(&doc).unwrap();
7539 assert!(md.contains("3. Third"));
7540 }
7541
7542 #[test]
7543 fn adf_blockquote_to_markdown() {
7544 let doc = AdfDocument {
7545 version: 1,
7546 doc_type: "doc".to_string(),
7547 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7548 AdfNode::text("A quote"),
7549 ])])],
7550 };
7551 let md = adf_to_markdown(&doc).unwrap();
7552 assert!(md.contains("> A quote"));
7553 }
7554
7555 #[test]
7556 fn adf_table_to_markdown() {
7557 let doc = AdfDocument {
7558 version: 1,
7559 doc_type: "doc".to_string(),
7560 content: vec![AdfNode::table(vec![
7561 AdfNode::table_row(vec![
7562 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7563 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7564 ]),
7565 AdfNode::table_row(vec![
7566 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7567 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7568 ]),
7569 ])],
7570 };
7571 let md = adf_to_markdown(&doc).unwrap();
7572 assert!(md.contains("| Name | Value |"));
7573 assert!(md.contains("| --- | --- |"));
7574 assert!(md.contains("| a | 1 |"));
7575 }
7576
7577 #[test]
7578 fn adf_media_to_markdown() {
7579 let doc = AdfDocument {
7580 version: 1,
7581 doc_type: "doc".to_string(),
7582 content: vec![AdfNode::media_single(
7583 "https://example.com/img.png",
7584 Some("Alt"),
7585 )],
7586 };
7587 let md = adf_to_markdown(&doc).unwrap();
7588 assert!(md.contains(""));
7589 }
7590
7591 #[test]
7592 fn adf_media_no_alt_to_markdown() {
7593 let doc = AdfDocument {
7594 version: 1,
7595 doc_type: "doc".to_string(),
7596 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7597 };
7598 let md = adf_to_markdown(&doc).unwrap();
7599 assert!(md.contains(""));
7600 }
7601
7602 #[test]
7603 fn adf_italic_to_markdown() {
7604 let doc = AdfDocument {
7605 version: 1,
7606 doc_type: "doc".to_string(),
7607 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7608 "emphasis",
7609 vec![AdfMark::em()],
7610 )])],
7611 };
7612 let md = adf_to_markdown(&doc).unwrap();
7613 assert_eq!(md.trim(), "*emphasis*");
7614 }
7615
7616 #[test]
7617 fn adf_strikethrough_to_markdown() {
7618 let doc = AdfDocument {
7619 version: 1,
7620 doc_type: "doc".to_string(),
7621 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7622 "deleted",
7623 vec![AdfMark::strike()],
7624 )])],
7625 };
7626 let md = adf_to_markdown(&doc).unwrap();
7627 assert_eq!(md.trim(), "~~deleted~~");
7628 }
7629
7630 #[test]
7631 fn adf_inline_code_to_markdown() {
7632 let doc = AdfDocument {
7633 version: 1,
7634 doc_type: "doc".to_string(),
7635 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7636 "code",
7637 vec![AdfMark::code()],
7638 )])],
7639 };
7640 let md = adf_to_markdown(&doc).unwrap();
7641 assert_eq!(md.trim(), "`code`");
7642 }
7643
7644 #[test]
7645 fn adf_code_with_link_to_markdown() {
7646 let doc = AdfDocument {
7647 version: 1,
7648 doc_type: "doc".to_string(),
7649 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7650 "func",
7651 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7652 )])],
7653 };
7654 let md = adf_to_markdown(&doc).unwrap();
7655 assert_eq!(md.trim(), "[`func`](https://example.com)");
7656 }
7657
7658 #[test]
7659 fn adf_bold_italic_to_markdown() {
7660 let doc = AdfDocument {
7661 version: 1,
7662 doc_type: "doc".to_string(),
7663 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7664 "both",
7665 vec![AdfMark::strong(), AdfMark::em()],
7666 )])],
7667 };
7668 let md = adf_to_markdown(&doc).unwrap();
7669 assert_eq!(md.trim(), "***both***");
7670 }
7671
7672 #[test]
7673 fn adf_bold_link_to_markdown() {
7674 let doc = AdfDocument {
7675 version: 1,
7676 doc_type: "doc".to_string(),
7677 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7678 "bold link",
7679 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7680 )])],
7681 };
7682 let md = adf_to_markdown(&doc).unwrap();
7683 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7684 }
7685
7686 #[test]
7687 fn adf_strikethrough_bold_to_markdown() {
7688 let doc = AdfDocument {
7689 version: 1,
7690 doc_type: "doc".to_string(),
7691 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7692 "struck",
7693 vec![AdfMark::strike(), AdfMark::strong()],
7694 )])],
7695 };
7696 let md = adf_to_markdown(&doc).unwrap();
7697 assert_eq!(md.trim(), "~~**struck**~~");
7698 }
7699
7700 #[test]
7701 fn adf_hard_break_to_markdown() {
7702 let doc = AdfDocument {
7703 version: 1,
7704 doc_type: "doc".to_string(),
7705 content: vec![AdfNode::paragraph(vec![
7706 AdfNode::text("Line 1"),
7707 AdfNode::hard_break(),
7708 AdfNode::text("Line 2"),
7709 ])],
7710 };
7711 let md = adf_to_markdown(&doc).unwrap();
7712 assert!(md.contains("Line 1\\\n Line 2"));
7713 }
7714
7715 #[test]
7716 #[test]
7717 fn adf_unsupported_inline_to_markdown() {
7718 let doc = AdfDocument {
7719 version: 1,
7720 doc_type: "doc".to_string(),
7721 content: vec![AdfNode::paragraph(vec![AdfNode {
7722 node_type: "unknownInline".to_string(),
7723 attrs: None,
7724 content: None,
7725 text: None,
7726 marks: None,
7727 local_id: None,
7728 parameters: None,
7729 }])],
7730 };
7731 let md = adf_to_markdown(&doc).unwrap();
7732 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7733 }
7734
7735 #[test]
7738 fn adf_media_inline_to_markdown() {
7739 let doc = AdfDocument {
7740 version: 1,
7741 doc_type: "doc".to_string(),
7742 content: vec![AdfNode::paragraph(vec![
7743 AdfNode::text("see "),
7744 AdfNode::media_inline(serde_json::json!({
7745 "type": "image",
7746 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7747 "collection": "contentId-111111",
7748 "width": 200,
7749 "height": 100
7750 })),
7751 AdfNode::text(" for details"),
7752 ])],
7753 };
7754 let md = adf_to_markdown(&doc).unwrap();
7755 assert!(md.contains(":media-inline[]{"), "got: {md}");
7756 assert!(md.contains("type=image"));
7757 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7758 assert!(md.contains("collection=contentId-111111"));
7759 assert!(md.contains("width=200"));
7760 assert!(md.contains("height=100"));
7761 assert!(!md.contains("<!-- unsupported inline"));
7762 }
7763
7764 #[test]
7765 fn media_inline_round_trip() {
7766 let doc = AdfDocument {
7767 version: 1,
7768 doc_type: "doc".to_string(),
7769 content: vec![AdfNode::paragraph(vec![
7770 AdfNode::text("see "),
7771 AdfNode::media_inline(serde_json::json!({
7772 "type": "image",
7773 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7774 "collection": "contentId-111111",
7775 "width": 200,
7776 "height": 100
7777 })),
7778 AdfNode::text(" for details"),
7779 ])],
7780 };
7781 let md = adf_to_markdown(&doc).unwrap();
7782 let rt = markdown_to_adf(&md).unwrap();
7783
7784 let content = rt.content[0].content.as_ref().unwrap();
7785 assert_eq!(content[0].text.as_deref(), Some("see "));
7786 assert_eq!(content[1].node_type, "mediaInline");
7787 let attrs = content[1].attrs.as_ref().unwrap();
7788 assert_eq!(attrs["type"], "image");
7789 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7790 assert_eq!(attrs["collection"], "contentId-111111");
7791 assert_eq!(attrs["width"], 200);
7792 assert_eq!(attrs["height"], 100);
7793 assert_eq!(content[2].text.as_deref(), Some(" for details"));
7794 }
7795
7796 #[test]
7797 fn media_inline_external_url_round_trip() {
7798 let doc = AdfDocument {
7799 version: 1,
7800 doc_type: "doc".to_string(),
7801 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7802 serde_json::json!({
7803 "type": "external",
7804 "url": "https://example.com/image.png",
7805 "alt": "example",
7806 "width": 400,
7807 "height": 300
7808 }),
7809 )])],
7810 };
7811 let md = adf_to_markdown(&doc).unwrap();
7812 let rt = markdown_to_adf(&md).unwrap();
7813
7814 let content = rt.content[0].content.as_ref().unwrap();
7815 assert_eq!(content[0].node_type, "mediaInline");
7816 let attrs = content[0].attrs.as_ref().unwrap();
7817 assert_eq!(attrs["type"], "external");
7818 assert_eq!(attrs["url"], "https://example.com/image.png");
7819 assert_eq!(attrs["alt"], "example");
7820 assert_eq!(attrs["width"], 400);
7821 assert_eq!(attrs["height"], 300);
7822 }
7823
7824 #[test]
7825 fn media_inline_minimal_attrs() {
7826 let doc = AdfDocument {
7827 version: 1,
7828 doc_type: "doc".to_string(),
7829 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7830 serde_json::json!({"type": "file", "id": "abc-123"}),
7831 )])],
7832 };
7833 let md = adf_to_markdown(&doc).unwrap();
7834 let rt = markdown_to_adf(&md).unwrap();
7835
7836 let content = rt.content[0].content.as_ref().unwrap();
7837 assert_eq!(content[0].node_type, "mediaInline");
7838 let attrs = content[0].attrs.as_ref().unwrap();
7839 assert_eq!(attrs["type"], "file");
7840 assert_eq!(attrs["id"], "abc-123");
7841 }
7842
7843 #[test]
7844 fn media_inline_from_issue_476_reproducer() {
7845 let adf_json: serde_json::Value = serde_json::json!({
7847 "type": "doc",
7848 "version": 1,
7849 "content": [
7850 {
7851 "type": "paragraph",
7852 "content": [
7853 {"type": "text", "text": "see "},
7854 {
7855 "type": "mediaInline",
7856 "attrs": {
7857 "collection": "contentId-111111",
7858 "height": 100,
7859 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7860 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7861 "type": "image",
7862 "width": 200
7863 }
7864 },
7865 {"type": "text", "text": " for details"}
7866 ]
7867 }
7868 ]
7869 });
7870 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7871 let md = adf_to_markdown(&doc).unwrap();
7872 assert!(
7873 !md.contains("<!-- unsupported inline"),
7874 "mediaInline should not be unsupported; got: {md}"
7875 );
7876
7877 let rt = markdown_to_adf(&md).unwrap();
7878 let content = rt.content[0].content.as_ref().unwrap();
7879 assert_eq!(content[1].node_type, "mediaInline");
7880 let attrs = content[1].attrs.as_ref().unwrap();
7881 assert_eq!(attrs["type"], "image");
7882 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7883 assert_eq!(attrs["collection"], "contentId-111111");
7884 assert_eq!(attrs["width"], 200);
7885 assert_eq!(attrs["height"], 100);
7886 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7887 }
7888
7889 #[test]
7890 fn emoji_shortcode() {
7891 let doc = markdown_to_adf("Hello :wave: world").unwrap();
7892 let content = doc.content[0].content.as_ref().unwrap();
7893 assert_eq!(content[0].text.as_deref(), Some("Hello "));
7894 assert_eq!(content[1].node_type, "emoji");
7895 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7896 assert_eq!(content[2].text.as_deref(), Some(" world"));
7897 }
7898
7899 #[test]
7900 fn adf_emoji_to_markdown() {
7901 let doc = AdfDocument {
7902 version: 1,
7903 doc_type: "doc".to_string(),
7904 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7905 };
7906 let md = adf_to_markdown(&doc).unwrap();
7907 assert!(md.contains(":thumbsup:"));
7908 }
7909
7910 #[test]
7911 fn adf_emoji_with_colon_prefix_to_markdown() {
7912 let doc = AdfDocument {
7914 version: 1,
7915 doc_type: "doc".to_string(),
7916 content: vec![AdfNode::paragraph(vec![AdfNode {
7917 node_type: "emoji".to_string(),
7918 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
7919 content: None,
7920 text: None,
7921 marks: None,
7922 local_id: None,
7923 parameters: None,
7924 }])],
7925 };
7926 let md = adf_to_markdown(&doc).unwrap();
7927 assert!(md.contains(":thumbsup:"));
7928 assert!(!md.contains("::thumbsup::"));
7930 }
7931
7932 #[test]
7933 fn round_trip_emoji() {
7934 let md = "Hello :wave: world\n";
7935 let doc = markdown_to_adf(md).unwrap();
7936 let result = adf_to_markdown(&doc).unwrap();
7937 assert!(result.contains(":wave:"));
7938 }
7939
7940 #[test]
7941 fn emoji_with_id_and_text_round_trips() {
7942 let doc = AdfDocument {
7943 version: 1,
7944 doc_type: "doc".to_string(),
7945 content: vec![AdfNode::paragraph(vec![AdfNode {
7946 node_type: "emoji".to_string(),
7947 attrs: Some(
7948 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
7949 ),
7950 content: None,
7951 text: None,
7952 marks: None,
7953 local_id: None,
7954 parameters: None,
7955 }])],
7956 };
7957 let md = adf_to_markdown(&doc).unwrap();
7958 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
7959 assert!(md.contains("id="), "id attr present: {md}");
7960 assert!(md.contains("text="), "text attr present: {md}");
7961
7962 let round_tripped = markdown_to_adf(&md).unwrap();
7964 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7965 let attrs = emoji.attrs.as_ref().unwrap();
7966 assert_eq!(attrs["shortName"], ":check_mark:");
7967 assert_eq!(attrs["id"], "2705");
7968 assert_eq!(attrs["text"], "✅");
7969 }
7970
7971 #[test]
7972 fn emoji_without_extra_attrs_still_works() {
7973 let md = "Hello :wave: world\n";
7974 let doc = markdown_to_adf(md).unwrap();
7975 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
7976 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
7977 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
7979 }
7980
7981 #[test]
7982 fn emoji_shortname_preserves_colons_round_trip() {
7983 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7985 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
7986 ]}]}"#;
7987 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7988
7989 let md = adf_to_markdown(&doc).unwrap();
7991 let round_tripped = markdown_to_adf(&md).unwrap();
7992 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7993 let attrs = emoji.attrs.as_ref().unwrap();
7994 assert_eq!(
7995 attrs["shortName"], ":cross_mark:",
7996 "shortName should preserve colons, got: {}",
7997 attrs["shortName"]
7998 );
7999 assert_eq!(attrs["id"], "atlassian-cross_mark");
8000 assert_eq!(attrs["text"], "❌");
8001 }
8002
8003 #[test]
8004 fn emoji_shortname_without_colons_preserved() {
8005 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8007 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8008 ]}]}"#;
8009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8010 let md = adf_to_markdown(&doc).unwrap();
8011 let round_tripped = markdown_to_adf(&md).unwrap();
8012 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8013 let attrs = emoji.attrs.as_ref().unwrap();
8014 assert_eq!(
8015 attrs["shortName"], "white_check_mark",
8016 "shortName without colons should stay without colons, got: {}",
8017 attrs["shortName"]
8018 );
8019 }
8020
8021 #[test]
8022 fn colon_in_text_not_emoji() {
8023 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8025 let content = doc.content[0].content.as_ref().unwrap();
8026 assert_eq!(content.len(), 1);
8027 assert_eq!(content[0].node_type, "text");
8028 }
8029
8030 #[test]
8031 fn text_with_shortcode_pattern_round_trips_as_text() {
8032 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8034 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8035
8036 let md = adf_to_markdown(&doc).unwrap();
8037 let round_tripped = markdown_to_adf(&md).unwrap();
8038 let content = round_tripped.content[0].content.as_ref().unwrap();
8039
8040 assert_eq!(
8041 content.len(),
8042 1,
8043 "should be a single text node, got: {content:?}"
8044 );
8045 assert_eq!(content[0].node_type, "text");
8046 assert_eq!(
8047 content[0].text.as_deref().unwrap(),
8048 "Alert :fire: triggered on pod:pod42"
8049 );
8050 }
8051
8052 #[test]
8053 fn double_colon_pattern_round_trips_as_text() {
8054 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8057
8058 let md = adf_to_markdown(&doc).unwrap();
8059 let round_tripped = markdown_to_adf(&md).unwrap();
8060 let content = round_tripped.content[0].content.as_ref().unwrap();
8061
8062 assert_eq!(
8063 content.len(),
8064 1,
8065 "should be a single text node, got: {content:?}"
8066 );
8067 assert_eq!(content[0].node_type, "text");
8068 assert_eq!(
8069 content[0].text.as_deref().unwrap(),
8070 "Status::Active::Running"
8071 );
8072 }
8073
8074 #[test]
8075 fn real_emoji_node_still_round_trips() {
8076 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8078 {"type":"text","text":"Hello "},
8079 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8080 {"type":"text","text":" world"}
8081 ]}]}"#;
8082 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8083
8084 let md = adf_to_markdown(&doc).unwrap();
8085 let round_tripped = markdown_to_adf(&md).unwrap();
8086 let content = round_tripped.content[0].content.as_ref().unwrap();
8087
8088 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8090 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8091 assert_eq!(content[1].node_type, "emoji");
8092 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8093 assert_eq!(content[2].text.as_deref(), Some(" world"));
8094 }
8095
8096 #[test]
8097 fn combined_emoji_shortname_round_trips_as_single_node() {
8098 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8103 {"type":"text","text":"Thanks for the help "},
8104 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8105 ]}]}"#;
8106 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8107
8108 let md = adf_to_markdown(&doc).unwrap();
8109 let round_tripped = markdown_to_adf(&md).unwrap();
8110 let content = round_tripped.content[0].content.as_ref().unwrap();
8111
8112 assert_eq!(
8113 content.len(),
8114 2,
8115 "should have text + single combined emoji: {content:?}"
8116 );
8117 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8118 assert_eq!(content[1].node_type, "emoji");
8119 let attrs = content[1].attrs.as_ref().unwrap();
8120 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8121 assert_eq!(attrs["id"], "");
8122 assert_eq!(attrs["text"], "");
8123 }
8124
8125 #[test]
8126 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8127 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8129 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8130 ]}]}"#;
8131 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8132
8133 let md = adf_to_markdown(&doc).unwrap();
8134 let round_tripped = markdown_to_adf(&md).unwrap();
8135 let content = round_tripped.content[0].content.as_ref().unwrap();
8136
8137 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8138 assert_eq!(content[0].node_type, "emoji");
8139 let attrs = content[0].attrs.as_ref().unwrap();
8140 assert_eq!(attrs["shortName"], ":a::b::c:");
8141 assert_eq!(attrs["id"], "x");
8142 }
8143
8144 #[test]
8145 fn consecutive_emojis_remain_separate_nodes() {
8146 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8150 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8151 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8152 ]}]}"#;
8153 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8154
8155 let md = adf_to_markdown(&doc).unwrap();
8156 let round_tripped = markdown_to_adf(&md).unwrap();
8157 let content = round_tripped.content[0].content.as_ref().unwrap();
8158
8159 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8160 assert_eq!(content[0].node_type, "emoji");
8161 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8162 assert_eq!(content[1].node_type, "emoji");
8163 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8164 }
8165
8166 #[test]
8167 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8168 let md = ":fire::water:";
8171 let doc = markdown_to_adf(md).unwrap();
8172 let content = doc.content[0].content.as_ref().unwrap();
8173
8174 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8175 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8176 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8177 }
8178
8179 #[test]
8180 fn combined_emoji_shortname_preserves_local_id() {
8181 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8184 let doc = markdown_to_adf(md).unwrap();
8185 let content = doc.content[0].content.as_ref().unwrap();
8186
8187 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8188 let attrs = content[0].attrs.as_ref().unwrap();
8189 assert_eq!(attrs["shortName"], ":a::b:");
8190 assert_eq!(attrs["id"], "x");
8191 assert_eq!(attrs["text"], "y");
8192 assert_eq!(attrs["localId"], "abc");
8193 }
8194
8195 #[test]
8196 fn text_shortcode_with_marks_round_trips() {
8197 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8199 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8200 ]}]}"#;
8201 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8202
8203 let md = adf_to_markdown(&doc).unwrap();
8204 let round_tripped = markdown_to_adf(&md).unwrap();
8205 let content = round_tripped.content[0].content.as_ref().unwrap();
8206
8207 assert_eq!(
8208 content.len(),
8209 1,
8210 "should be single bold text node: {content:?}"
8211 );
8212 assert_eq!(content[0].node_type, "text");
8213 assert_eq!(
8214 content[0].text.as_deref().unwrap(),
8215 "Alert :fire: triggered"
8216 );
8217 assert!(content[0]
8218 .marks
8219 .as_ref()
8220 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8221 }
8222
8223 #[test]
8224 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8225 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8227 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8228 {"type":"text","text":" says :hello: to you"}
8229 ]}]}"#;
8230 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8231
8232 let md = adf_to_markdown(&doc).unwrap();
8233 let round_tripped = markdown_to_adf(&md).unwrap();
8234 let content = round_tripped.content[0].content.as_ref().unwrap();
8235
8236 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8238 assert_eq!(content[0].node_type, "emoji");
8239 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8240 assert_eq!(content[1].node_type, "text");
8241 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8242 }
8243
8244 #[test]
8245 fn code_block_with_shortcode_pattern_round_trips() {
8246 let adf_json = r#"{"version":1,"type":"doc","content":[
8249 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8250 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8251 ]}
8252 ]}"#;
8253 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8254
8255 let md = adf_to_markdown(&doc).unwrap();
8256 let round_tripped = markdown_to_adf(&md).unwrap();
8257
8258 assert_eq!(
8259 round_tripped.content.len(),
8260 1,
8261 "should be a single codeBlock"
8262 );
8263 let cb = &round_tripped.content[0];
8264 assert_eq!(cb.node_type, "codeBlock");
8265 let content = cb.content.as_ref().expect("codeBlock content");
8266 assert_eq!(
8267 content.len(),
8268 1,
8269 "should be a single text node: {content:?}"
8270 );
8271 assert_eq!(content[0].node_type, "text");
8272 assert_eq!(
8273 content[0].text.as_deref().unwrap(),
8274 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8275 );
8276 assert!(
8277 content.iter().all(|n| n.node_type != "emoji"),
8278 "no emoji nodes should be present, got: {content:?}"
8279 );
8280 }
8281
8282 #[test]
8283 fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8284 let adf_json = r#"{"version":1,"type":"doc","content":[
8286 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8287 {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8288 ]}
8289 ]}"#;
8290 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8291
8292 let md = adf_to_markdown(&doc).unwrap();
8293 let round_tripped = markdown_to_adf(&md).unwrap();
8294
8295 let cb = &round_tripped.content[0];
8296 assert_eq!(cb.node_type, "codeBlock");
8297 let content = cb.content.as_ref().expect("codeBlock content");
8298 assert_eq!(content.len(), 1, "should be a single text node");
8299 assert_eq!(
8300 content[0].text.as_deref().unwrap(),
8301 "class ZBC::Zendesk::PlanType::Professional < Base"
8302 );
8303 }
8304
8305 #[test]
8306 fn code_block_with_literal_shortcode_round_trips() {
8307 let adf_json = r#"{"version":1,"type":"doc","content":[
8310 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8311 {"type":"text","text":":fire: :wave: :thumbsup:"}
8312 ]}
8313 ]}"#;
8314 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8315
8316 let md = adf_to_markdown(&doc).unwrap();
8317 let round_tripped = markdown_to_adf(&md).unwrap();
8318
8319 let cb = &round_tripped.content[0];
8320 assert_eq!(cb.node_type, "codeBlock");
8321 let content = cb.content.as_ref().expect("codeBlock content");
8322 assert_eq!(
8323 content.len(),
8324 1,
8325 "should be a single text node: {content:?}"
8326 );
8327 assert_eq!(content[0].node_type, "text");
8328 assert_eq!(
8329 content[0].text.as_deref().unwrap(),
8330 ":fire: :wave: :thumbsup:"
8331 );
8332 }
8333
8334 #[test]
8335 fn inline_code_with_shortcode_pattern_round_trips() {
8336 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8339 {"type":"text","text":"See "},
8340 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8341 {"type":"text","text":" for details"}
8342 ]}]}"#;
8343 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8344
8345 let md = adf_to_markdown(&doc).unwrap();
8346 let round_tripped = markdown_to_adf(&md).unwrap();
8347 let content = round_tripped.content[0].content.as_ref().unwrap();
8348
8349 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8350 assert_eq!(content[0].text.as_deref(), Some("See "));
8351 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8352 assert!(content[1]
8353 .marks
8354 .as_ref()
8355 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8356 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8357 assert!(
8358 content.iter().all(|n| n.node_type != "emoji"),
8359 "no emoji nodes should be present"
8360 );
8361 }
8362
8363 #[test]
8364 fn inline_code_with_literal_shortcode_round_trips() {
8365 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8368 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8369 ]}]}"#;
8370 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8371
8372 let md = adf_to_markdown(&doc).unwrap();
8373 let round_tripped = markdown_to_adf(&md).unwrap();
8374 let content = round_tripped.content[0].content.as_ref().unwrap();
8375
8376 assert_eq!(
8377 content.len(),
8378 1,
8379 "should be a single code node: {content:?}"
8380 );
8381 assert_eq!(content[0].node_type, "text");
8382 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8383 assert!(content[0]
8384 .marks
8385 .as_ref()
8386 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8387 }
8388
8389 #[test]
8390 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8391 let adf_json = r#"{"version":1,"type":"doc","content":[
8394 {"type":"bulletList","content":[
8395 {"type":"listItem","content":[
8396 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8397 {"type":"text","text":"Foo::Bar::Baz"}
8398 ]}
8399 ]}
8400 ]}
8401 ]}"#;
8402 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8403
8404 let md = adf_to_markdown(&doc).unwrap();
8405 let round_tripped = markdown_to_adf(&md).unwrap();
8406
8407 let list = &round_tripped.content[0];
8408 assert_eq!(list.node_type, "bulletList");
8409 let item = &list.content.as_ref().unwrap()[0];
8410 assert_eq!(item.node_type, "listItem");
8411 let cb = &item.content.as_ref().unwrap()[0];
8412 assert_eq!(cb.node_type, "codeBlock");
8413 let cb_content = cb.content.as_ref().unwrap();
8414 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8415 assert_eq!(cb_content[0].node_type, "text");
8416 }
8417
8418 #[test]
8419 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8420 let adf_json = r#"{"version":1,"type":"doc","content":[
8424 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8425 {"type":"text","text":"class ZBC::配置::Production < Base"}
8426 ]}
8427 ]}"#;
8428 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8429
8430 let md = adf_to_markdown(&doc).unwrap();
8431 let round_tripped = markdown_to_adf(&md).unwrap();
8432
8433 let cb = &round_tripped.content[0];
8434 assert_eq!(cb.node_type, "codeBlock");
8435 let content = cb.content.as_ref().expect("codeBlock content");
8436 assert_eq!(content.len(), 1);
8437 assert_eq!(
8438 content[0].text.as_deref().unwrap(),
8439 "class ZBC::配置::Production < Base"
8440 );
8441 }
8442
8443 #[test]
8444 fn list_item_hardbreak_then_code_block_round_trips() {
8445 let adf_json = r#"{"version":1,"type":"doc","content":[
8451 {"type":"bulletList","content":[
8452 {"type":"listItem","content":[
8453 {"type":"paragraph","content":[
8454 {"type":"text","text":"Consider removing this check."},
8455 {"type":"hardBreak"}
8456 ]},
8457 {"type":"codeBlock","content":[
8458 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8459 ]}
8460 ]}
8461 ]}
8462 ]}"#;
8463 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8464
8465 let md = adf_to_markdown(&doc).unwrap();
8466 let round_tripped = markdown_to_adf(&md).unwrap();
8467
8468 let list = &round_tripped.content[0];
8469 assert_eq!(list.node_type, "bulletList");
8470 let item = &list.content.as_ref().unwrap()[0];
8471 assert_eq!(item.node_type, "listItem");
8472 let item_content = item.content.as_ref().unwrap();
8473 assert_eq!(
8474 item_content.len(),
8475 2,
8476 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8477 );
8478 assert_eq!(item_content[0].node_type, "paragraph");
8479 assert_eq!(item_content[1].node_type, "codeBlock");
8480
8481 let cb_content = item_content[1].content.as_ref().unwrap();
8483 assert_eq!(cb_content[0].node_type, "text");
8484 assert_eq!(
8485 cb_content[0].text.as_deref().unwrap(),
8486 "x = Foo::Bar::Baz.new"
8487 );
8488
8489 assert!(
8491 item_content
8492 .iter()
8493 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8494 .all(|n| n.node_type != "emoji"),
8495 "no emoji nodes should be present, got: {item_content:?}"
8496 );
8497 }
8498
8499 #[test]
8500 fn list_item_hardbreak_then_nested_list_still_works() {
8501 let md = "- first\\\n continuation text\\\n - nested item\n";
8505 let doc = markdown_to_adf(md).unwrap();
8506 let list = &doc.content[0];
8507 assert_eq!(list.node_type, "bulletList");
8508 let item = &list.content.as_ref().unwrap()[0];
8509 let item_content = item.content.as_ref().unwrap();
8511 let para = &item_content[0];
8512 assert_eq!(para.node_type, "paragraph");
8513 let para_nodes = para.content.as_ref().unwrap();
8514 assert!(
8515 para_nodes
8516 .iter()
8517 .any(|n| n.text.as_deref() == Some("continuation text")),
8518 "continuation text should survive: {para_nodes:?}"
8519 );
8520 }
8521
8522 #[test]
8523 fn list_item_hardbreak_then_image_still_works() {
8524 let md = "- first\\\n {type=file id=x}\n";
8529 let doc = markdown_to_adf(md).unwrap();
8530 let list = &doc.content[0];
8531 let item = &list.content.as_ref().unwrap()[0];
8532 let item_content = item.content.as_ref().unwrap();
8533 assert!(
8535 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8536 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8537 );
8538 }
8539
8540 #[test]
8541 fn list_item_hardbreak_then_container_directive_round_trips() {
8542 let adf_json = r#"{"version":1,"type":"doc","content":[
8546 {"type":"bulletList","content":[
8547 {"type":"listItem","content":[
8548 {"type":"paragraph","content":[
8549 {"type":"text","text":"intro"},
8550 {"type":"hardBreak"}
8551 ]},
8552 {"type":"panel","attrs":{"panelType":"info"},"content":[
8553 {"type":"paragraph","content":[
8554 {"type":"text","text":"panel body"}
8555 ]}
8556 ]}
8557 ]}
8558 ]}
8559 ]}"#;
8560 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8561 let md = adf_to_markdown(&doc).unwrap();
8562 let round_tripped = markdown_to_adf(&md).unwrap();
8563
8564 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8565 let item_content = item.content.as_ref().unwrap();
8566 assert!(
8567 item_content.iter().any(|n| n.node_type == "panel"),
8568 "panel should survive as block-level sibling, got: {item_content:?}"
8569 );
8570 }
8571
8572 #[test]
8573 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8574 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8577 {"type":"text","text":"See "},
8578 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8579 {"type":"text","text":" for prod"}
8580 ]}]}"#;
8581 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8582
8583 let md = adf_to_markdown(&doc).unwrap();
8584 let round_tripped = markdown_to_adf(&md).unwrap();
8585 let content = round_tripped.content[0].content.as_ref().unwrap();
8586
8587 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8588 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8589 assert!(content[1]
8590 .marks
8591 .as_ref()
8592 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8593 }
8594
8595 #[test]
8596 fn code_block_followed_by_shortcode_text_round_trips() {
8597 let adf_json = r#"{"version":1,"type":"doc","content":[
8600 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8601 {"type":"text","text":"Foo::Bar::Baz"}
8602 ]},
8603 {"type":"paragraph","content":[
8604 {"type":"text","text":"Status::Active::Running"}
8605 ]}
8606 ]}"#;
8607 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8608
8609 let md = adf_to_markdown(&doc).unwrap();
8610 let round_tripped = markdown_to_adf(&md).unwrap();
8611
8612 assert_eq!(round_tripped.content.len(), 2);
8613 let cb = &round_tripped.content[0];
8614 assert_eq!(cb.node_type, "codeBlock");
8615 let cb_content = cb.content.as_ref().unwrap();
8616 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8617
8618 let para = &round_tripped.content[1];
8619 assert_eq!(para.node_type, "paragraph");
8620 let para_content = para.content.as_ref().unwrap();
8621 assert_eq!(para_content.len(), 1);
8622 assert_eq!(para_content[0].node_type, "text");
8623 assert_eq!(
8624 para_content[0].text.as_deref(),
8625 Some("Status::Active::Running")
8626 );
8627 }
8628
8629 #[test]
8630 fn adf_inline_card_to_markdown() {
8631 let doc = AdfDocument {
8632 version: 1,
8633 doc_type: "doc".to_string(),
8634 content: vec![AdfNode::paragraph(vec![AdfNode {
8635 node_type: "inlineCard".to_string(),
8636 attrs: Some(
8637 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8638 ),
8639 content: None,
8640 text: None,
8641 marks: None,
8642 local_id: None,
8643 parameters: None,
8644 }])],
8645 };
8646 let md = adf_to_markdown(&doc).unwrap();
8647 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8648 assert!(!md.contains("<!-- unsupported inline"));
8649 }
8650
8651 #[test]
8652 fn inline_card_directive_round_trips() {
8653 let original = AdfDocument {
8655 version: 1,
8656 doc_type: "doc".to_string(),
8657 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8658 "https://org.atlassian.net/browse/ACCS-4382",
8659 )])],
8660 };
8661 let md = adf_to_markdown(&original).unwrap();
8662 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8663 let restored = markdown_to_adf(&md).unwrap();
8664 let node = &restored.content[0].content.as_ref().unwrap()[0];
8665 assert_eq!(node.node_type, "inlineCard");
8666 assert_eq!(
8667 node.attrs.as_ref().unwrap()["url"],
8668 "https://org.atlassian.net/browse/ACCS-4382"
8669 );
8670 }
8671
8672 #[test]
8673 fn inline_card_directive_parsed_from_jfm() {
8674 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8676 let nodes = doc.content[0].content.as_ref().unwrap();
8677 assert_eq!(nodes[0].node_type, "text");
8678 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8679 assert_eq!(nodes[1].node_type, "inlineCard");
8680 assert_eq!(
8681 nodes[1].attrs.as_ref().unwrap()["url"],
8682 "https://example.com/issue/123"
8683 );
8684 assert_eq!(nodes[2].node_type, "text");
8685 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8686 }
8687
8688 #[test]
8689 fn self_link_becomes_link_mark_not_inline_card() {
8690 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8693 let node = &doc.content[0].content.as_ref().unwrap()[0];
8694 assert_eq!(node.node_type, "text");
8695 assert_eq!(node.text.as_deref(), Some("https://example.com"));
8696 let mark = &node.marks.as_ref().unwrap()[0];
8697 assert_eq!(mark.mark_type, "link");
8698 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8699 }
8700
8701 #[test]
8702 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8703 let doc =
8706 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8707 let node = &doc.content[0].content.as_ref().unwrap()[0];
8708 assert_eq!(node.node_type, "text");
8709 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8710 let mark = &node.marks.as_ref().unwrap()[0];
8711 assert_eq!(mark.mark_type, "link");
8712 assert_eq!(
8713 mark.attrs.as_ref().unwrap()["href"],
8714 "https://octopz.example.com/"
8715 );
8716 }
8717
8718 #[test]
8719 fn named_link_does_not_become_inline_card() {
8720 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8722 let node = &doc.content[0].content.as_ref().unwrap()[0];
8723 assert_eq!(node.node_type, "text");
8724 assert_eq!(node.text.as_deref(), Some("#4668"));
8725 let mark = &node.marks.as_ref().unwrap()[0];
8726 assert_eq!(mark.mark_type, "link");
8727 }
8728
8729 #[test]
8730 fn adf_inline_card_no_url_to_markdown() {
8731 let doc = AdfDocument {
8732 version: 1,
8733 doc_type: "doc".to_string(),
8734 content: vec![AdfNode::paragraph(vec![AdfNode {
8735 node_type: "inlineCard".to_string(),
8736 attrs: Some(serde_json::json!({})),
8737 content: None,
8738 text: None,
8739 marks: None,
8740 local_id: None,
8741 parameters: None,
8742 }])],
8743 };
8744 let md = adf_to_markdown(&doc).unwrap();
8745 assert!(!md.contains("<!-- unsupported inline"));
8747 }
8748
8749 #[test]
8750 fn adf_code_block_no_language_to_markdown() {
8751 let doc = AdfDocument {
8752 version: 1,
8753 doc_type: "doc".to_string(),
8754 content: vec![AdfNode::code_block(None, "plain code")],
8755 };
8756 let md = adf_to_markdown(&doc).unwrap();
8757 assert!(md.contains("```\n"));
8758 assert!(md.contains("plain code"));
8759 }
8760
8761 #[test]
8762 fn adf_code_block_empty_language_to_markdown() {
8763 let doc = AdfDocument {
8764 version: 1,
8765 doc_type: "doc".to_string(),
8766 content: vec![AdfNode::code_block(Some(""), "plain code")],
8767 };
8768 let md = adf_to_markdown(&doc).unwrap();
8769 assert!(md.contains("```\"\"\n"));
8770 assert!(md.contains("plain code"));
8771 }
8772
8773 #[test]
8776 fn round_trip_table() {
8777 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8778 let adf = markdown_to_adf(md).unwrap();
8779 let restored = adf_to_markdown(&adf).unwrap();
8780 assert!(restored.contains("| A | B |"));
8781 assert!(restored.contains("| 1 | 2 |"));
8782 }
8783
8784 #[test]
8785 fn round_trip_blockquote() {
8786 let md = "> This is quoted\n";
8787 let adf = markdown_to_adf(md).unwrap();
8788 let restored = adf_to_markdown(&adf).unwrap();
8789 assert!(restored.contains("> This is quoted"));
8790 }
8791
8792 #[test]
8793 fn round_trip_image() {
8794 let md = "\n";
8795 let adf = markdown_to_adf(md).unwrap();
8796 let restored = adf_to_markdown(&adf).unwrap();
8797 assert!(restored.contains(""));
8798 }
8799
8800 #[test]
8801 fn round_trip_ordered_list() {
8802 let md = "1. A\n2. B\n3. C\n";
8803 let adf = markdown_to_adf(md).unwrap();
8804 let restored = adf_to_markdown(&adf).unwrap();
8805 assert!(restored.contains("1. A"));
8806 assert!(restored.contains("2. B"));
8807 assert!(restored.contains("3. C"));
8808 }
8809
8810 #[test]
8811 fn round_trip_inline_marks() {
8812 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8813 let adf = markdown_to_adf(md).unwrap();
8814 let restored = adf_to_markdown(&adf).unwrap();
8815 assert!(restored.contains("`code`"));
8816 assert!(restored.contains("~~strike~~"));
8817 assert!(restored.contains("[link](https://x.com)"));
8818 }
8819
8820 #[test]
8823 fn panel_info() {
8824 let md = ":::panel{type=info}\nThis is informational.\n:::";
8825 let doc = markdown_to_adf(md).unwrap();
8826 assert_eq!(doc.content[0].node_type, "panel");
8827 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8828 let inner = doc.content[0].content.as_ref().unwrap();
8829 assert_eq!(inner[0].node_type, "paragraph");
8830 }
8831
8832 #[test]
8833 fn adf_panel_to_markdown() {
8834 let doc = AdfDocument {
8835 version: 1,
8836 doc_type: "doc".to_string(),
8837 content: vec![AdfNode::panel(
8838 "warning",
8839 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8840 )],
8841 };
8842 let md = adf_to_markdown(&doc).unwrap();
8843 assert!(md.contains(":::panel{type=warning}"));
8844 assert!(md.contains("Be careful."));
8845 assert!(md.contains(":::"));
8846 }
8847
8848 #[test]
8849 fn round_trip_panel() {
8850 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8851 let doc = markdown_to_adf(md).unwrap();
8852 let result = adf_to_markdown(&doc).unwrap();
8853 assert!(result.contains(":::panel{type=info}"));
8854 assert!(result.contains("This is informational."));
8855 }
8856
8857 #[test]
8858 fn expand_with_title() {
8859 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8860 let doc = markdown_to_adf(md).unwrap();
8861 assert_eq!(doc.content[0].node_type, "expand");
8862 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8863 }
8864
8865 #[test]
8866 fn adf_expand_to_markdown() {
8867 let doc = AdfDocument {
8868 version: 1,
8869 doc_type: "doc".to_string(),
8870 content: vec![AdfNode::expand(
8871 Some("Details"),
8872 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8873 )],
8874 };
8875 let md = adf_to_markdown(&doc).unwrap();
8876 assert!(md.contains(":::expand{title=\"Details\"}"));
8877 assert!(md.contains("Inner."));
8878 }
8879
8880 #[test]
8881 fn round_trip_expand() {
8882 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8883 let doc = markdown_to_adf(md).unwrap();
8884 let result = adf_to_markdown(&doc).unwrap();
8885 assert!(result.contains(":::expand{title=\"Details\"}"));
8886 assert!(result.contains("Inner content."));
8887 }
8888
8889 #[test]
8890 fn layout_two_columns() {
8891 let md =
8892 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8893 let doc = markdown_to_adf(md).unwrap();
8894 assert_eq!(doc.content[0].node_type, "layoutSection");
8895 let columns = doc.content[0].content.as_ref().unwrap();
8896 assert_eq!(columns.len(), 2);
8897 assert_eq!(columns[0].node_type, "layoutColumn");
8898 assert_eq!(columns[1].node_type, "layoutColumn");
8899 }
8900
8901 #[test]
8902 fn adf_layout_to_markdown() {
8903 let doc = AdfDocument {
8904 version: 1,
8905 doc_type: "doc".to_string(),
8906 content: vec![AdfNode::layout_section(vec![
8907 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
8908 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
8909 ])],
8910 };
8911 let md = adf_to_markdown(&doc).unwrap();
8912 assert!(md.contains("::::layout"));
8913 assert!(md.contains(":::column{width=50}"));
8914 assert!(md.contains("Left."));
8915 assert!(md.contains("Right."));
8916 }
8917
8918 #[test]
8919 fn layout_column_localid_roundtrip() {
8920 let adf_json = r#"{
8921 "version": 1,
8922 "type": "doc",
8923 "content": [{
8924 "type": "layoutSection",
8925 "content": [
8926 {
8927 "type": "layoutColumn",
8928 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8929 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
8930 },
8931 {
8932 "type": "layoutColumn",
8933 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
8934 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
8935 }
8936 ]
8937 }]
8938 }"#;
8939 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8940 let md = adf_to_markdown(&doc).unwrap();
8941 assert!(
8942 md.contains("localId=aabb112233cc"),
8943 "first column localId should appear in markdown: {md}"
8944 );
8945 assert!(
8946 md.contains("localId=ddeeff445566"),
8947 "second column localId should appear in markdown: {md}"
8948 );
8949 let rt = markdown_to_adf(&md).unwrap();
8950 let cols = rt.content[0].content.as_ref().unwrap();
8951 assert_eq!(
8952 cols[0].attrs.as_ref().unwrap()["localId"],
8953 "aabb112233cc",
8954 "first column localId should round-trip"
8955 );
8956 assert_eq!(
8957 cols[1].attrs.as_ref().unwrap()["localId"],
8958 "ddeeff445566",
8959 "second column localId should round-trip"
8960 );
8961 }
8962
8963 #[test]
8964 fn layout_column_without_localid() {
8965 let md =
8966 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8967 let doc = markdown_to_adf(md).unwrap();
8968 let cols = doc.content[0].content.as_ref().unwrap();
8969 assert!(
8970 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
8971 "column without localId should not gain one"
8972 );
8973 let md2 = adf_to_markdown(&doc).unwrap();
8974 assert!(
8975 !md2.contains("localId"),
8976 "no localId should appear in output: {md2}"
8977 );
8978 }
8979
8980 #[test]
8981 fn layout_column_localid_stripped_when_option_set() {
8982 let adf_json = r#"{
8983 "version": 1,
8984 "type": "doc",
8985 "content": [{
8986 "type": "layoutSection",
8987 "content": [{
8988 "type": "layoutColumn",
8989 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8990 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
8991 }]
8992 }]
8993 }"#;
8994 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8995 let opts = RenderOptions {
8996 strip_local_ids: true,
8997 ..Default::default()
8998 };
8999 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9000 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9001 }
9002
9003 #[test]
9004 fn layout_column_localid_flush_previous() {
9005 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9007 let doc = markdown_to_adf(md).unwrap();
9008 let cols = doc.content[0].content.as_ref().unwrap();
9009 assert_eq!(
9010 cols[0].attrs.as_ref().unwrap()["localId"],
9011 "aabb112233cc",
9012 "flush-previous column should preserve localId"
9013 );
9014 assert_eq!(
9015 cols[1].attrs.as_ref().unwrap()["localId"],
9016 "ddeeff445566",
9017 "second column localId should be preserved"
9018 );
9019 }
9020
9021 #[test]
9022 fn layout_column_localid_flush_last() {
9023 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9025 let doc = markdown_to_adf(md).unwrap();
9026 let cols = doc.content[0].content.as_ref().unwrap();
9027 assert_eq!(
9028 cols[0].attrs.as_ref().unwrap()["localId"],
9029 "aabb112233cc",
9030 "flush-last column should preserve localId"
9031 );
9032 }
9033
9034 #[test]
9036 fn issue_555_layout_column_fractional_width_roundtrip() {
9037 let adf_json = r#"{
9038 "version": 1,
9039 "type": "doc",
9040 "content": [{
9041 "type": "layoutSection",
9042 "content": [
9043 {
9044 "type": "layoutColumn",
9045 "attrs": {"width": 66.66},
9046 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9047 },
9048 {
9049 "type": "layoutColumn",
9050 "attrs": {"width": 33.34},
9051 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9052 }
9053 ]
9054 }]
9055 }"#;
9056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9057 let md = adf_to_markdown(&doc).unwrap();
9058 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9059 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9060 let rt = markdown_to_adf(&md).unwrap();
9061 let cols = rt.content[0].content.as_ref().unwrap();
9062 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9063 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9064 }
9065
9066 #[test]
9068 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9069 let adf_json = r#"{
9070 "version": 1,
9071 "type": "doc",
9072 "content": [{
9073 "type": "layoutSection",
9074 "content": [
9075 {
9076 "type": "layoutColumn",
9077 "attrs": {"width": 83.33},
9078 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9079 },
9080 {
9081 "type": "layoutColumn",
9082 "attrs": {"width": 16.67},
9083 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9084 }
9085 ]
9086 }]
9087 }"#;
9088 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9089 let md = adf_to_markdown(&doc).unwrap();
9090 let rt = markdown_to_adf(&md).unwrap();
9091 let cols = rt.content[0].content.as_ref().unwrap();
9092 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9093 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9094 }
9095
9096 #[test]
9098 fn issue_555_layout_column_integer_width_preserved() {
9099 let adf_json = r#"{
9100 "version": 1,
9101 "type": "doc",
9102 "content": [{
9103 "type": "layoutSection",
9104 "content": [
9105 {
9106 "type": "layoutColumn",
9107 "attrs": {"width": 50},
9108 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9109 },
9110 {
9111 "type": "layoutColumn",
9112 "attrs": {"width": 50},
9113 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9114 }
9115 ]
9116 }]
9117 }"#;
9118 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9119 let md = adf_to_markdown(&doc).unwrap();
9120 assert!(
9121 md.contains("width=50") && !md.contains("width=50."),
9122 "integer width should render without decimal: {md}"
9123 );
9124 let rt = markdown_to_adf(&md).unwrap();
9125 let cols = rt.content[0].content.as_ref().unwrap();
9126 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9127 assert!(
9128 w0.is_i64() || w0.is_u64(),
9129 "width should remain a JSON integer, got: {w0}"
9130 );
9131 assert_eq!(w0.as_i64(), Some(50));
9132 }
9133
9134 #[test]
9136 fn issue_555_media_single_integer_width_preserved() {
9137 let adf_json = r#"{
9138 "version": 1,
9139 "type": "doc",
9140 "content": [{
9141 "type": "mediaSingle",
9142 "attrs": {"layout": "center", "width": 800},
9143 "content": [
9144 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9145 ]
9146 }]
9147 }"#;
9148 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9149 let md = adf_to_markdown(&doc).unwrap();
9150 assert!(
9151 md.contains("width=800") && !md.contains("width=800."),
9152 "integer width should render without decimal: {md}"
9153 );
9154 let rt = markdown_to_adf(&md).unwrap();
9155 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9156 let w = &ms_attrs["width"];
9157 assert!(
9158 w.is_i64() || w.is_u64(),
9159 "mediaSingle width should remain JSON integer, got: {w}"
9160 );
9161 assert_eq!(w.as_i64(), Some(800));
9162 }
9163
9164 #[test]
9168 fn issue_555_media_single_fractional_width_preserved() {
9169 let adf_json = r#"{
9170 "version": 1,
9171 "type": "doc",
9172 "content": [{
9173 "type": "mediaSingle",
9174 "attrs": {"layout": "center", "width": 66.5},
9175 "content": [
9176 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9177 ]
9178 }]
9179 }"#;
9180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9181 let md = adf_to_markdown(&doc).unwrap();
9182 assert!(
9183 md.contains("width=66.5"),
9184 "fractional width must appear in JFM: {md}"
9185 );
9186 let rt = markdown_to_adf(&md).unwrap();
9187 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9188 assert_eq!(ms_attrs["width"], 66.5);
9189 }
9190
9191 #[test]
9193 fn issue_555_media_single_float_width_preserved() {
9194 let adf_json = r#"{
9195 "version": 1,
9196 "type": "doc",
9197 "content": [{
9198 "type": "mediaSingle",
9199 "attrs": {"layout": "center", "width": 800.0},
9200 "content": [
9201 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9202 ]
9203 }]
9204 }"#;
9205 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9206 let md = adf_to_markdown(&doc).unwrap();
9207 assert!(
9208 md.contains("width=800.0"),
9209 "float width should render with decimal: {md}"
9210 );
9211 let rt = markdown_to_adf(&md).unwrap();
9212 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9213 let w = &ms_attrs["width"];
9214 assert!(
9215 w.is_f64(),
9216 "mediaSingle float width should stay a JSON float, got: {w}"
9217 );
9218 assert_eq!(w.as_f64(), Some(800.0));
9219 }
9220
9221 #[test]
9223 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9224 let adf_json = r#"{
9225 "version": 1,
9226 "type": "doc",
9227 "content": [{
9228 "type": "mediaSingle",
9229 "attrs": {"layout": "wide", "width": 1420},
9230 "content": [
9231 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9232 ]
9233 }]
9234 }"#;
9235 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9236 let md = adf_to_markdown(&doc).unwrap();
9237 let rt = markdown_to_adf(&md).unwrap();
9238 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9239 assert_eq!(ms_attrs["layout"], "wide");
9240 let w = &ms_attrs["width"];
9241 assert!(
9242 w.is_i64() || w.is_u64(),
9243 "mediaSingle width should remain JSON integer, got: {w}"
9244 );
9245 assert_eq!(w.as_i64(), Some(1420));
9246 }
9247
9248 #[test]
9251 fn issue_555_file_media_single_integer_width_preserved() {
9252 let adf_json = r#"{
9253 "version": 1,
9254 "type": "doc",
9255 "content": [{
9256 "type": "mediaSingle",
9257 "attrs": {"layout": "wide", "width": 1420},
9258 "content": [
9259 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9260 ]
9261 }]
9262 }"#;
9263 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9264 let md = adf_to_markdown(&doc).unwrap();
9265 let rt = markdown_to_adf(&md).unwrap();
9266 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9267 let ms_w = &ms_attrs["width"];
9268 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9269 assert_eq!(ms_w.as_i64(), Some(1420));
9270 let media = &rt.content[0].content.as_ref().unwrap()[0];
9271 let media_attrs = media.attrs.as_ref().unwrap();
9272 let mw = &media_attrs["width"];
9273 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9274 assert_eq!(mw.as_i64(), Some(1200));
9275 let mh = &media_attrs["height"];
9276 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9277 assert_eq!(mh.as_i64(), Some(800));
9278 }
9279
9280 #[test]
9282 fn issue_555_fmt_numeric_attr_preserves_type() {
9283 assert_eq!(
9284 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9285 Some("50")
9286 );
9287 assert_eq!(
9288 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9289 Some("50.0")
9290 );
9291 assert_eq!(
9292 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9293 Some("66.66")
9294 );
9295 assert_eq!(
9296 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9297 Some("-5")
9298 );
9299 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9300 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9302 assert_eq!(
9303 fmt_numeric_attr(&big).as_deref(),
9304 Some("18446744073709551615")
9305 );
9306 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9308 }
9309
9310 #[test]
9312 fn issue_555_parse_numeric_attr_detects_type() {
9313 let v = parse_numeric_attr("800").unwrap();
9314 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9315 assert_eq!(v.as_i64(), Some(800));
9316
9317 let v = parse_numeric_attr("800.0").unwrap();
9318 assert!(v.is_f64(), "'800.0' should parse as float");
9319 assert_eq!(v.as_f64(), Some(800.0));
9320
9321 let v = parse_numeric_attr("66.66").unwrap();
9322 assert!(v.is_f64());
9323 assert_eq!(v.as_f64(), Some(66.66));
9324
9325 let v = parse_numeric_attr("1e2").unwrap();
9327 assert!(v.is_f64());
9328 assert_eq!(v.as_f64(), Some(100.0));
9329 let v = parse_numeric_attr("1E2").unwrap();
9330 assert!(v.is_f64());
9331 assert_eq!(v.as_f64(), Some(100.0));
9332
9333 let v = parse_numeric_attr("-42").unwrap();
9335 assert!(v.is_i64());
9336 assert_eq!(v.as_i64(), Some(-42));
9337
9338 assert!(parse_numeric_attr("not-a-number").is_none());
9339 assert!(parse_numeric_attr("").is_none());
9340 assert!(parse_numeric_attr("1.2.3").is_none());
9341 }
9342
9343 #[test]
9346 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9347 let adf_json = r#"{
9348 "version": 1,
9349 "type": "doc",
9350 "content": [{
9351 "type": "mediaSingle",
9352 "attrs": {"layout": "wide", "width": 83.33},
9353 "content": [
9354 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9355 ]
9356 }]
9357 }"#;
9358 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9359 let md = adf_to_markdown(&doc).unwrap();
9360 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9361 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9362 let rt = markdown_to_adf(&md).unwrap();
9363 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9364 assert_eq!(ms_attrs["layout"], "wide");
9365 assert_eq!(ms_attrs["width"], 83.33);
9366 }
9367
9368 #[test]
9372 fn issue_555_file_media_single_fractional_media_width_preserved() {
9373 let adf_json = r#"{
9374 "version": 1,
9375 "type": "doc",
9376 "content": [{
9377 "type": "mediaSingle",
9378 "attrs": {"layout": "wide", "width": 66.5},
9379 "content": [
9380 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9381 ]
9382 }]
9383 }"#;
9384 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9385 let md = adf_to_markdown(&doc).unwrap();
9386 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9387 let rt = markdown_to_adf(&md).unwrap();
9388 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9389 assert_eq!(ms_attrs["width"], 66.5);
9390 }
9391
9392 #[test]
9396 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9397 let adf_json = r#"{
9398 "version": 1,
9399 "type": "doc",
9400 "content": [{
9401 "type": "mediaSingle",
9402 "attrs": {"layout": "center"},
9403 "content": [
9404 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9405 ]
9406 }]
9407 }"#;
9408 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9409 let md = adf_to_markdown(&doc).unwrap();
9410 assert!(md.contains("width=1200.5"), "width in md: {md}");
9411 assert!(md.contains("height=800.25"), "height in md: {md}");
9412 let rt = markdown_to_adf(&md).unwrap();
9413 let media = &rt.content[0].content.as_ref().unwrap()[0];
9414 let attrs = media.attrs.as_ref().unwrap();
9415 assert_eq!(attrs["width"], 1200.5);
9416 assert_eq!(attrs["height"], 800.25);
9417 }
9418
9419 #[test]
9420 fn decisions_list() {
9421 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9422 let doc = markdown_to_adf(md).unwrap();
9423 assert_eq!(doc.content[0].node_type, "decisionList");
9424 let items = doc.content[0].content.as_ref().unwrap();
9425 assert_eq!(items.len(), 2);
9426 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9427 }
9428
9429 #[test]
9430 fn adf_decisions_to_markdown() {
9431 let doc = AdfDocument {
9432 version: 1,
9433 doc_type: "doc".to_string(),
9434 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9435 "DECIDED",
9436 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9437 )])],
9438 };
9439 let md = adf_to_markdown(&doc).unwrap();
9440 assert!(md.contains(":::decisions"));
9441 assert!(md.contains("- <> Use PostgreSQL"));
9442 }
9443
9444 #[test]
9445 fn bodied_extension_container() {
9446 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9447 let doc = markdown_to_adf(md).unwrap();
9448 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9449 assert_eq!(
9450 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9451 "com.forge"
9452 );
9453 }
9454
9455 #[test]
9456 fn adf_bodied_extension_to_markdown() {
9457 let doc = AdfDocument {
9458 version: 1,
9459 doc_type: "doc".to_string(),
9460 content: vec![AdfNode::bodied_extension(
9461 "com.forge",
9462 "my-macro",
9463 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9464 )],
9465 };
9466 let md = adf_to_markdown(&doc).unwrap();
9467 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9468 assert!(md.contains("Content."));
9469 }
9470
9471 #[test]
9474 fn leaf_block_card() {
9475 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9476 assert_eq!(doc.content[0].node_type, "blockCard");
9477 assert_eq!(
9478 doc.content[0].attrs.as_ref().unwrap()["url"],
9479 "https://example.com/browse/PROJ-123"
9480 );
9481 }
9482
9483 #[test]
9484 fn adf_block_card_to_markdown() {
9485 let doc = AdfDocument {
9486 version: 1,
9487 doc_type: "doc".to_string(),
9488 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9489 };
9490 let md = adf_to_markdown(&doc).unwrap();
9491 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9492 }
9493
9494 #[test]
9495 fn round_trip_block_card() {
9496 let md = "::card[https://example.com/browse/PROJ-123]\n";
9497 let doc = markdown_to_adf(md).unwrap();
9498 let result = adf_to_markdown(&doc).unwrap();
9499 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9500 }
9501
9502 #[test]
9503 fn leaf_embed_card() {
9504 let doc =
9505 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9506 assert_eq!(doc.content[0].node_type, "embedCard");
9507 let attrs = doc.content[0].attrs.as_ref().unwrap();
9508 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9509 assert_eq!(attrs["layout"], "wide");
9510 assert_eq!(attrs["width"], 80.0);
9511 }
9512
9513 #[test]
9514 fn leaf_embed_card_with_original_height() {
9515 let doc = markdown_to_adf(
9516 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9517 )
9518 .unwrap();
9519 assert_eq!(doc.content[0].node_type, "embedCard");
9520 let attrs = doc.content[0].attrs.as_ref().unwrap();
9521 assert_eq!(attrs["url"], "https://example.com");
9522 assert_eq!(attrs["layout"], "center");
9523 assert_eq!(attrs["originalHeight"], 732.0);
9524 assert_eq!(attrs["width"], 100.0);
9525 }
9526
9527 #[test]
9528 fn adf_embed_card_to_markdown() {
9529 let doc = AdfDocument {
9530 version: 1,
9531 doc_type: "doc".to_string(),
9532 content: vec![AdfNode::embed_card(
9533 "https://figma.com/file/abc",
9534 Some("wide"),
9535 None,
9536 Some(80.0),
9537 )],
9538 };
9539 let md = adf_to_markdown(&doc).unwrap();
9540 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9541 }
9542
9543 #[test]
9544 fn adf_embed_card_original_height_to_markdown() {
9545 let doc = AdfDocument {
9546 version: 1,
9547 doc_type: "doc".to_string(),
9548 content: vec![AdfNode::embed_card(
9549 "https://example.com",
9550 Some("center"),
9551 Some(732.0),
9552 Some(100.0),
9553 )],
9554 };
9555 let md = adf_to_markdown(&doc).unwrap();
9556 assert!(
9557 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9558 "expected originalHeight and width in md: {md}"
9559 );
9560 }
9561
9562 #[test]
9563 fn embed_card_roundtrip_with_all_attrs() {
9564 let adf_json = r#"{"version":1,"type":"doc","content":[{
9565 "type":"embedCard",
9566 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9567 }]}"#;
9568 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9569 let md = adf_to_markdown(&doc).unwrap();
9570 assert!(
9571 md.contains("originalHeight=732"),
9572 "originalHeight missing from md: {md}"
9573 );
9574 assert!(md.contains("width=100"), "width missing from md: {md}");
9575 let rt = markdown_to_adf(&md).unwrap();
9576 let attrs = rt.content[0].attrs.as_ref().unwrap();
9577 assert_eq!(attrs["originalHeight"], 732.0);
9578 assert_eq!(attrs["width"], 100.0);
9579 assert_eq!(attrs["layout"], "center");
9580 assert_eq!(attrs["url"], "https://example.com");
9581 }
9582
9583 #[test]
9584 fn embed_card_fractional_dimensions() {
9585 let doc = AdfDocument {
9586 version: 1,
9587 doc_type: "doc".to_string(),
9588 content: vec![AdfNode::embed_card(
9589 "https://example.com",
9590 Some("center"),
9591 Some(732.5),
9592 Some(99.9),
9593 )],
9594 };
9595 let md = adf_to_markdown(&doc).unwrap();
9596 assert!(
9597 md.contains("originalHeight=732.5"),
9598 "fractional originalHeight missing: {md}"
9599 );
9600 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9601 let rt = markdown_to_adf(&md).unwrap();
9602 let attrs = rt.content[0].attrs.as_ref().unwrap();
9603 assert_eq!(attrs["originalHeight"], 732.5);
9604 assert_eq!(attrs["width"], 99.9);
9605 }
9606
9607 #[test]
9608 fn embed_card_integer_width_in_json() {
9609 let adf_json = r#"{"version":1,"type":"doc","content":[{
9611 "type":"embedCard",
9612 "attrs":{"url":"https://example.com","width":100}
9613 }]}"#;
9614 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9615 let md = adf_to_markdown(&doc).unwrap();
9616 assert!(
9617 md.contains("width=100"),
9618 "integer width missing from md: {md}"
9619 );
9620 let rt = markdown_to_adf(&md).unwrap();
9621 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9622 }
9623
9624 #[test]
9625 fn embed_card_only_original_height() {
9626 let adf_json = r#"{"version":1,"type":"doc","content":[{
9628 "type":"embedCard",
9629 "attrs":{"url":"https://example.com","originalHeight":500.0}
9630 }]}"#;
9631 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9632 let md = adf_to_markdown(&doc).unwrap();
9633 assert!(
9634 md.contains("originalHeight=500"),
9635 "originalHeight missing: {md}"
9636 );
9637 assert!(!md.contains("width="), "width should not appear: {md}");
9638 let rt = markdown_to_adf(&md).unwrap();
9639 let attrs = rt.content[0].attrs.as_ref().unwrap();
9640 assert_eq!(attrs["originalHeight"], 500.0);
9641 assert!(attrs.get("width").is_none());
9642 }
9643
9644 #[test]
9645 fn leaf_void_extension() {
9646 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9647 assert_eq!(doc.content[0].node_type, "extension");
9648 assert_eq!(
9649 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9650 "com.atlassian.macro"
9651 );
9652 assert_eq!(
9653 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9654 "jira-chart"
9655 );
9656 }
9657
9658 #[test]
9659 fn adf_void_extension_to_markdown() {
9660 let doc = AdfDocument {
9661 version: 1,
9662 doc_type: "doc".to_string(),
9663 content: vec![AdfNode::extension(
9664 "com.atlassian.macro",
9665 "jira-chart",
9666 None,
9667 )],
9668 };
9669 let md = adf_to_markdown(&doc).unwrap();
9670 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9671 }
9672
9673 #[test]
9676 fn bare_url_autolink() {
9677 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9678 let content = doc.content[0].content.as_ref().unwrap();
9679 assert_eq!(content[0].text.as_deref(), Some("Visit "));
9680 assert_eq!(content[1].node_type, "inlineCard");
9681 assert_eq!(
9682 content[1].attrs.as_ref().unwrap()["url"],
9683 "https://example.com"
9684 );
9685 assert_eq!(content[2].text.as_deref(), Some(" today"));
9686 }
9687
9688 #[test]
9689 fn bare_url_strips_trailing_punctuation() {
9690 let doc = markdown_to_adf("See https://example.com.").unwrap();
9691 let content = doc.content[0].content.as_ref().unwrap();
9692 assert_eq!(
9693 content[1].attrs.as_ref().unwrap()["url"],
9694 "https://example.com"
9695 );
9696 }
9697
9698 #[test]
9699 fn bare_url_round_trip() {
9700 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9701 let md = adf_to_markdown(&doc).unwrap();
9702 assert!(md.contains(":card[https://example.com/path]"));
9703 }
9704
9705 #[test]
9708 fn plain_text_url_round_trips_as_text() {
9709 let adf_json = r#"{
9712 "version": 1,
9713 "type": "doc",
9714 "content": [{
9715 "type": "paragraph",
9716 "content": [
9717 {"type": "text", "text": "https://example.com/some/path/to/resource"}
9718 ]
9719 }]
9720 }"#;
9721 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9722 let jfm = adf_to_markdown(&adf).unwrap();
9723 let roundtripped = markdown_to_adf(&jfm).unwrap();
9724 let content = roundtripped.content[0].content.as_ref().unwrap();
9725 assert_eq!(content.len(), 1, "should be a single node");
9726 assert_eq!(content[0].node_type, "text");
9727 assert_eq!(
9728 content[0].text.as_deref(),
9729 Some("https://example.com/some/path/to/resource")
9730 );
9731 }
9732
9733 #[test]
9734 fn url_text_with_link_mark_round_trips_as_text_node() {
9735 let adf_json = r#"{
9739 "version": 1,
9740 "type": "doc",
9741 "content": [{
9742 "type": "paragraph",
9743 "content": [{
9744 "type": "text",
9745 "text": "https://octopz.example.com",
9746 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9747 }]
9748 }]
9749 }"#;
9750 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9751 let jfm = adf_to_markdown(&adf).unwrap();
9752 let roundtripped = markdown_to_adf(&jfm).unwrap();
9753 let content = roundtripped.content[0].content.as_ref().unwrap();
9754 assert_eq!(content.len(), 1, "should be a single node");
9755 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9756 assert_eq!(
9757 content[0].text.as_deref(),
9758 Some("https://octopz.example.com")
9759 );
9760 let mark = &content[0].marks.as_ref().unwrap()[0];
9761 assert_eq!(mark.mark_type, "link");
9762 assert_eq!(
9763 mark.attrs.as_ref().unwrap()["href"],
9764 "https://octopz.example.com/"
9765 );
9766 }
9767
9768 #[test]
9769 fn url_text_with_exact_link_mark_round_trips() {
9770 let adf_json = r#"{
9772 "version": 1,
9773 "type": "doc",
9774 "content": [{
9775 "type": "paragraph",
9776 "content": [{
9777 "type": "text",
9778 "text": "https://example.com/path",
9779 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9780 }]
9781 }]
9782 }"#;
9783 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9784 let jfm = adf_to_markdown(&adf).unwrap();
9785 let roundtripped = markdown_to_adf(&jfm).unwrap();
9786 let content = roundtripped.content[0].content.as_ref().unwrap();
9787 assert_eq!(content.len(), 1, "should be a single node");
9788 assert_eq!(content[0].node_type, "text");
9789 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9790 let mark = &content[0].marks.as_ref().unwrap()[0];
9791 assert_eq!(mark.mark_type, "link");
9792 }
9793
9794 #[test]
9795 fn plain_text_url_amid_text_round_trips() {
9796 let adf_json = r#"{
9798 "version": 1,
9799 "type": "doc",
9800 "content": [{
9801 "type": "paragraph",
9802 "content": [
9803 {"type": "text", "text": "see https://example.com for info"}
9804 ]
9805 }]
9806 }"#;
9807 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9808 let jfm = adf_to_markdown(&adf).unwrap();
9809 let roundtripped = markdown_to_adf(&jfm).unwrap();
9810 let content = roundtripped.content[0].content.as_ref().unwrap();
9811 assert_eq!(content.len(), 1);
9812 assert_eq!(content[0].node_type, "text");
9813 assert_eq!(
9814 content[0].text.as_deref(),
9815 Some("see https://example.com for info")
9816 );
9817 }
9818
9819 #[test]
9820 fn plain_text_multiple_urls_round_trips() {
9821 let adf_json = r#"{
9822 "version": 1,
9823 "type": "doc",
9824 "content": [{
9825 "type": "paragraph",
9826 "content": [
9827 {"type": "text", "text": "http://a.com and https://b.com"}
9828 ]
9829 }]
9830 }"#;
9831 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9832 let jfm = adf_to_markdown(&adf).unwrap();
9833 let roundtripped = markdown_to_adf(&jfm).unwrap();
9834 let content = roundtripped.content[0].content.as_ref().unwrap();
9835 assert_eq!(content.len(), 1);
9836 assert_eq!(content[0].node_type, "text");
9837 assert_eq!(
9838 content[0].text.as_deref(),
9839 Some("http://a.com and https://b.com")
9840 );
9841 }
9842
9843 #[test]
9844 fn plain_text_http_prefix_no_url_unchanged() {
9845 let adf_json = r#"{
9847 "version": 1,
9848 "type": "doc",
9849 "content": [{
9850 "type": "paragraph",
9851 "content": [
9852 {"type": "text", "text": "the http header is important"}
9853 ]
9854 }]
9855 }"#;
9856 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9857 let jfm = adf_to_markdown(&adf).unwrap();
9858 let roundtripped = markdown_to_adf(&jfm).unwrap();
9859 let content = roundtripped.content[0].content.as_ref().unwrap();
9860 assert_eq!(
9861 content[0].text.as_deref(),
9862 Some("the http header is important")
9863 );
9864 }
9865
9866 #[test]
9867 fn linked_url_text_round_trips() {
9868 let adf_json = r#"{
9872 "version": 1,
9873 "type": "doc",
9874 "content": [{
9875 "type": "paragraph",
9876 "content": [{
9877 "type": "text",
9878 "text": "https://example.com",
9879 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9880 }]
9881 }]
9882 }"#;
9883 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9884 let jfm = adf_to_markdown(&adf).unwrap();
9885 let roundtripped = markdown_to_adf(&jfm).unwrap();
9886 let content = roundtripped.content[0].content.as_ref().unwrap();
9887 assert_eq!(content.len(), 1);
9888 assert_eq!(content[0].node_type, "text");
9889 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9890 let mark = &content[0].marks.as_ref().unwrap()[0];
9891 assert_eq!(mark.mark_type, "link");
9892 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9893 }
9894
9895 #[test]
9898 fn escape_link_brackets_unit() {
9899 assert_eq!(escape_link_brackets("hello"), "hello");
9900 assert_eq!(escape_link_brackets("["), "\\[");
9901 assert_eq!(escape_link_brackets("]"), "\\]");
9902 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
9903 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
9904 }
9905
9906 #[test]
9907 fn bracket_text_with_link_mark_escapes_brackets() {
9908 let doc = AdfDocument {
9911 version: 1,
9912 doc_type: "doc".to_string(),
9913 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9914 "[",
9915 vec![AdfMark::link("https://example.com")],
9916 )])],
9917 };
9918 let md = adf_to_markdown(&doc).unwrap();
9919 assert_eq!(md.trim(), "[\\[](https://example.com)");
9920 }
9921
9922 #[test]
9923 fn bracket_text_with_link_mark_round_trips() {
9924 let adf_json = r#"{
9927 "type": "doc",
9928 "version": 1,
9929 "content": [{
9930 "type": "paragraph",
9931 "content": [
9932 {
9933 "type": "text",
9934 "text": "[",
9935 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
9936 },
9937 {
9938 "type": "text",
9939 "text": "PROJ-456] Fix the auth bug",
9940 "marks": [
9941 {"type": "underline"},
9942 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
9943 ]
9944 }
9945 ]
9946 }]
9947 }"#;
9948 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9949 let jfm = adf_to_markdown(&adf).unwrap();
9950
9951 assert!(jfm.contains("\\["), "opening bracket should be escaped");
9953
9954 let rt = markdown_to_adf(&jfm).unwrap();
9956 let content = rt.content[0].content.as_ref().unwrap();
9957
9958 let link_nodes: Vec<_> = content
9960 .iter()
9961 .filter(|n| {
9962 n.marks
9963 .as_ref()
9964 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
9965 })
9966 .collect();
9967 assert!(
9968 !link_nodes.is_empty(),
9969 "link mark must be preserved on round-trip"
9970 );
9971
9972 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
9974 assert!(
9975 all_text.contains('['),
9976 "literal '[' must survive round-trip"
9977 );
9978 assert!(
9979 all_text.contains("PROJ-456]"),
9980 "continuation text must survive round-trip"
9981 );
9982 }
9983
9984 #[test]
9985 fn closing_bracket_in_link_text_round_trips() {
9986 let doc = AdfDocument {
9989 version: 1,
9990 doc_type: "doc".to_string(),
9991 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9992 "item]",
9993 vec![AdfMark::link("https://example.com")],
9994 )])],
9995 };
9996 let md = adf_to_markdown(&doc).unwrap();
9997 assert_eq!(md.trim(), "[item\\]](https://example.com)");
9998
9999 let rt = markdown_to_adf(&md).unwrap();
10000 let content = rt.content[0].content.as_ref().unwrap();
10001 assert_eq!(content[0].text.as_deref(), Some("item]"));
10002 assert!(content[0]
10003 .marks
10004 .as_ref()
10005 .unwrap()
10006 .iter()
10007 .any(|m| m.mark_type == "link"));
10008 }
10009
10010 #[test]
10011 fn brackets_in_link_text_round_trip() {
10012 let doc = AdfDocument {
10014 version: 1,
10015 doc_type: "doc".to_string(),
10016 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10017 "[PROJ-123]",
10018 vec![AdfMark::link("https://example.com")],
10019 )])],
10020 };
10021 let md = adf_to_markdown(&doc).unwrap();
10022 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10023
10024 let rt = markdown_to_adf(&md).unwrap();
10025 let content = rt.content[0].content.as_ref().unwrap();
10026 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10027 assert!(content[0]
10028 .marks
10029 .as_ref()
10030 .unwrap()
10031 .iter()
10032 .any(|m| m.mark_type == "link"));
10033 }
10034
10035 #[test]
10036 fn plain_text_brackets_not_escaped() {
10037 let doc = AdfDocument {
10039 version: 1,
10040 doc_type: "doc".to_string(),
10041 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10042 "see [PROJ-123] for details",
10043 )])],
10044 };
10045 let md = adf_to_markdown(&doc).unwrap();
10046 assert_eq!(md.trim(), "see [PROJ-123] for details");
10047 }
10048
10049 #[test]
10050 fn link_with_no_brackets_unchanged() {
10051 let doc = AdfDocument {
10053 version: 1,
10054 doc_type: "doc".to_string(),
10055 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10056 "click here",
10057 vec![AdfMark::link("https://example.com")],
10058 )])],
10059 };
10060 let md = adf_to_markdown(&doc).unwrap();
10061 assert_eq!(md.trim(), "[click here](https://example.com)");
10062 }
10063
10064 #[test]
10067 fn url_with_brackets_as_link_text_round_trips() {
10068 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10073 let doc = AdfDocument {
10074 version: 1,
10075 doc_type: "doc".to_string(),
10076 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10077 href,
10078 vec![AdfMark::link(href)],
10079 )])],
10080 };
10081 let md = adf_to_markdown(&doc).unwrap();
10082 let rt = markdown_to_adf(&md).unwrap();
10083 let content = rt.content[0].content.as_ref().unwrap();
10084 assert_eq!(content.len(), 1);
10085 assert_eq!(content[0].node_type, "text");
10086 assert_eq!(content[0].text.as_deref(), Some(href));
10087 let mark = &content[0].marks.as_ref().unwrap()[0];
10088 assert_eq!(mark.mark_type, "link");
10089 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10090 }
10091
10092 #[test]
10093 fn url_with_brackets_embedded_in_link_text_round_trips() {
10094 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10101 let text =
10102 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10103 let doc = AdfDocument {
10104 version: 1,
10105 doc_type: "doc".to_string(),
10106 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10107 text,
10108 vec![AdfMark::link(href)],
10109 )])],
10110 };
10111 let md = adf_to_markdown(&doc).unwrap();
10112 let rt = markdown_to_adf(&md).unwrap();
10113 let content = rt.content[0].content.as_ref().unwrap();
10114 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10115 assert_eq!(content[0].node_type, "text");
10116 assert_eq!(content[0].text.as_deref(), Some(text));
10117 let mark = &content[0].marks.as_ref().unwrap()[0];
10118 assert_eq!(mark.mark_type, "link");
10119 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10120 }
10121
10122 #[test]
10123 fn url_with_brackets_plain_text_round_trips() {
10124 let text =
10127 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10128 let doc = AdfDocument {
10129 version: 1,
10130 doc_type: "doc".to_string(),
10131 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10132 };
10133 let md = adf_to_markdown(&doc).unwrap();
10134 let rt = markdown_to_adf(&md).unwrap();
10135 let content = rt.content[0].content.as_ref().unwrap();
10136 assert_eq!(content.len(), 1);
10137 assert_eq!(content[0].node_type, "text");
10138 assert_eq!(content[0].text.as_deref(), Some(text));
10139 assert!(content[0].marks.is_none());
10140 }
10141
10142 #[test]
10143 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10144 let href = "https://example.com/";
10147 let text = "See https://example.com/ for more";
10148 let doc = AdfDocument {
10149 version: 1,
10150 doc_type: "doc".to_string(),
10151 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10152 text,
10153 vec![AdfMark::link(href)],
10154 )])],
10155 };
10156 let md = adf_to_markdown(&doc).unwrap();
10157 let rt = markdown_to_adf(&md).unwrap();
10158 let content = rt.content[0].content.as_ref().unwrap();
10159 assert_eq!(content.len(), 1);
10160 assert_eq!(content[0].node_type, "text");
10161 assert_eq!(content[0].text.as_deref(), Some(text));
10162 let mark = &content[0].marks.as_ref().unwrap()[0];
10163 assert_eq!(mark.mark_type, "link");
10164 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10165 }
10166
10167 #[test]
10168 fn nested_brackets_in_link_text_round_trip() {
10169 let href = "https://x.com";
10172 let text = "foo [a[b]c] bar";
10173 let doc = AdfDocument {
10174 version: 1,
10175 doc_type: "doc".to_string(),
10176 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10177 text,
10178 vec![AdfMark::link(href)],
10179 )])],
10180 };
10181 let md = adf_to_markdown(&doc).unwrap();
10182 let rt = markdown_to_adf(&md).unwrap();
10183 let content = rt.content[0].content.as_ref().unwrap();
10184 assert_eq!(content.len(), 1);
10185 assert_eq!(content[0].node_type, "text");
10186 assert_eq!(content[0].text.as_deref(), Some(text));
10187 }
10188
10189 #[test]
10190 fn bracket_url_bracket_in_link_text_round_trips() {
10191 let href = "https://y.com";
10197 let text = "[see https://x.com/a[0]=1 here]";
10198 let doc = AdfDocument {
10199 version: 1,
10200 doc_type: "doc".to_string(),
10201 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10202 text,
10203 vec![AdfMark::link(href)],
10204 )])],
10205 };
10206 let md = adf_to_markdown(&doc).unwrap();
10207 let rt = markdown_to_adf(&md).unwrap();
10208 let content = rt.content[0].content.as_ref().unwrap();
10209 assert_eq!(content.len(), 1);
10210 assert_eq!(content[0].node_type, "text");
10211 assert_eq!(content[0].text.as_deref(), Some(text));
10212 let mark = &content[0].marks.as_ref().unwrap()[0];
10213 assert_eq!(mark.mark_type, "link");
10214 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10215 }
10216
10217 #[test]
10218 fn escape_bare_urls_applied_inside_link_text() {
10219 let doc = AdfDocument {
10225 version: 1,
10226 doc_type: "doc".to_string(),
10227 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10228 "See https://example.com/",
10229 vec![AdfMark::link("https://target.example.com/")],
10230 )])],
10231 };
10232 let md = adf_to_markdown(&doc).unwrap();
10233 assert!(
10234 md.contains(r"\https://example.com/"),
10235 "bare URL inside link text must be escaped, got: {md}"
10236 );
10237 }
10238
10239 #[test]
10240 fn inline_card_still_round_trips() {
10241 let adf_json = r#"{
10244 "version": 1,
10245 "type": "doc",
10246 "content": [{
10247 "type": "paragraph",
10248 "content": [
10249 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10250 ]
10251 }]
10252 }"#;
10253 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10254 let jfm = adf_to_markdown(&adf).unwrap();
10255 assert!(jfm.contains(":card[https://example.com/page]"));
10256 let roundtripped = markdown_to_adf(&jfm).unwrap();
10257 let content = roundtripped.content[0].content.as_ref().unwrap();
10258 assert_eq!(content[0].node_type, "inlineCard");
10259 assert_eq!(
10260 content[0].attrs.as_ref().unwrap()["url"],
10261 "https://example.com/page"
10262 );
10263 }
10264
10265 #[test]
10268 fn url_safe_in_bracket_content_balanced() {
10269 assert!(url_safe_in_bracket_content("https://example.com"));
10271 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10272 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10273 assert!(url_safe_in_bracket_content(""));
10274 }
10275
10276 #[test]
10277 fn url_safe_in_bracket_content_unbalanced() {
10278 assert!(!url_safe_in_bracket_content("a]b"));
10280 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10281 assert!(!url_safe_in_bracket_content("a\nb"));
10283 }
10284
10285 #[test]
10286 fn inline_card_url_with_closing_bracket_round_trip() {
10287 let adf_json = r#"{
10292 "version": 1,
10293 "type": "doc",
10294 "content": [{
10295 "type": "paragraph",
10296 "content": [
10297 {"type": "text", "text": "See: "},
10298 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10299 ]
10300 }]
10301 }"#;
10302 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10303 let jfm = adf_to_markdown(&adf).unwrap();
10304 assert!(
10305 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10306 "expected attr-form for URL with `]`, got: {jfm}"
10307 );
10308 let rt = markdown_to_adf(&jfm).unwrap();
10309 let content = rt.content[0].content.as_ref().unwrap();
10310 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10311 assert_eq!(content[0].node_type, "text");
10312 assert_eq!(content[0].text.as_deref(), Some("See: "));
10313 assert_eq!(content[1].node_type, "inlineCard");
10314 assert_eq!(
10315 content[1].attrs.as_ref().unwrap()["url"],
10316 "https://example.com/path]end/?q=1"
10317 );
10318 }
10319
10320 #[test]
10321 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10322 let adf_json = r#"{
10324 "version": 1,
10325 "type": "doc",
10326 "content": [{
10327 "type": "paragraph",
10328 "content": [
10329 {"type": "inlineCard", "attrs": {
10330 "url": "https://example.com/a]b",
10331 "localId": "c-77"
10332 }}
10333 ]
10334 }]
10335 }"#;
10336 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10337 let jfm = adf_to_markdown(&adf).unwrap();
10338 assert!(
10339 jfm.contains(r#"url="https://example.com/a]b""#),
10340 "jfm: {jfm}"
10341 );
10342 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10343 let rt = markdown_to_adf(&jfm).unwrap();
10344 let card = &rt.content[0].content.as_ref().unwrap()[0];
10345 assert_eq!(card.node_type, "inlineCard");
10346 assert_eq!(
10347 card.attrs.as_ref().unwrap()["url"],
10348 "https://example.com/a]b"
10349 );
10350 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10351 }
10352
10353 #[test]
10354 fn block_card_url_with_closing_bracket_round_trip() {
10355 let adf_json = r#"{
10357 "version": 1,
10358 "type": "doc",
10359 "content": [
10360 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10361 ]
10362 }"#;
10363 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10364 let jfm = adf_to_markdown(&adf).unwrap();
10365 assert!(
10366 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10367 "expected attr-form for blockCard with `]`, got: {jfm}"
10368 );
10369 let rt = markdown_to_adf(&jfm).unwrap();
10370 assert_eq!(rt.content[0].node_type, "blockCard");
10371 assert_eq!(
10372 rt.content[0].attrs.as_ref().unwrap()["url"],
10373 "https://example.com/path]end"
10374 );
10375 }
10376
10377 #[test]
10378 fn block_card_attr_form_parses_without_renderer() {
10379 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10383 assert_eq!(doc.content[0].node_type, "blockCard");
10384 assert_eq!(
10385 doc.content[0].attrs.as_ref().unwrap()["url"],
10386 "https://example.com/a"
10387 );
10388 }
10389
10390 #[test]
10391 fn block_card_attr_form_url_overrides_content() {
10392 let doc =
10396 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10397 .unwrap();
10398 assert_eq!(doc.content[0].node_type, "blockCard");
10399 assert_eq!(
10400 doc.content[0].attrs.as_ref().unwrap()["url"],
10401 "https://new.example.com"
10402 );
10403 }
10404
10405 #[test]
10406 fn block_card_attr_form_with_layout_and_width() {
10407 let doc =
10410 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10411 .unwrap();
10412 let attrs = doc.content[0].attrs.as_ref().unwrap();
10413 assert_eq!(attrs["url"], "https://example.com/a]b");
10414 assert_eq!(attrs["layout"], "wide");
10415 assert_eq!(attrs["width"], 80);
10416 }
10417
10418 #[test]
10419 fn inline_card_issue_553_reproducer() {
10420 let adf_json = r#"{
10424 "version": 1,
10425 "type": "doc",
10426 "content": [{
10427 "type": "paragraph",
10428 "content": [
10429 {"type": "text", "text": "See the related page: "},
10430 {"type": "inlineCard", "attrs": {
10431 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10432 }}
10433 ]
10434 }]
10435 }"#;
10436 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10437 let jfm = adf_to_markdown(&adf).unwrap();
10438 let rt = markdown_to_adf(&jfm).unwrap();
10439 let content = rt.content[0].content.as_ref().unwrap();
10440 assert_eq!(content.len(), 2);
10441 assert_eq!(content[0].node_type, "text");
10442 assert_eq!(content[1].node_type, "inlineCard");
10443 assert_eq!(
10444 content[1].attrs.as_ref().unwrap()["url"],
10445 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10446 );
10447 }
10448
10449 #[test]
10450 fn inline_card_attr_form_parses_even_without_renderer() {
10451 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10453 let node = &doc.content[0].content.as_ref().unwrap()[0];
10454 assert_eq!(node.node_type, "inlineCard");
10455 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10456 }
10457
10458 #[test]
10459 fn inline_card_attr_form_url_overrides_content() {
10460 let doc =
10464 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10465 .unwrap();
10466 let node = &doc.content[0].content.as_ref().unwrap()[0];
10467 assert_eq!(node.node_type, "inlineCard");
10468 assert_eq!(
10469 node.attrs.as_ref().unwrap()["url"],
10470 "https://new.example.com"
10471 );
10472 }
10473
10474 #[test]
10477 fn url_with_link_and_underline_marks_round_trip() {
10478 let adf_json = r#"{
10482 "version": 1,
10483 "type": "doc",
10484 "content": [{
10485 "type": "paragraph",
10486 "content": [
10487 {"type": "text", "text": "See results at: "},
10488 {"type": "text",
10489 "text": "https://example.com/projects/abc123/analytics",
10490 "marks": [
10491 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10492 {"type": "underline"}
10493 ]},
10494 {"type": "text", "text": " for details."}
10495 ]
10496 }]
10497 }"#;
10498 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10499 let jfm = adf_to_markdown(&adf).unwrap();
10500 let rt = markdown_to_adf(&jfm).unwrap();
10501 let content = rt.content[0].content.as_ref().unwrap();
10502 assert_eq!(
10503 content.len(),
10504 3,
10505 "expected 3 inline nodes, got: {content:?}"
10506 );
10507 assert_eq!(content[0].node_type, "text");
10508 assert_eq!(
10509 content[1].node_type, "text",
10510 "must stay text, not inlineCard"
10511 );
10512 assert_eq!(
10513 content[1].text.as_deref(),
10514 Some("https://example.com/projects/abc123/analytics")
10515 );
10516 let mark_types: Vec<&str> = content[1]
10517 .marks
10518 .as_deref()
10519 .unwrap_or(&[])
10520 .iter()
10521 .map(|m| m.mark_type.as_str())
10522 .collect();
10523 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10524 assert_eq!(content[2].node_type, "text");
10525 }
10526
10527 #[test]
10528 fn url_inside_bracketed_span_stays_text() {
10529 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10533 let node = &doc.content[0].content.as_ref().unwrap()[0];
10534 assert_eq!(node.node_type, "text");
10535 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10536 let mark_types: Vec<&str> = node
10537 .marks
10538 .as_deref()
10539 .unwrap_or(&[])
10540 .iter()
10541 .map(|m| m.mark_type.as_str())
10542 .collect();
10543 assert_eq!(mark_types, vec!["underline"]);
10544 }
10545
10546 #[test]
10547 fn url_inside_emphasis_stays_text() {
10548 for (md, mark) in [
10551 ("**https://example.com**", "strong"),
10552 ("*https://example.com*", "em"),
10553 ("~~https://example.com~~", "strike"),
10554 ] {
10555 let doc = markdown_to_adf(md).unwrap();
10556 let node = &doc.content[0].content.as_ref().unwrap()[0];
10557 assert_eq!(node.node_type, "text", "md={md}: must be text");
10558 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10559 let mark_types: Vec<&str> = node
10560 .marks
10561 .as_deref()
10562 .unwrap_or(&[])
10563 .iter()
10564 .map(|m| m.mark_type.as_str())
10565 .collect();
10566 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10567 }
10568 }
10569
10570 #[test]
10571 fn url_inside_span_directive_stays_text() {
10572 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10574 let node = &doc.content[0].content.as_ref().unwrap()[0];
10575 assert_eq!(node.node_type, "text");
10576 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10577 let mark = &node.marks.as_ref().unwrap()[0];
10578 assert_eq!(mark.mark_type, "textColor");
10579 }
10580
10581 #[test]
10582 fn url_as_link_text_with_underline_after_link_mark_order() {
10583 let adf_json = r#"{
10587 "version": 1,
10588 "type": "doc",
10589 "content": [{
10590 "type": "paragraph",
10591 "content": [
10592 {"type": "text",
10593 "text": "https://example.com",
10594 "marks": [
10595 {"type": "underline"},
10596 {"type": "link", "attrs": {"href": "https://example.com"}}
10597 ]}
10598 ]
10599 }]
10600 }"#;
10601 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10602 let jfm = adf_to_markdown(&adf).unwrap();
10603 let rt = markdown_to_adf(&jfm).unwrap();
10604 let node = &rt.content[0].content.as_ref().unwrap()[0];
10605 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10606 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10607 let mark_types: Vec<&str> = node
10608 .marks
10609 .as_deref()
10610 .unwrap_or(&[])
10611 .iter()
10612 .map(|m| m.mark_type.as_str())
10613 .collect();
10614 assert_eq!(mark_types, vec!["underline", "link"]);
10615 }
10616
10617 #[test]
10618 fn bare_url_at_top_level_still_becomes_inline_card() {
10619 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10623 let content = doc.content[0].content.as_ref().unwrap();
10624 assert_eq!(content.len(), 3);
10625 assert_eq!(content[0].node_type, "text");
10626 assert_eq!(content[1].node_type, "inlineCard");
10627 assert_eq!(
10628 content[1].attrs.as_ref().unwrap()["url"],
10629 "https://example.com"
10630 );
10631 assert_eq!(content[2].node_type, "text");
10632 }
10633
10634 #[test]
10637 fn paragraph_align_center() {
10638 let md = "Centered text.\n{align=center}";
10639 let doc = markdown_to_adf(md).unwrap();
10640 let marks = doc.content[0].marks.as_ref().unwrap();
10641 assert_eq!(marks[0].mark_type, "alignment");
10642 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10643 }
10644
10645 #[test]
10646 fn adf_alignment_to_markdown() {
10647 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10648 node.marks = Some(vec![AdfMark::alignment("center")]);
10649 let doc = AdfDocument {
10650 version: 1,
10651 doc_type: "doc".to_string(),
10652 content: vec![node],
10653 };
10654 let md = adf_to_markdown(&doc).unwrap();
10655 assert!(md.contains("Centered."));
10656 assert!(md.contains("{align=center}"));
10657 }
10658
10659 #[test]
10660 fn round_trip_alignment() {
10661 let md = "Centered.\n{align=center}\n";
10662 let doc = markdown_to_adf(md).unwrap();
10663 let result = adf_to_markdown(&doc).unwrap();
10664 assert!(result.contains("{align=center}"));
10665 }
10666
10667 #[test]
10668 fn paragraph_indent() {
10669 let md = "Indented.\n{indent=2}";
10670 let doc = markdown_to_adf(md).unwrap();
10671 let marks = doc.content[0].marks.as_ref().unwrap();
10672 assert_eq!(marks[0].mark_type, "indentation");
10673 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10674 }
10675
10676 #[test]
10677 fn code_block_breakout() {
10678 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10679 let doc = markdown_to_adf(md).unwrap();
10680 let marks = doc.content[0].marks.as_ref().unwrap();
10681 assert_eq!(marks[0].mark_type, "breakout");
10682 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10683 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10684 }
10685
10686 #[test]
10687 fn code_block_breakout_with_width() {
10688 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10689 let doc = markdown_to_adf(md).unwrap();
10690 let marks = doc.content[0].marks.as_ref().unwrap();
10691 assert_eq!(marks[0].mark_type, "breakout");
10692 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10693 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10694 }
10695
10696 #[test]
10697 fn adf_breakout_to_markdown() {
10698 let mut node = AdfNode::code_block(Some("python"), "pass");
10699 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10700 let doc = AdfDocument {
10701 version: 1,
10702 doc_type: "doc".to_string(),
10703 content: vec![node],
10704 };
10705 let md = adf_to_markdown(&doc).unwrap();
10706 assert!(md.contains("{breakout=wide}"));
10707 assert!(!md.contains("breakoutWidth"));
10708 }
10709
10710 #[test]
10711 fn adf_breakout_with_width_to_markdown() {
10712 let mut node = AdfNode::code_block(Some("python"), "pass");
10713 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
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=1200"));
10722 }
10723
10724 #[test]
10725 fn breakout_width_round_trip() {
10726 let adf_json = r#"{"version":1,"type":"doc","content":[{
10727 "type":"codeBlock",
10728 "attrs":{"language":"text"},
10729 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10730 "content":[{"type":"text","text":"some code"}]
10731 }]}"#;
10732 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10733 let md = adf_to_markdown(&doc).unwrap();
10734 assert!(md.contains("breakout=wide"));
10735 assert!(md.contains("breakoutWidth=1200"));
10736 let round_tripped = markdown_to_adf(&md).unwrap();
10737 let marks = round_tripped.content[0].marks.as_ref().unwrap();
10738 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10739 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10740 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10741 }
10742
10743 #[test]
10746 fn image_with_layout_attrs() {
10747 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
10748 let node = &doc.content[0];
10749 assert_eq!(node.node_type, "mediaSingle");
10750 let attrs = node.attrs.as_ref().unwrap();
10751 assert_eq!(attrs["layout"], "wide");
10752 assert_eq!(attrs["width"], 80);
10753 }
10754
10755 #[test]
10756 fn adf_image_with_layout_to_markdown() {
10757 let mut node = AdfNode::media_single("url", Some("alt"));
10758 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10759 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10760 let doc = AdfDocument {
10761 version: 1,
10762 doc_type: "doc".to_string(),
10763 content: vec![node],
10764 };
10765 let md = adf_to_markdown(&doc).unwrap();
10766 assert!(md.contains("{layout=wide width=80}"));
10767 }
10768
10769 #[test]
10770 fn table_with_layout_attrs() {
10771 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10772 let doc = markdown_to_adf(md).unwrap();
10773 let table = &doc.content[0];
10774 assert_eq!(table.node_type, "table");
10775 let attrs = table.attrs.as_ref().unwrap();
10776 assert_eq!(attrs["layout"], "wide");
10777 assert_eq!(attrs["isNumberColumnEnabled"], true);
10778 }
10779
10780 #[test]
10781 fn adf_table_with_attrs_to_markdown() {
10782 let mut table = AdfNode::table(vec![
10783 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10784 AdfNode::text("H"),
10785 ])])]),
10786 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10787 AdfNode::text("C"),
10788 ])])]),
10789 ]);
10790 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10791 let doc = AdfDocument {
10792 version: 1,
10793 doc_type: "doc".to_string(),
10794 content: vec![table],
10795 };
10796 let md = adf_to_markdown(&doc).unwrap();
10797 assert!(md.contains("{layout=wide numbered}"));
10798 }
10799
10800 #[test]
10803 fn underline_bracketed_span() {
10804 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10805 let content = doc.content[0].content.as_ref().unwrap();
10806 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10807 let marks = content[1].marks.as_ref().unwrap();
10808 assert_eq!(marks[0].mark_type, "underline");
10809 }
10810
10811 #[test]
10812 fn adf_underline_to_markdown() {
10813 let doc = AdfDocument {
10814 version: 1,
10815 doc_type: "doc".to_string(),
10816 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10817 "underlined",
10818 vec![AdfMark::underline()],
10819 )])],
10820 };
10821 let md = adf_to_markdown(&doc).unwrap();
10822 assert!(md.contains("[underlined]{underline}"));
10823 }
10824
10825 #[test]
10826 fn round_trip_underline() {
10827 let md = "This is [underlined text]{underline} here.\n";
10828 let doc = markdown_to_adf(md).unwrap();
10829 let result = adf_to_markdown(&doc).unwrap();
10830 assert!(result.contains("[underlined text]{underline}"));
10831 }
10832
10833 #[test]
10834 fn mark_ordering_underline_strong_preserved() {
10835 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10837 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10838 ]}]}"#;
10839 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10840 let md = adf_to_markdown(&doc).unwrap();
10841 let round_tripped = markdown_to_adf(&md).unwrap();
10842 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10843 let mark_types: Vec<&str> = node
10844 .marks
10845 .as_ref()
10846 .unwrap()
10847 .iter()
10848 .map(|m| m.mark_type.as_str())
10849 .collect();
10850 assert_eq!(
10851 mark_types,
10852 vec!["underline", "strong"],
10853 "mark order should be preserved, got: {mark_types:?}"
10854 );
10855 }
10856
10857 #[test]
10858 fn mark_ordering_link_strong_preserved() {
10859 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10861 {"type":"text","text":"bold link","marks":[
10862 {"type":"link","attrs":{"href":"https://example.com"}},
10863 {"type":"strong"}
10864 ]}
10865 ]}]}"#;
10866 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10867 let md = adf_to_markdown(&doc).unwrap();
10868 let round_tripped = markdown_to_adf(&md).unwrap();
10869 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10870 let mark_types: Vec<&str> = node
10871 .marks
10872 .as_ref()
10873 .unwrap()
10874 .iter()
10875 .map(|m| m.mark_type.as_str())
10876 .collect();
10877 assert_eq!(
10878 mark_types,
10879 vec!["link", "strong"],
10880 "mark order should be preserved, got: {mark_types:?}"
10881 );
10882 }
10883
10884 #[test]
10885 fn mark_ordering_link_textcolor_preserved() {
10886 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10888 {"type":"text","text":"red link","marks":[
10889 {"type":"link","attrs":{"href":"https://example.com"}},
10890 {"type":"textColor","attrs":{"color":"#ff0000"}}
10891 ]}
10892 ]}]}"##;
10893 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10894 let md = adf_to_markdown(&doc).unwrap();
10895 let round_tripped = markdown_to_adf(&md).unwrap();
10896 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10897 let mark_types: Vec<&str> = node
10898 .marks
10899 .as_ref()
10900 .unwrap()
10901 .iter()
10902 .map(|m| m.mark_type.as_str())
10903 .collect();
10904 assert_eq!(
10905 mark_types,
10906 vec!["link", "textColor"],
10907 "mark order should be preserved, got: {mark_types:?}"
10908 );
10909 }
10910
10911 #[test]
10912 fn mark_ordering_link_em_preserved() {
10913 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10915 {"type":"text","text":"italic link","marks":[
10916 {"type":"link","attrs":{"href":"https://example.com"}},
10917 {"type":"em"}
10918 ]}
10919 ]}]}"#;
10920 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10921 let md = adf_to_markdown(&doc).unwrap();
10922 let round_tripped = markdown_to_adf(&md).unwrap();
10923 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10924 let mark_types: Vec<&str> = node
10925 .marks
10926 .as_ref()
10927 .unwrap()
10928 .iter()
10929 .map(|m| m.mark_type.as_str())
10930 .collect();
10931 assert_eq!(
10932 mark_types,
10933 vec!["link", "em"],
10934 "mark order should be preserved, got: {mark_types:?}"
10935 );
10936 }
10937
10938 #[test]
10939 fn mark_ordering_link_strike_preserved() {
10940 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10942 {"type":"text","text":"struck link","marks":[
10943 {"type":"link","attrs":{"href":"https://example.com"}},
10944 {"type":"strike"}
10945 ]}
10946 ]}]}"#;
10947 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10948 let md = adf_to_markdown(&doc).unwrap();
10949 let round_tripped = markdown_to_adf(&md).unwrap();
10950 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10951 let mark_types: Vec<&str> = node
10952 .marks
10953 .as_ref()
10954 .unwrap()
10955 .iter()
10956 .map(|m| m.mark_type.as_str())
10957 .collect();
10958 assert_eq!(
10959 mark_types,
10960 vec!["link", "strike"],
10961 "mark order should be preserved, got: {mark_types:?}"
10962 );
10963 }
10964
10965 #[test]
10966 fn mark_ordering_strong_link_preserved() {
10967 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10969 {"type":"text","text":"bold link","marks":[
10970 {"type":"strong"},
10971 {"type":"link","attrs":{"href":"https://example.com"}}
10972 ]}
10973 ]}]}"#;
10974 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10975 let md = adf_to_markdown(&doc).unwrap();
10976 let round_tripped = markdown_to_adf(&md).unwrap();
10977 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10978 let mark_types: Vec<&str> = node
10979 .marks
10980 .as_ref()
10981 .unwrap()
10982 .iter()
10983 .map(|m| m.mark_type.as_str())
10984 .collect();
10985 assert_eq!(
10986 mark_types,
10987 vec!["strong", "link"],
10988 "mark order should be preserved, got: {mark_types:?}"
10989 );
10990 }
10991
10992 #[test]
10993 fn mark_ordering_em_link_preserved() {
10994 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10996 {"type":"text","text":"italic link","marks":[
10997 {"type":"em"},
10998 {"type":"link","attrs":{"href":"https://example.com"}}
10999 ]}
11000 ]}]}"#;
11001 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11002 let md = adf_to_markdown(&doc).unwrap();
11003 let round_tripped = markdown_to_adf(&md).unwrap();
11004 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11005 let mark_types: Vec<&str> = node
11006 .marks
11007 .as_ref()
11008 .unwrap()
11009 .iter()
11010 .map(|m| m.mark_type.as_str())
11011 .collect();
11012 assert_eq!(
11013 mark_types,
11014 vec!["em", "link"],
11015 "mark order should be preserved, got: {mark_types:?}"
11016 );
11017 }
11018
11019 #[test]
11020 fn mark_ordering_strike_link_preserved() {
11021 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11023 {"type":"text","text":"struck link","marks":[
11024 {"type":"strike"},
11025 {"type":"link","attrs":{"href":"https://example.com"}}
11026 ]}
11027 ]}]}"#;
11028 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11029 let md = adf_to_markdown(&doc).unwrap();
11030 let round_tripped = markdown_to_adf(&md).unwrap();
11031 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11032 let mark_types: Vec<&str> = node
11033 .marks
11034 .as_ref()
11035 .unwrap()
11036 .iter()
11037 .map(|m| m.mark_type.as_str())
11038 .collect();
11039 assert_eq!(
11040 mark_types,
11041 vec!["strike", "link"],
11042 "mark order should be preserved, got: {mark_types:?}"
11043 );
11044 }
11045
11046 #[test]
11047 fn mark_ordering_underline_link_preserved() {
11048 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11050 {"type":"text","text":"click here","marks":[
11051 {"type":"underline"},
11052 {"type":"link","attrs":{"href":"https://example.com"}}
11053 ]}
11054 ]}]}"#;
11055 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11056 let md = adf_to_markdown(&doc).unwrap();
11057 let round_tripped = markdown_to_adf(&md).unwrap();
11058 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11059 let mark_types: Vec<&str> = node
11060 .marks
11061 .as_ref()
11062 .unwrap()
11063 .iter()
11064 .map(|m| m.mark_type.as_str())
11065 .collect();
11066 assert_eq!(
11067 mark_types,
11068 vec!["underline", "link"],
11069 "mark order should be preserved, got: {mark_types:?}"
11070 );
11071 }
11072
11073 #[test]
11074 fn mark_ordering_textcolor_link_preserved() {
11075 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11077 {"type":"text","text":"red link","marks":[
11078 {"type":"textColor","attrs":{"color":"#ff0000"}},
11079 {"type":"link","attrs":{"href":"https://example.com"}}
11080 ]}
11081 ]}]}"##;
11082 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11083 let md = adf_to_markdown(&doc).unwrap();
11084 let round_tripped = markdown_to_adf(&md).unwrap();
11085 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11086 let mark_types: Vec<&str> = node
11087 .marks
11088 .as_ref()
11089 .unwrap()
11090 .iter()
11091 .map(|m| m.mark_type.as_str())
11092 .collect();
11093 assert_eq!(
11094 mark_types,
11095 vec!["textColor", "link"],
11096 "mark order should be preserved, got: {mark_types:?}"
11097 );
11098 }
11099
11100 #[test]
11101 fn mark_ordering_link_underline_preserved() {
11102 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11104 {"type":"text","text":"click here","marks":[
11105 {"type":"link","attrs":{"href":"https://example.com"}},
11106 {"type":"underline"}
11107 ]}
11108 ]}]}"#;
11109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11110 let md = adf_to_markdown(&doc).unwrap();
11111 assert!(
11113 md.contains("](https://example.com)"),
11114 "should have link: {md}"
11115 );
11116 assert!(md.contains("underline"), "should have underline: {md}");
11117 let round_tripped = markdown_to_adf(&md).unwrap();
11118 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11119 let mark_types: Vec<&str> = node
11120 .marks
11121 .as_ref()
11122 .unwrap()
11123 .iter()
11124 .map(|m| m.mark_type.as_str())
11125 .collect();
11126 assert_eq!(
11127 mark_types,
11128 vec!["link", "underline"],
11129 "mark order should be preserved, got: {mark_types:?}"
11130 );
11131 }
11132
11133 #[test]
11134 fn mark_ordering_underline_strong_link_preserved() {
11135 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11137 {"type":"text","text":"bold underlined link","marks":[
11138 {"type":"underline"},
11139 {"type":"strong"},
11140 {"type":"link","attrs":{"href":"https://example.com/page"}}
11141 ]}
11142 ]}]}"#;
11143 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11144 let md = adf_to_markdown(&doc).unwrap();
11145 let round_tripped = markdown_to_adf(&md).unwrap();
11146 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11147 let mark_types: Vec<&str> = node
11148 .marks
11149 .as_ref()
11150 .unwrap()
11151 .iter()
11152 .map(|m| m.mark_type.as_str())
11153 .collect();
11154 assert_eq!(
11155 mark_types,
11156 vec!["underline", "strong", "link"],
11157 "mark order should be preserved, got: {mark_types:?}"
11158 );
11159 }
11160
11161 #[test]
11162 fn mark_ordering_strong_underline_link_preserved() {
11163 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11165 {"type":"text","text":"bold underlined link","marks":[
11166 {"type":"strong"},
11167 {"type":"underline"},
11168 {"type":"link","attrs":{"href":"https://example.com/page"}}
11169 ]}
11170 ]}]}"#;
11171 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11172 let md = adf_to_markdown(&doc).unwrap();
11173 let round_tripped = markdown_to_adf(&md).unwrap();
11174 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11175 let mark_types: Vec<&str> = node
11176 .marks
11177 .as_ref()
11178 .unwrap()
11179 .iter()
11180 .map(|m| m.mark_type.as_str())
11181 .collect();
11182 assert_eq!(
11183 mark_types,
11184 vec!["strong", "underline", "link"],
11185 "mark order should be preserved, got: {mark_types:?}"
11186 );
11187 }
11188
11189 #[test]
11190 fn mark_ordering_underline_em_link_preserved() {
11191 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11193 {"type":"text","text":"italic underlined link","marks":[
11194 {"type":"underline"},
11195 {"type":"em"},
11196 {"type":"link","attrs":{"href":"https://example.com/page"}}
11197 ]}
11198 ]}]}"#;
11199 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11200 let md = adf_to_markdown(&doc).unwrap();
11201 let round_tripped = markdown_to_adf(&md).unwrap();
11202 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11203 let mark_types: Vec<&str> = node
11204 .marks
11205 .as_ref()
11206 .unwrap()
11207 .iter()
11208 .map(|m| m.mark_type.as_str())
11209 .collect();
11210 assert_eq!(
11211 mark_types,
11212 vec!["underline", "em", "link"],
11213 "mark order should be preserved, got: {mark_types:?}"
11214 );
11215 }
11216
11217 #[test]
11218 fn mark_ordering_underline_strike_link_preserved() {
11219 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11221 {"type":"text","text":"struck underlined link","marks":[
11222 {"type":"underline"},
11223 {"type":"strike"},
11224 {"type":"link","attrs":{"href":"https://example.com/page"}}
11225 ]}
11226 ]}]}"#;
11227 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11228 let md = adf_to_markdown(&doc).unwrap();
11229 let round_tripped = markdown_to_adf(&md).unwrap();
11230 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11231 let mark_types: Vec<&str> = node
11232 .marks
11233 .as_ref()
11234 .unwrap()
11235 .iter()
11236 .map(|m| m.mark_type.as_str())
11237 .collect();
11238 assert_eq!(
11239 mark_types,
11240 vec!["underline", "strike", "link"],
11241 "mark order should be preserved, got: {mark_types:?}"
11242 );
11243 }
11244
11245 #[test]
11246 fn mark_ordering_underline_strong_em_link_preserved() {
11247 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11249 {"type":"text","text":"all the marks","marks":[
11250 {"type":"underline"},
11251 {"type":"strong"},
11252 {"type":"em"},
11253 {"type":"link","attrs":{"href":"https://example.com/page"}}
11254 ]}
11255 ]}]}"#;
11256 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11257 let md = adf_to_markdown(&doc).unwrap();
11258 let round_tripped = markdown_to_adf(&md).unwrap();
11259 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11260 let mark_types: Vec<&str> = node
11261 .marks
11262 .as_ref()
11263 .unwrap()
11264 .iter()
11265 .map(|m| m.mark_type.as_str())
11266 .collect();
11267 assert_eq!(
11268 mark_types,
11269 vec!["underline", "strong", "em", "link"],
11270 "mark order should be preserved, got: {mark_types:?}"
11271 );
11272 }
11273
11274 #[test]
11275 fn em_strong_round_trip() {
11276 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11278 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11279 ]}]}"#;
11280 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11281 let md = adf_to_markdown(&doc).unwrap();
11282 assert_eq!(md.trim(), "***bold and italic***");
11283 let round_tripped = markdown_to_adf(&md).unwrap();
11284 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11285 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11286 let mark_types: Vec<&str> = node
11287 .marks
11288 .as_ref()
11289 .unwrap()
11290 .iter()
11291 .map(|m| m.mark_type.as_str())
11292 .collect();
11293 assert_eq!(
11294 mark_types,
11295 vec!["strong", "em"],
11296 "both strong and em marks should be preserved, got: {mark_types:?}"
11297 );
11298 }
11299
11300 #[test]
11301 fn em_strong_round_trip_em_first() {
11302 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11304 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11305 ]}]}"#;
11306 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11307 let md = adf_to_markdown(&doc).unwrap();
11308 let round_tripped = markdown_to_adf(&md).unwrap();
11309 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11310 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11311 let mark_types: Vec<&str> = node
11312 .marks
11313 .as_ref()
11314 .unwrap()
11315 .iter()
11316 .map(|m| m.mark_type.as_str())
11317 .collect();
11318 assert_eq!(
11319 mark_types,
11320 vec!["em", "strong"],
11321 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11322 );
11323 }
11324
11325 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11328 let doc = AdfDocument {
11329 version: 1,
11330 doc_type: "doc".to_string(),
11331 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11332 "text", marks,
11333 )])],
11334 };
11335 let md = adf_to_markdown(&doc).unwrap();
11336 let round_tripped = markdown_to_adf(&md).unwrap();
11337 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11338 let mark_types: Vec<&str> = node
11339 .marks
11340 .as_ref()
11341 .expect("round-tripped node should have marks")
11342 .iter()
11343 .map(|m| m.mark_type.as_str())
11344 .collect();
11345 assert_eq!(
11346 mark_types, expected,
11347 "marks should round-trip in order via {md:?}"
11348 );
11349 }
11350
11351 #[test]
11352 fn round_trip_em_strong_mark_order() {
11353 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11355 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11356 }
11357
11358 #[test]
11359 fn round_trip_strong_underline_mark_order() {
11360 assert_mark_order_round_trip(
11362 vec![AdfMark::strong(), AdfMark::underline()],
11363 &["strong", "underline"],
11364 );
11365 assert_mark_order_round_trip(
11366 vec![AdfMark::underline(), AdfMark::strong()],
11367 &["underline", "strong"],
11368 );
11369 }
11370
11371 #[test]
11372 fn round_trip_em_underline_mark_order() {
11373 assert_mark_order_round_trip(
11374 vec![AdfMark::em(), AdfMark::underline()],
11375 &["em", "underline"],
11376 );
11377 assert_mark_order_round_trip(
11378 vec![AdfMark::underline(), AdfMark::em()],
11379 &["underline", "em"],
11380 );
11381 }
11382
11383 #[test]
11384 fn round_trip_strike_strong_em_permutations() {
11385 assert_mark_order_round_trip(
11389 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11390 &["strike", "strong", "em"],
11391 );
11392 assert_mark_order_round_trip(
11393 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11394 &["strike", "em", "strong"],
11395 );
11396 assert_mark_order_round_trip(
11397 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11398 &["strong", "strike", "em"],
11399 );
11400 assert_mark_order_round_trip(
11401 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11402 &["strong", "em", "strike"],
11403 );
11404 assert_mark_order_round_trip(
11405 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11406 &["em", "strike", "strong"],
11407 );
11408 assert_mark_order_round_trip(
11409 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11410 &["em", "strong", "strike"],
11411 );
11412 }
11413
11414 #[test]
11415 fn round_trip_underline_nested_with_strong_em() {
11416 assert_mark_order_round_trip(
11419 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11420 &["underline", "strong", "em"],
11421 );
11422 assert_mark_order_round_trip(
11423 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11424 &["strong", "underline", "em"],
11425 );
11426 assert_mark_order_round_trip(
11427 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11428 &["strong", "em", "underline"],
11429 );
11430 }
11431
11432 #[test]
11433 fn round_trip_span_attr_order_preserved() {
11434 assert_mark_order_round_trip(
11438 vec![
11439 AdfMark::background_color("#ffff00"),
11440 AdfMark::text_color("#ff0000"),
11441 ],
11442 &["backgroundColor", "textColor"],
11443 );
11444 assert_mark_order_round_trip(
11445 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11446 &["subsup", "textColor"],
11447 );
11448 assert_mark_order_round_trip(
11449 vec![
11450 AdfMark::text_color("#ff0000"),
11451 AdfMark::background_color("#ffff00"),
11452 ],
11453 &["textColor", "backgroundColor"],
11454 );
11455 }
11456
11457 #[test]
11458 fn round_trip_annotation_before_underline() {
11459 assert_mark_order_round_trip(
11463 vec![
11464 AdfMark::annotation("ann-1", "inlineComment"),
11465 AdfMark::underline(),
11466 ],
11467 &["annotation", "underline"],
11468 );
11469 assert_mark_order_round_trip(
11470 vec![
11471 AdfMark::annotation("ann-1", "inlineComment"),
11472 AdfMark::underline(),
11473 AdfMark::annotation("ann-2", "inlineComment"),
11474 ],
11475 &["annotation", "underline", "annotation"],
11476 );
11477 }
11478
11479 #[test]
11480 fn round_trip_em_content_with_underscores() {
11481 let doc = AdfDocument {
11486 version: 1,
11487 doc_type: "doc".to_string(),
11488 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11489 "foo _bar_ baz",
11490 vec![AdfMark::em(), AdfMark::strong()],
11491 )])],
11492 };
11493 let md = adf_to_markdown(&doc).unwrap();
11494 let round_tripped = markdown_to_adf(&md).unwrap();
11495 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11496 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11497 let mark_types: Vec<&str> = node
11498 .marks
11499 .as_ref()
11500 .unwrap()
11501 .iter()
11502 .map(|m| m.mark_type.as_str())
11503 .collect();
11504 assert_eq!(mark_types, vec!["em", "strong"]);
11505 }
11506
11507 #[test]
11508 fn round_trip_link_nested_with_formatting_marks() {
11509 assert_mark_order_round_trip(
11512 vec![
11513 AdfMark::link("https://example.com"),
11514 AdfMark::strong(),
11515 AdfMark::em(),
11516 ],
11517 &["link", "strong", "em"],
11518 );
11519 assert_mark_order_round_trip(
11520 vec![
11521 AdfMark::em(),
11522 AdfMark::strong(),
11523 AdfMark::link("https://example.com"),
11524 ],
11525 &["em", "strong", "link"],
11526 );
11527 assert_mark_order_round_trip(
11528 vec![
11529 AdfMark::underline(),
11530 AdfMark::link("https://example.com"),
11531 AdfMark::strong(),
11532 ],
11533 &["underline", "link", "strong"],
11534 );
11535 }
11536
11537 fn bare_mark(mark_type: &str) -> AdfMark {
11541 AdfMark {
11542 mark_type: mark_type.to_string(),
11543 attrs: None,
11544 }
11545 }
11546
11547 #[test]
11548 fn collect_span_attr_handles_missing_attrs() {
11549 let mut attrs = Vec::new();
11554 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11555 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11556 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11557 collect_span_attr(&bare_mark("link"), &mut attrs);
11558 assert!(attrs.is_empty(), "got: {attrs:?}");
11559 }
11560
11561 #[test]
11562 fn collect_bracketed_attr_handles_missing_attrs() {
11563 let mut attrs = Vec::new();
11566 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11567 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11568 assert!(attrs.is_empty(), "got: {attrs:?}");
11569 }
11570
11571 #[test]
11572 fn collect_bracketed_attr_handles_annotation_without_id() {
11573 let mark = AdfMark {
11577 mark_type: "annotation".to_string(),
11578 attrs: Some(serde_json::json!({})),
11579 };
11580 let mut attrs = Vec::new();
11581 collect_bracketed_attr(&mark, &mut attrs);
11582 assert!(attrs.is_empty(), "got: {attrs:?}");
11583 }
11584
11585 #[test]
11586 fn span_attr_order_rejects_unknown_types() {
11587 assert_eq!(span_attr_order("textColor"), 0);
11591 assert_eq!(span_attr_order("backgroundColor"), 1);
11592 assert_eq!(span_attr_order("subsup"), 2);
11593 assert_eq!(span_attr_order("strong"), u8::MAX);
11594 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11595 }
11596
11597 #[test]
11598 fn bracketed_run_rejects_unknown_types() {
11599 assert!(bracketed_run_is_canonical(&[
11603 AdfMark::underline(),
11604 AdfMark::annotation("x", "inlineComment")
11605 ]));
11606 assert!(!bracketed_run_is_canonical(&[
11607 AdfMark::annotation("x", "inlineComment"),
11608 AdfMark::underline()
11609 ]));
11610 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11611 }
11612
11613 #[test]
11614 fn render_marked_text_ignores_unknown_mark_types() {
11615 let doc = AdfDocument {
11619 version: 1,
11620 doc_type: "doc".to_string(),
11621 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11622 "hello",
11623 vec![bare_mark("futureMark"), AdfMark::strong()],
11624 )])],
11625 };
11626 let md = adf_to_markdown(&doc).unwrap();
11627 assert_eq!(md.trim(), "**hello**");
11628 let rt = markdown_to_adf(&md).unwrap();
11629 let node = &rt.content[0].content.as_ref().unwrap()[0];
11630 assert_eq!(node.text.as_deref(), Some("hello"));
11631 let mark_types: Vec<&str> = node
11632 .marks
11633 .as_ref()
11634 .unwrap()
11635 .iter()
11636 .map(|m| m.mark_type.as_str())
11637 .collect();
11638 assert_eq!(mark_types, vec!["strong"]);
11639 }
11640
11641 #[test]
11642 fn triple_asterisk_parse_to_adf() {
11643 let md = "***bold and italic***\n";
11645 let doc = markdown_to_adf(md).unwrap();
11646 let node = &doc.content[0].content.as_ref().unwrap()[0];
11647 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11648 let mark_types: Vec<&str> = node
11649 .marks
11650 .as_ref()
11651 .unwrap()
11652 .iter()
11653 .map(|m| m.mark_type.as_str())
11654 .collect();
11655 assert!(
11656 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11657 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11658 );
11659 }
11660
11661 #[test]
11662 fn triple_asterisk_with_surrounding_text() {
11663 let md = "before ***bold italic*** after\n";
11665 let doc = markdown_to_adf(md).unwrap();
11666 let nodes = doc.content[0].content.as_ref().unwrap();
11667 assert!(
11669 nodes.len() >= 3,
11670 "expected at least 3 nodes, got {}",
11671 nodes.len()
11672 );
11673 assert_eq!(nodes[0].text.as_deref(), Some("before "));
11674 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11675 let mark_types: Vec<&str> = nodes[1]
11676 .marks
11677 .as_ref()
11678 .unwrap()
11679 .iter()
11680 .map(|m| m.mark_type.as_str())
11681 .collect();
11682 assert!(
11683 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11684 "middle node should have strong+em, got: {mark_types:?}"
11685 );
11686 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11687 }
11688
11689 #[test]
11690 fn annotation_mark_round_trip() {
11691 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11693 {"type":"text","text":"highlighted text","marks":[
11694 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11695 ]}
11696 ]}]}"#;
11697 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11698
11699 let md = adf_to_markdown(&doc).unwrap();
11700 assert!(
11701 md.contains("annotation-id="),
11702 "JFM should contain annotation-id, got: {md}"
11703 );
11704
11705 let round_tripped = markdown_to_adf(&md).unwrap();
11706 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11707 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11708 let marks = text_node.marks.as_ref().expect("should have marks");
11709 let ann = marks
11710 .iter()
11711 .find(|m| m.mark_type == "annotation")
11712 .expect("should have annotation mark");
11713 let attrs = ann.attrs.as_ref().unwrap();
11714 assert_eq!(attrs["id"], "abc123");
11715 assert_eq!(attrs["annotationType"], "inlineComment");
11716 }
11717
11718 #[test]
11719 fn annotation_mark_with_bold() {
11720 let doc = AdfDocument {
11722 version: 1,
11723 doc_type: "doc".to_string(),
11724 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11725 "bold comment",
11726 vec![
11727 AdfMark::strong(),
11728 AdfMark::annotation("def456", "inlineComment"),
11729 ],
11730 )])],
11731 };
11732 let md = adf_to_markdown(&doc).unwrap();
11733 let round_tripped = markdown_to_adf(&md).unwrap();
11734 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11735 let marks = text_node.marks.as_ref().expect("should have marks");
11736 assert!(
11737 marks.iter().any(|m| m.mark_type == "strong"),
11738 "should have strong mark"
11739 );
11740 assert!(
11741 marks.iter().any(|m| m.mark_type == "annotation"),
11742 "should have annotation mark"
11743 );
11744 }
11745
11746 #[test]
11747 fn annotation_and_link_marks_both_preserved() {
11748 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11750 {"type":"text","text":"HANGUL-8","marks":[
11751 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11752 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11753 ]}
11754 ]}]}"#;
11755 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11756 let md = adf_to_markdown(&doc).unwrap();
11757 assert!(
11759 md.contains("annotation-id="),
11760 "JFM should contain annotation-id, got: {md}"
11761 );
11762 assert!(
11763 md.contains("](https://"),
11764 "JFM should contain link href, got: {md}"
11765 );
11766 let round_tripped = markdown_to_adf(&md).unwrap();
11767 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11768 let marks = text_node.marks.as_ref().expect("should have marks");
11769 assert!(
11770 marks.iter().any(|m| m.mark_type == "annotation"),
11771 "should have annotation mark, got: {:?}",
11772 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11773 );
11774 assert!(
11775 marks.iter().any(|m| m.mark_type == "link"),
11776 "should have link mark, got: {:?}",
11777 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11778 );
11779 }
11780
11781 #[test]
11782 fn annotation_and_code_marks_both_preserved() {
11783 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11785 {"type":"text","text":"some text with "},
11786 {"type":"text","text":"annotated code","marks":[
11787 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11788 {"type":"code"}
11789 ]},
11790 {"type":"text","text":" remaining text"}
11791 ]}]}"#;
11792 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11793 let md = adf_to_markdown(&doc).unwrap();
11794 assert!(
11795 md.contains("annotation-id="),
11796 "JFM should contain annotation-id, got: {md}"
11797 );
11798 assert!(
11799 md.contains('`'),
11800 "JFM should contain backticks for code, got: {md}"
11801 );
11802
11803 let round_tripped = markdown_to_adf(&md).unwrap();
11804 let nodes = round_tripped.content[0].content.as_ref().unwrap();
11805 let code_node = nodes
11807 .iter()
11808 .find(|n| n.text.as_deref() == Some("annotated code"))
11809 .expect("should have 'annotated code' text node");
11810 let marks = code_node.marks.as_ref().expect("should have marks");
11811 assert!(
11812 marks.iter().any(|m| m.mark_type == "annotation"),
11813 "should have annotation mark, got: {:?}",
11814 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11815 );
11816 assert!(
11817 marks.iter().any(|m| m.mark_type == "code"),
11818 "should have code mark, got: {:?}",
11819 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11820 );
11821 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11822 let attrs = ann.attrs.as_ref().unwrap();
11823 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11824 assert_eq!(attrs["annotationType"], "inlineComment");
11825 }
11826
11827 #[test]
11828 fn annotation_and_code_and_link_marks_all_preserved() {
11829 let doc = AdfDocument {
11831 version: 1,
11832 doc_type: "doc".to_string(),
11833 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11834 "linked code",
11835 vec![
11836 AdfMark::annotation("ann-001", "inlineComment"),
11837 AdfMark::code(),
11838 AdfMark::link("https://example.com"),
11839 ],
11840 )])],
11841 };
11842 let md = adf_to_markdown(&doc).unwrap();
11843 assert!(
11844 md.contains("annotation-id="),
11845 "JFM should contain annotation-id, got: {md}"
11846 );
11847 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11848 assert!(
11849 md.contains("](https://example.com)"),
11850 "JFM should contain link, got: {md}"
11851 );
11852
11853 let round_tripped = markdown_to_adf(&md).unwrap();
11854 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11855 let marks = text_node.marks.as_ref().expect("should have marks");
11856 assert!(
11857 marks.iter().any(|m| m.mark_type == "annotation"),
11858 "should have annotation mark, got: {:?}",
11859 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11860 );
11861 assert!(
11862 marks.iter().any(|m| m.mark_type == "code"),
11863 "should have code mark, got: {:?}",
11864 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11865 );
11866 assert!(
11867 marks.iter().any(|m| m.mark_type == "link"),
11868 "should have link mark, got: {:?}",
11869 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11870 );
11871 }
11872
11873 #[test]
11874 fn multiple_annotations_and_code_mark_preserved() {
11875 let doc = AdfDocument {
11877 version: 1,
11878 doc_type: "doc".to_string(),
11879 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11880 "doubly annotated",
11881 vec![
11882 AdfMark::annotation("ann-aaa", "inlineComment"),
11883 AdfMark::annotation("ann-bbb", "inlineComment"),
11884 AdfMark::code(),
11885 ],
11886 )])],
11887 };
11888 let md = adf_to_markdown(&doc).unwrap();
11889 assert!(
11890 md.contains("ann-aaa"),
11891 "JFM should contain first annotation id, got: {md}"
11892 );
11893 assert!(
11894 md.contains("ann-bbb"),
11895 "JFM should contain second annotation id, got: {md}"
11896 );
11897
11898 let round_tripped = markdown_to_adf(&md).unwrap();
11899 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11900 let marks = text_node.marks.as_ref().expect("should have marks");
11901 let ann_marks: Vec<_> = marks
11902 .iter()
11903 .filter(|m| m.mark_type == "annotation")
11904 .collect();
11905 assert_eq!(
11906 ann_marks.len(),
11907 2,
11908 "should have 2 annotation marks, got: {}",
11909 ann_marks.len()
11910 );
11911 assert!(
11912 marks.iter().any(|m| m.mark_type == "code"),
11913 "should have code mark"
11914 );
11915 }
11916
11917 #[test]
11918 fn underline_and_link_marks_both_preserved() {
11919 let doc = AdfDocument {
11921 version: 1,
11922 doc_type: "doc".to_string(),
11923 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11924 "click here",
11925 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
11926 )])],
11927 };
11928 let md = adf_to_markdown(&doc).unwrap();
11929 assert!(md.contains("underline"), "should have underline attr: {md}");
11930 assert!(
11931 md.contains("](https://example.com)"),
11932 "should have link: {md}"
11933 );
11934 let round_tripped = markdown_to_adf(&md).unwrap();
11935 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11936 let marks = text_node.marks.as_ref().expect("should have marks");
11937 assert!(marks.iter().any(|m| m.mark_type == "underline"));
11938 assert!(marks.iter().any(|m| m.mark_type == "link"));
11939 }
11940
11941 #[test]
11942 fn annotation_link_and_bold_all_preserved() {
11943 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11945 {"type":"text","text":"important","marks":[
11946 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
11947 {"type":"link","attrs":{"href":"https://example.com"}},
11948 {"type":"strong"}
11949 ]}
11950 ]}]}"#;
11951 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11952 let md = adf_to_markdown(&doc).unwrap();
11953 let round_tripped = markdown_to_adf(&md).unwrap();
11954 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11955 let marks = text_node.marks.as_ref().expect("should have marks");
11956 assert!(
11957 marks.iter().any(|m| m.mark_type == "annotation"),
11958 "should have annotation"
11959 );
11960 assert!(
11961 marks.iter().any(|m| m.mark_type == "link"),
11962 "should have link"
11963 );
11964 assert!(
11965 marks.iter().any(|m| m.mark_type == "strong"),
11966 "should have strong"
11967 );
11968 }
11969
11970 #[test]
11971 fn multiple_annotation_marks_round_trip() {
11972 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11974 {"type":"text","text":"some annotated text","marks":[
11975 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
11976 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
11977 ]}
11978 ]}]}"#;
11979 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11980
11981 let md = adf_to_markdown(&doc).unwrap();
11982 assert!(
11983 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
11984 "JFM should contain first annotation id, got: {md}"
11985 );
11986 assert!(
11987 md.contains("ffffffff-1111-2222-3333-444444444444"),
11988 "JFM should contain second annotation id, got: {md}"
11989 );
11990
11991 let round_tripped = markdown_to_adf(&md).unwrap();
11992 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11993 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
11994 let marks = text_node.marks.as_ref().expect("should have marks");
11995 let annotations: Vec<_> = marks
11996 .iter()
11997 .filter(|m| m.mark_type == "annotation")
11998 .collect();
11999 assert_eq!(
12000 annotations.len(),
12001 2,
12002 "should have 2 annotation marks, got: {annotations:?}"
12003 );
12004 let ids: Vec<_> = annotations
12005 .iter()
12006 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12007 .collect();
12008 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12009 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12010 }
12011
12012 #[test]
12013 fn three_annotation_marks_round_trip() {
12014 let doc = AdfDocument {
12016 version: 1,
12017 doc_type: "doc".to_string(),
12018 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12019 "triple annotated",
12020 vec![
12021 AdfMark::annotation("id-1", "inlineComment"),
12022 AdfMark::annotation("id-2", "inlineComment"),
12023 AdfMark::annotation("id-3", "inlineComment"),
12024 ],
12025 )])],
12026 };
12027 let md = adf_to_markdown(&doc).unwrap();
12028 let round_tripped = markdown_to_adf(&md).unwrap();
12029 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12030 let marks = text_node.marks.as_ref().expect("should have marks");
12031 let annotations: Vec<_> = marks
12032 .iter()
12033 .filter(|m| m.mark_type == "annotation")
12034 .collect();
12035 assert_eq!(
12036 annotations.len(),
12037 3,
12038 "should have 3 annotation marks, got: {annotations:?}"
12039 );
12040 }
12041
12042 #[test]
12043 fn multiple_annotations_with_bold_round_trip() {
12044 let doc = AdfDocument {
12046 version: 1,
12047 doc_type: "doc".to_string(),
12048 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12049 "bold double annotated",
12050 vec![
12051 AdfMark::strong(),
12052 AdfMark::annotation("ann-a", "inlineComment"),
12053 AdfMark::annotation("ann-b", "inlineComment"),
12054 ],
12055 )])],
12056 };
12057 let md = adf_to_markdown(&doc).unwrap();
12058 let round_tripped = markdown_to_adf(&md).unwrap();
12059 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12060 let marks = text_node.marks.as_ref().expect("should have marks");
12061 assert!(
12062 marks.iter().any(|m| m.mark_type == "strong"),
12063 "should have strong mark"
12064 );
12065 let annotations: Vec<_> = marks
12066 .iter()
12067 .filter(|m| m.mark_type == "annotation")
12068 .collect();
12069 assert_eq!(
12070 annotations.len(),
12071 2,
12072 "should have 2 annotation marks, got: {annotations:?}"
12073 );
12074 }
12075
12076 #[test]
12077 fn multiple_annotations_with_link_round_trip() {
12078 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12080 {"type":"text","text":"linked text","marks":[
12081 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12082 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12083 {"type":"link","attrs":{"href":"https://example.com"}}
12084 ]}
12085 ]}]}"#;
12086 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12087 let md = adf_to_markdown(&doc).unwrap();
12088 let round_tripped = markdown_to_adf(&md).unwrap();
12089 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12090 let marks = text_node.marks.as_ref().expect("should have marks");
12091 assert!(
12092 marks.iter().any(|m| m.mark_type == "link"),
12093 "should have link mark"
12094 );
12095 let annotations: Vec<_> = marks
12096 .iter()
12097 .filter(|m| m.mark_type == "annotation")
12098 .collect();
12099 assert_eq!(
12100 annotations.len(),
12101 2,
12102 "should have 2 annotation marks, got: {annotations:?}"
12103 );
12104 }
12105
12106 #[test]
12109 fn annotation_on_emoji_round_trip() {
12110 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12112 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12113 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12114 ]},
12115 {"type":"text","text":" annotated text","marks":[
12116 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12117 ]}
12118 ]}]}"#;
12119 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12120 let md = adf_to_markdown(&doc).unwrap();
12121 assert!(
12122 md.contains("annotation-id="),
12123 "JFM should contain annotation-id for emoji, got: {md}"
12124 );
12125
12126 let round_tripped = markdown_to_adf(&md).unwrap();
12127 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12128
12129 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12131 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12132 assert!(
12133 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12134 "emoji should have annotation mark, got: {emoji_marks:?}"
12135 );
12136 let ann = emoji_marks
12137 .iter()
12138 .find(|m| m.mark_type == "annotation")
12139 .unwrap();
12140 assert_eq!(
12141 ann.attrs.as_ref().unwrap()["id"],
12142 "ccddee11-2233-4455-aabb-ccddee112233"
12143 );
12144
12145 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12147 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12148 assert!(
12149 text_marks.iter().any(|m| m.mark_type == "annotation"),
12150 "text should have annotation mark"
12151 );
12152 }
12153
12154 #[test]
12155 fn annotation_on_status_round_trip() {
12156 let mut status = AdfNode::status("In Progress", "blue");
12157 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12158
12159 let doc = AdfDocument {
12160 version: 1,
12161 doc_type: "doc".to_string(),
12162 content: vec![AdfNode::paragraph(vec![status])],
12163 };
12164 let md = adf_to_markdown(&doc).unwrap();
12165 assert!(
12166 md.contains("annotation-id="),
12167 "JFM should contain annotation-id for status, got: {md}"
12168 );
12169
12170 let round_tripped = markdown_to_adf(&md).unwrap();
12171 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12172 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12173 let marks = status_node
12174 .marks
12175 .as_ref()
12176 .expect("status should have marks");
12177 assert!(
12178 marks.iter().any(|m| m.mark_type == "annotation"),
12179 "status should have annotation mark, got: {marks:?}"
12180 );
12181 }
12182
12183 #[test]
12184 fn annotation_on_date_round_trip() {
12185 let mut date = AdfNode::date("1704067200000");
12186 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12187
12188 let doc = AdfDocument {
12189 version: 1,
12190 doc_type: "doc".to_string(),
12191 content: vec![AdfNode::paragraph(vec![date])],
12192 };
12193 let md = adf_to_markdown(&doc).unwrap();
12194 assert!(
12195 md.contains("annotation-id="),
12196 "JFM should contain annotation-id for date, got: {md}"
12197 );
12198
12199 let round_tripped = markdown_to_adf(&md).unwrap();
12200 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12201 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12202 let marks = date_node.marks.as_ref().expect("date should have marks");
12203 assert!(
12204 marks.iter().any(|m| m.mark_type == "annotation"),
12205 "date should have annotation mark, got: {marks:?}"
12206 );
12207 }
12208
12209 #[test]
12210 fn annotation_on_mention_round_trip() {
12211 let mut mention = AdfNode::mention("user-123", "@Alice");
12212 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12213
12214 let doc = AdfDocument {
12215 version: 1,
12216 doc_type: "doc".to_string(),
12217 content: vec![AdfNode::paragraph(vec![mention])],
12218 };
12219 let md = adf_to_markdown(&doc).unwrap();
12220 assert!(
12221 md.contains("annotation-id="),
12222 "JFM should contain annotation-id for mention, got: {md}"
12223 );
12224
12225 let round_tripped = markdown_to_adf(&md).unwrap();
12226 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12227 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12228 let marks = mention_node
12229 .marks
12230 .as_ref()
12231 .expect("mention should have marks");
12232 assert!(
12233 marks.iter().any(|m| m.mark_type == "annotation"),
12234 "mention should have annotation mark, got: {marks:?}"
12235 );
12236 }
12237
12238 #[test]
12239 fn annotation_on_inline_card_round_trip() {
12240 let mut card = AdfNode::inline_card("https://example.com");
12241 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12242
12243 let doc = AdfDocument {
12244 version: 1,
12245 doc_type: "doc".to_string(),
12246 content: vec![AdfNode::paragraph(vec![card])],
12247 };
12248 let md = adf_to_markdown(&doc).unwrap();
12249 assert!(
12250 md.contains("annotation-id="),
12251 "JFM should contain annotation-id for inlineCard, got: {md}"
12252 );
12253
12254 let round_tripped = markdown_to_adf(&md).unwrap();
12255 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12256 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12257 let marks = card_node
12258 .marks
12259 .as_ref()
12260 .expect("inlineCard should have marks");
12261 assert!(
12262 marks.iter().any(|m| m.mark_type == "annotation"),
12263 "inlineCard should have annotation mark, got: {marks:?}"
12264 );
12265 }
12266
12267 #[test]
12268 fn annotation_on_placeholder_round_trip() {
12269 let mut placeholder = AdfNode::placeholder("Enter text here");
12270 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12271
12272 let doc = AdfDocument {
12273 version: 1,
12274 doc_type: "doc".to_string(),
12275 content: vec![AdfNode::paragraph(vec![placeholder])],
12276 };
12277 let md = adf_to_markdown(&doc).unwrap();
12278 assert!(
12279 md.contains("annotation-id="),
12280 "JFM should contain annotation-id for placeholder, got: {md}"
12281 );
12282
12283 let round_tripped = markdown_to_adf(&md).unwrap();
12284 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12285 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12286 let marks = ph_node
12287 .marks
12288 .as_ref()
12289 .expect("placeholder should have marks");
12290 assert!(
12291 marks.iter().any(|m| m.mark_type == "annotation"),
12292 "placeholder should have annotation mark, got: {marks:?}"
12293 );
12294 }
12295
12296 #[test]
12297 fn multiple_annotations_on_emoji_round_trip() {
12298 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12300 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12301 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12302 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12303 ]}
12304 ]}]}"#;
12305 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12306 let md = adf_to_markdown(&doc).unwrap();
12307
12308 let round_tripped = markdown_to_adf(&md).unwrap();
12309 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12310 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12311 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12312 let annotations: Vec<_> = marks
12313 .iter()
12314 .filter(|m| m.mark_type == "annotation")
12315 .collect();
12316 assert_eq!(
12317 annotations.len(),
12318 2,
12319 "emoji should have 2 annotation marks, got: {annotations:?}"
12320 );
12321 }
12322
12323 #[test]
12324 fn emoji_without_annotation_unchanged() {
12325 let doc = AdfDocument {
12327 version: 1,
12328 doc_type: "doc".to_string(),
12329 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12330 };
12331 let md = adf_to_markdown(&doc).unwrap();
12332 assert!(
12334 !md.contains('['),
12335 "emoji without annotation should not be wrapped in brackets, got: {md}"
12336 );
12337 assert!(md.contains(":fire:"));
12338 }
12339
12340 #[test]
12343 fn status_directive() {
12344 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12345 let content = doc.content[0].content.as_ref().unwrap();
12346 assert_eq!(content[1].node_type, "status");
12347 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12348 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12349 }
12350
12351 #[test]
12352 fn adf_status_to_markdown() {
12353 let doc = AdfDocument {
12354 version: 1,
12355 doc_type: "doc".to_string(),
12356 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12357 };
12358 let md = adf_to_markdown(&doc).unwrap();
12359 assert!(md.contains(":status[Done]{color=green}"));
12360 }
12361
12362 #[test]
12363 fn round_trip_status() {
12364 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12365 let doc = markdown_to_adf(md).unwrap();
12366 let result = adf_to_markdown(&doc).unwrap();
12367 assert!(result.contains(":status[In Progress]{color=blue}"));
12368 }
12369
12370 #[test]
12371 fn status_with_style_and_localid_roundtrips() {
12372 let adf = AdfDocument {
12373 version: 1,
12374 doc_type: "doc".to_string(),
12375 content: vec![AdfNode::paragraph(vec![{
12376 let mut node = AdfNode::status("open", "green");
12377 node.attrs.as_mut().unwrap()["style"] =
12378 serde_json::Value::String("bold".to_string());
12379 node.attrs.as_mut().unwrap()["localId"] =
12380 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12381 node
12382 }])],
12383 };
12384
12385 let md = adf_to_markdown(&adf).unwrap();
12386 assert!(
12387 md.contains("style=bold"),
12388 "Markdown should contain style attr: {md}"
12389 );
12390 assert!(
12391 md.contains("localId=d2205ca5"),
12392 "Markdown should contain localId attr: {md}"
12393 );
12394
12395 let rt = markdown_to_adf(&md).unwrap();
12396 let status = &rt.content[0].content.as_ref().unwrap()[0];
12397 let attrs = status.attrs.as_ref().unwrap();
12398 assert_eq!(attrs["text"], "open");
12399 assert_eq!(attrs["color"], "green");
12400 assert_eq!(attrs["style"], "bold");
12401 assert_eq!(
12402 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12403 "localId should be preserved, got: {}",
12404 attrs["localId"]
12405 );
12406 }
12407
12408 #[test]
12409 fn status_without_style_still_works() {
12410 let md = ":status[Done]{color=green}\n";
12411 let doc = markdown_to_adf(md).unwrap();
12412 let status = &doc.content[0].content.as_ref().unwrap()[0];
12413 let attrs = status.attrs.as_ref().unwrap();
12414 assert_eq!(attrs["text"], "Done");
12415 assert_eq!(attrs["color"], "green");
12416 assert!(
12418 attrs.get("style").is_none() || attrs["style"].is_null(),
12419 "style should not be set when not provided"
12420 );
12421 }
12422
12423 #[test]
12424 fn strip_local_ids_removes_localid_from_status() {
12425 let adf = AdfDocument {
12426 version: 1,
12427 doc_type: "doc".to_string(),
12428 content: vec![AdfNode::paragraph(vec![{
12429 let mut node = AdfNode::status("open", "green");
12430 node.attrs.as_mut().unwrap()["localId"] =
12431 serde_json::Value::String("real-uuid-here".to_string());
12432 node
12433 }])],
12434 };
12435 let opts = RenderOptions {
12436 strip_local_ids: true,
12437 };
12438 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12439 assert!(
12440 !md.contains("localId"),
12441 "localId should be stripped, got: {md}"
12442 );
12443 assert!(md.contains("color=green"), "color should be preserved");
12444 }
12445
12446 #[test]
12447 fn strip_local_ids_removes_localid_from_table() {
12448 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"}]}]}]}]}]}"#;
12449 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12450 let opts = RenderOptions {
12451 strip_local_ids: true,
12452 };
12453 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12454 assert!(
12455 !md.contains("localId"),
12456 "localId should be stripped from table, got: {md}"
12457 );
12458 assert!(md.contains("layout=default"), "layout should be preserved");
12459 }
12460
12461 #[test]
12462 fn default_options_preserve_localid() {
12463 let adf = AdfDocument {
12464 version: 1,
12465 doc_type: "doc".to_string(),
12466 content: vec![AdfNode::paragraph(vec![{
12467 let mut node = AdfNode::status("open", "green");
12468 node.attrs.as_mut().unwrap()["localId"] =
12469 serde_json::Value::String("real-uuid-here".to_string());
12470 node
12471 }])],
12472 };
12473 let md = adf_to_markdown(&adf).unwrap();
12474 assert!(
12475 md.contains("localId=real-uuid-here"),
12476 "Default should preserve localId, got: {md}"
12477 );
12478 }
12479
12480 #[test]
12481 fn mention_localid_roundtrip() {
12482 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12483 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12484 let md = adf_to_markdown(&doc).unwrap();
12485 assert!(
12486 md.contains("localId=m-001"),
12487 "mention should have localId in md: {md}"
12488 );
12489 let rt = markdown_to_adf(&md).unwrap();
12490 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12491 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12492 }
12493
12494 #[test]
12495 fn date_localid_roundtrip() {
12496 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-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=d-001"),
12501 "date should have localId in md: {md}"
12502 );
12503 let rt = markdown_to_adf(&md).unwrap();
12504 let date = &rt.content[0].content.as_ref().unwrap()[0];
12505 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12506 }
12507
12508 #[test]
12509 fn emoji_localid_roundtrip() {
12510 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-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=e-001"),
12515 "emoji should have localId in md: {md}"
12516 );
12517 let rt = markdown_to_adf(&md).unwrap();
12518 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12519 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12520 }
12521
12522 #[test]
12523 fn inline_card_localid_roundtrip() {
12524 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-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=c-001"),
12529 "inlineCard should have localId in md: {md}"
12530 );
12531 let rt = markdown_to_adf(&md).unwrap();
12532 let card = &rt.content[0].content.as_ref().unwrap()[0];
12533 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12534 }
12535
12536 #[test]
12537 fn strip_local_ids_removes_from_mention() {
12538 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12540 let opts = RenderOptions {
12541 strip_local_ids: true,
12542 };
12543 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12544 assert!(
12545 !md.contains("localId"),
12546 "localId should be stripped from mention: {md}"
12547 );
12548 assert!(md.contains("id=user123"), "other attrs should be preserved");
12549 }
12550
12551 #[test]
12552 fn strip_local_ids_removes_from_date() {
12553 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12554 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12555 let opts = RenderOptions {
12556 strip_local_ids: true,
12557 };
12558 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12559 assert!(
12560 !md.contains("localId"),
12561 "localId should be stripped from date: {md}"
12562 );
12563 }
12564
12565 #[test]
12566 fn strip_local_ids_removes_from_block_attrs() {
12567 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
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 block attrs: {md}"
12576 );
12577 }
12578
12579 #[test]
12580 fn table_cell_localid_roundtrip() {
12581 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"}]}]}]}]}]}"#;
12582 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583 let md = adf_to_markdown(&doc).unwrap();
12584 assert!(
12585 md.contains("localId=tc-001"),
12586 "tableCell should have localId in md: {md}"
12587 );
12588 let rt = markdown_to_adf(&md).unwrap();
12589 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12590 .content
12591 .as_ref()
12592 .unwrap()[0];
12593 assert_eq!(
12594 cell.attrs.as_ref().unwrap()["localId"],
12595 "tc-001",
12596 "tableCell localId should round-trip"
12597 );
12598 }
12599
12600 #[test]
12601 fn table_cell_border_mark_roundtrip() {
12602 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"}]}]}]}]}]}"##;
12603 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12604 let md = adf_to_markdown(&doc).unwrap();
12605 assert!(
12606 md.contains("border-color=#ff000033"),
12607 "tableCell should have border-color in md: {md}"
12608 );
12609 assert!(
12610 md.contains("border-size=2"),
12611 "tableCell should have border-size in md: {md}"
12612 );
12613 let rt = markdown_to_adf(&md).unwrap();
12614 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12615 .content
12616 .as_ref()
12617 .unwrap()[0];
12618 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12619 assert_eq!(marks.len(), 1);
12620 assert_eq!(marks[0].mark_type, "border");
12621 let attrs = marks[0].attrs.as_ref().unwrap();
12622 assert_eq!(attrs["color"], "#ff000033");
12623 assert_eq!(attrs["size"], 2);
12624 }
12625
12626 #[test]
12627 fn table_header_border_mark_roundtrip() {
12628 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"}]}]}]}]}]}"##;
12629 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12630 let md = adf_to_markdown(&doc).unwrap();
12631 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12632 assert!(md.contains("border-size=3"), "md: {md}");
12633 let rt = markdown_to_adf(&md).unwrap();
12634 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12635 .content
12636 .as_ref()
12637 .unwrap()[0];
12638 assert_eq!(cell.node_type, "tableHeader");
12639 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12640 assert_eq!(marks[0].mark_type, "border");
12641 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12642 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12643 }
12644
12645 #[test]
12646 fn table_cell_border_mark_with_attrs_roundtrip() {
12647 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"}]}]}]}]}]}"##;
12648 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12649 let md = adf_to_markdown(&doc).unwrap();
12650 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12651 assert!(md.contains("colspan=2"), "md: {md}");
12652 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12653 let rt = markdown_to_adf(&md).unwrap();
12654 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12655 .content
12656 .as_ref()
12657 .unwrap()[0];
12658 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12659 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12660 let marks = cell.marks.as_ref().expect("should have marks");
12661 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12662 }
12663
12664 #[test]
12665 fn table_cell_no_border_mark_unchanged() {
12666 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"}]}]}]}]}]}"#;
12667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12668 let md = adf_to_markdown(&doc).unwrap();
12669 assert!(
12670 !md.contains("border-color"),
12671 "no border attrs expected: {md}"
12672 );
12673 let rt = markdown_to_adf(&md).unwrap();
12674 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12675 .content
12676 .as_ref()
12677 .unwrap()[0];
12678 assert!(cell.marks.is_none(), "no marks expected on plain cell");
12679 }
12680
12681 #[test]
12682 fn table_cell_border_size_only_defaults_color() {
12683 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12686 let doc = markdown_to_adf(md).unwrap();
12687 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12688 .content
12689 .as_ref()
12690 .unwrap()[0];
12691 let marks = cell.marks.as_ref().expect("should have border mark");
12692 assert_eq!(marks[0].mark_type, "border");
12693 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12694 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12695 }
12696
12697 #[test]
12698 fn table_cell_border_color_only_defaults_size() {
12699 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12701 let doc = markdown_to_adf(md).unwrap();
12702 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12703 .content
12704 .as_ref()
12705 .unwrap()[0];
12706 let marks = cell.marks.as_ref().expect("should have border mark");
12707 assert_eq!(marks[0].mark_type, "border");
12708 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12709 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12710 }
12711
12712 #[test]
12713 fn media_file_border_mark_roundtrip() {
12714 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}}]}]}]}"##;
12715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716 let md = adf_to_markdown(&doc).unwrap();
12717 assert!(
12718 md.contains("border-color=#091e4224"),
12719 "media should have border-color in md: {md}"
12720 );
12721 assert!(
12722 md.contains("border-size=2"),
12723 "media should have border-size in md: {md}"
12724 );
12725 let rt = markdown_to_adf(&md).unwrap();
12726 let media_single = &rt.content[0];
12727 let media = &media_single.content.as_ref().unwrap()[0];
12728 assert_eq!(media.node_type, "media");
12729 let marks = media.marks.as_ref().expect("media should have marks");
12730 assert_eq!(marks.len(), 1);
12731 assert_eq!(marks[0].mark_type, "border");
12732 let attrs = marks[0].attrs.as_ref().unwrap();
12733 assert_eq!(attrs["color"], "#091e4224");
12734 assert_eq!(attrs["size"], 2);
12735 }
12736
12737 #[test]
12738 fn media_external_border_mark_roundtrip() {
12739 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}}]}]}]}"##;
12740 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12741 let md = adf_to_markdown(&doc).unwrap();
12742 assert!(
12743 md.contains("border-color=#ff0000"),
12744 "external media should have border-color in md: {md}"
12745 );
12746 assert!(
12747 md.contains("border-size=3"),
12748 "external media should have border-size in md: {md}"
12749 );
12750 let rt = markdown_to_adf(&md).unwrap();
12751 let media = &rt.content[0].content.as_ref().unwrap()[0];
12752 let marks = media.marks.as_ref().expect("media should have marks");
12753 assert_eq!(marks[0].mark_type, "border");
12754 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12755 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12756 }
12757
12758 #[test]
12759 fn media_file_no_border_mark_unchanged() {
12760 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}}]}]}"#;
12761 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12762 let md = adf_to_markdown(&doc).unwrap();
12763 assert!(
12764 !md.contains("border-color"),
12765 "no border attrs expected: {md}"
12766 );
12767 let rt = markdown_to_adf(&md).unwrap();
12768 let media = &rt.content[0].content.as_ref().unwrap()[0];
12769 assert!(media.marks.is_none(), "no marks expected on plain media");
12770 }
12771
12772 #[test]
12773 fn media_border_size_only_defaults_color() {
12774 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}}]}]}]}"##;
12775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776 let md = adf_to_markdown(&doc).unwrap();
12777 assert!(md.contains("border-size=4"), "md: {md}");
12778 let rt = markdown_to_adf(&md).unwrap();
12779 let media = &rt.content[0].content.as_ref().unwrap()[0];
12780 let marks = media.marks.as_ref().expect("should have border mark");
12781 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12782 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12783 }
12784
12785 #[test]
12786 fn media_border_color_only_defaults_size() {
12787 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"}}]}]}]}"##;
12788 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12789 let md = adf_to_markdown(&doc).unwrap();
12790 assert!(md.contains("border-color=#00ff00"), "md: {md}");
12791 let rt = markdown_to_adf(&md).unwrap();
12792 let media = &rt.content[0].content.as_ref().unwrap()[0];
12793 let marks = media.marks.as_ref().expect("should have border mark");
12794 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12795 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12796 }
12797
12798 #[test]
12799 fn media_border_with_other_attrs_roundtrip() {
12800 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}}]}]}]}"##;
12801 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12802 let md = adf_to_markdown(&doc).unwrap();
12803 assert!(md.contains("layout=wide"), "md: {md}");
12804 assert!(md.contains("mediaWidth=600"), "md: {md}");
12805 assert!(md.contains("border-color=#091e4224"), "md: {md}");
12806 assert!(md.contains("border-size=2"), "md: {md}");
12807 let rt = markdown_to_adf(&md).unwrap();
12808 let ms = &rt.content[0];
12809 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12810 let media = &ms.content.as_ref().unwrap()[0];
12811 let marks = media.marks.as_ref().expect("should have marks");
12812 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12813 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12814 }
12815
12816 #[test]
12817 fn table_row_localid_roundtrip() {
12818 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"}]}]}]}]}]}"#;
12819 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12820 let md = adf_to_markdown(&doc).unwrap();
12821 assert!(
12822 md.contains("localId=tr-001"),
12823 "tableRow should have localId in md: {md}"
12824 );
12825 let rt = markdown_to_adf(&md).unwrap();
12826 let row = &rt.content[0].content.as_ref().unwrap()[0];
12827 assert_eq!(
12828 row.attrs.as_ref().unwrap()["localId"],
12829 "tr-001",
12830 "tableRow localId should round-trip"
12831 );
12832 }
12833
12834 #[test]
12835 fn list_item_localid_roundtrip() {
12836 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"}]}]}]}]}"#;
12838 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12839 let md = adf_to_markdown(&doc).unwrap();
12840 assert!(
12841 md.contains("localId=li-001"),
12842 "listItem should have localId in md: {md}"
12843 );
12844 let rt = markdown_to_adf(&md).unwrap();
12846 let list = &rt.content[0];
12847 assert!(
12848 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12849 "bulletList should NOT have localId: {:?}",
12850 list.attrs
12851 );
12852 let item = &list.content.as_ref().unwrap()[0];
12853 assert_eq!(
12854 item.attrs.as_ref().unwrap()["localId"],
12855 "li-001",
12856 "listItem should have localId=li-001"
12857 );
12858 }
12859
12860 #[test]
12861 fn list_item_localid_not_promoted_to_parent() {
12862 let md = "- item {localId=li-002}\n";
12864 let doc = markdown_to_adf(md).unwrap();
12865 let list = &doc.content[0];
12866 assert!(
12867 list.attrs.is_none(),
12868 "bulletList should have no attrs: {:?}",
12869 list.attrs
12870 );
12871 let item = &list.content.as_ref().unwrap()[0];
12872 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12873 }
12874
12875 #[test]
12876 fn ordered_list_item_localid_roundtrip() {
12877 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"}]}]}]}]}"#;
12878 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12879 let md = adf_to_markdown(&doc).unwrap();
12880 assert!(md.contains("localId=oli-001"), "md: {md}");
12881 let rt = markdown_to_adf(&md).unwrap();
12882 let item = &rt.content[0].content.as_ref().unwrap()[0];
12883 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12884 }
12885
12886 #[test]
12887 fn task_item_localid_roundtrip() {
12888 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"}]}]}]}]}"#;
12889 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12890 let md = adf_to_markdown(&doc).unwrap();
12891 assert!(md.contains("localId=ti-001"), "md: {md}");
12892 let rt = markdown_to_adf(&md).unwrap();
12893 let item = &rt.content[0].content.as_ref().unwrap()[0];
12894 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
12895 }
12896
12897 #[test]
12900 fn task_list_short_localid_roundtrip() {
12901 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"}]}]}]}"#;
12902 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12903 let md = adf_to_markdown(&doc).unwrap();
12904 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12906 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
12907 assert!(
12909 !md.contains("localId=}"),
12910 "empty localId should not be emitted: {md}"
12911 );
12912 let rt = markdown_to_adf(&md).unwrap();
12913 let task_list = &rt.content[0];
12914 assert_eq!(task_list.node_type, "taskList");
12915 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
12917 let items = task_list.content.as_ref().unwrap();
12918 assert_eq!(items.len(), 2);
12919 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12921 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
12922 assert!(
12923 items[0].content.is_none(),
12924 "empty taskItem should have no content: {:?}",
12925 items[0].content
12926 );
12927 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
12929 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
12930 let content = items[1].content.as_ref().unwrap();
12931 assert_eq!(content.len(), 1);
12932 assert_eq!(content[0].text.as_deref(), Some("done task"));
12933 }
12934
12935 #[test]
12939 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
12940 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!!)"}]}]}]}]}"#;
12941 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12942 let md = adf_to_markdown(&doc).unwrap();
12943 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12945 let rt = markdown_to_adf(&md).unwrap();
12947 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
12948 let task_list = &rt.content[0];
12949 assert_eq!(task_list.node_type, "taskList");
12950 let items = task_list.content.as_ref().unwrap();
12951 assert_eq!(items.len(), 1);
12952 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12954 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
12955 let para = &items[0].content.as_ref().unwrap()[0];
12957 assert_eq!(para.node_type, "paragraph");
12958 let inlines = para.content.as_ref().unwrap();
12959 assert_eq!(inlines[0].node_type, "text");
12960 assert_eq!(
12961 inlines[0].text.as_deref(),
12962 Some("Engineering Onboarding Link")
12963 );
12964 assert_eq!(inlines[1].node_type, "hardBreak");
12965 assert_eq!(inlines[2].node_type, "text");
12966 assert_eq!(
12967 inlines[2].text.as_deref(),
12968 Some("(This has links to all the various useful tools!!)")
12969 );
12970 let rt_json = serde_json::to_string(&rt).unwrap();
12972 assert!(
12973 !rt_json.contains("{localId="),
12974 "localId attr syntax should not leak into ADF text: {rt_json}"
12975 );
12976 }
12977
12978 #[test]
12980 fn task_item_multiple_hardbreak_localids_roundtrip() {
12981 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"}]}]}]}]}"#;
12982 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12983 let md = adf_to_markdown(&doc).unwrap();
12984 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12985 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
12986 let rt = markdown_to_adf(&md).unwrap();
12987 let items = rt.content[0].content.as_ref().unwrap();
12988 assert_eq!(items.len(), 2);
12989 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12990 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
12991 for item in items {
12993 let para = &item.content.as_ref().unwrap()[0];
12994 assert_eq!(para.node_type, "paragraph");
12995 let inlines = para.content.as_ref().unwrap();
12996 assert_eq!(inlines[1].node_type, "hardBreak");
12997 }
12998 }
12999
13000 #[test]
13005 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13006 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"}]}]}]}"#;
13007 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13008 let md = adf_to_markdown(&doc).unwrap();
13009 assert!(
13011 md.contains(" (parenthetical"),
13012 "continuation line should be 2-space indented: {md}"
13013 );
13014 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13015 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13016 let rt = markdown_to_adf(&md).unwrap();
13017 assert_eq!(
13019 rt.content.len(),
13020 1,
13021 "should be one taskList: {:#?}",
13022 rt.content
13023 );
13024 assert_eq!(rt.content[0].node_type, "taskList");
13025 let items = rt.content[0].content.as_ref().unwrap();
13026 assert_eq!(items.len(), 2, "should have 2 taskItems");
13027 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13028 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13029 let first_content = items[0].content.as_ref().unwrap();
13031 assert!(
13032 first_content.iter().any(|n| n.node_type == "hardBreak"),
13033 "first item should contain hardBreak"
13034 );
13035 let second_content = items[1].content.as_ref().unwrap();
13037 assert_eq!(second_content[0].node_type, "text");
13038 assert_eq!(
13039 second_content[0].text.as_deref().unwrap(),
13040 "second task item"
13041 );
13042 }
13043
13044 #[test]
13047 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13048 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"}]}]}]}]}"#;
13049 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13050 let md = adf_to_markdown(&doc).unwrap();
13051 let rt = markdown_to_adf(&md).unwrap();
13052 assert_eq!(
13053 rt.content.len(),
13054 1,
13055 "should be one taskList: {:#?}",
13056 rt.content
13057 );
13058 let items = rt.content[0].content.as_ref().unwrap();
13059 assert_eq!(items.len(), 2);
13060 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13061 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13062 }
13063
13064 #[test]
13067 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13068 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"}]}]}]}"#;
13069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13070 let md = adf_to_markdown(&doc).unwrap();
13071 let rt = markdown_to_adf(&md).unwrap();
13072 assert_eq!(rt.content.len(), 1);
13073 let items = rt.content[0].content.as_ref().unwrap();
13074 assert_eq!(items.len(), 3);
13075 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13076 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13077 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13078 let mid_content = items[1].content.as_ref().unwrap();
13080 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13081 }
13082
13083 #[test]
13086 fn task_list_empty_localid_no_spurious_paragraph() {
13087 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"}]}]}]}"#;
13088 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13089 let md = adf_to_markdown(&doc).unwrap();
13090 assert!(
13091 !md.contains("{localId=}"),
13092 "empty localId should not be emitted: {md}"
13093 );
13094 let rt = markdown_to_adf(&md).unwrap();
13095 assert_eq!(
13096 rt.content.len(),
13097 1,
13098 "no spurious paragraph: {:#?}",
13099 rt.content
13100 );
13101 assert_eq!(rt.content[0].node_type, "taskList");
13102 }
13103
13104 #[test]
13106 fn task_list_localid_stripped() {
13107 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"}]}]}]}]}"#;
13108 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13109 let opts = RenderOptions {
13110 strip_local_ids: true,
13111 };
13112 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13113 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13114 }
13115
13116 #[test]
13118 fn task_item_no_content_emits_localid() {
13119 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"}}]}]}"#;
13120 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13121 let md = adf_to_markdown(&doc).unwrap();
13122 assert!(
13123 md.contains("localId=abc"),
13124 "localId should be emitted even without content: {md}"
13125 );
13126 let rt = markdown_to_adf(&md).unwrap();
13127 let item = &rt.content[0].content.as_ref().unwrap()[0];
13128 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13129 assert!(item.content.is_none(), "should have no content");
13130 }
13131
13132 #[test]
13134 fn task_list_localid_roundtrip() {
13135 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"}]}]}]}]}"#;
13136 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13137 let md = adf_to_markdown(&doc).unwrap();
13138 assert!(
13139 md.contains("localId=tl-xyz"),
13140 "taskList localId missing: {md}"
13141 );
13142 let rt = markdown_to_adf(&md).unwrap();
13143 assert_eq!(
13144 rt.content[0].attrs.as_ref().unwrap()["localId"],
13145 "tl-xyz",
13146 "taskList localId should survive round-trip"
13147 );
13148 }
13149
13150 #[test]
13152 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13153 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"}]}]}]}]}"#;
13154 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13155 let md = adf_to_markdown(&doc).unwrap();
13156 assert!(
13157 md.contains("paraLocalId=_"),
13158 "should emit paraLocalId=_ sentinel: {md}"
13159 );
13160 let rt = markdown_to_adf(&md).unwrap();
13161 let item = &rt.content[0].content.as_ref().unwrap()[0];
13162 let content = item.content.as_ref().unwrap();
13163 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13164 assert_eq!(
13165 content[0].node_type, "paragraph",
13166 "child should be a paragraph: {content:#?}"
13167 );
13168 let para_content = content[0].content.as_ref().unwrap();
13169 assert_eq!(
13170 para_content[0].text.as_deref(),
13171 Some("A task with paragraph wrapper")
13172 );
13173 assert!(
13175 content[0].attrs.is_none(),
13176 "paragraph should have no attrs: {:?}",
13177 content[0].attrs
13178 );
13179 }
13180
13181 #[test]
13183 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13184 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"}]}]}]}]}"#;
13185 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13186 let md = adf_to_markdown(&doc).unwrap();
13187 assert!(
13188 md.contains("paraLocalId=p-001"),
13189 "should emit paraLocalId=p-001: {md}"
13190 );
13191 let rt = markdown_to_adf(&md).unwrap();
13192 let item = &rt.content[0].content.as_ref().unwrap()[0];
13193 let content = item.content.as_ref().unwrap();
13194 assert_eq!(content[0].node_type, "paragraph");
13195 assert_eq!(
13196 content[0].attrs.as_ref().unwrap()["localId"],
13197 "p-001",
13198 "paragraph localId should be preserved"
13199 );
13200 }
13201
13202 #[test]
13204 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13205 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"}]}]}]}"#;
13206 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13207 let md = adf_to_markdown(&doc).unwrap();
13208 assert!(
13209 !md.contains("paraLocalId"),
13210 "should NOT emit paraLocalId for unwrapped inline: {md}"
13211 );
13212 let rt = markdown_to_adf(&md).unwrap();
13213 let item = &rt.content[0].content.as_ref().unwrap()[0];
13214 let content = item.content.as_ref().unwrap();
13215 assert_eq!(
13216 content[0].node_type, "text",
13217 "should remain unwrapped: {content:#?}"
13218 );
13219 }
13220
13221 #[test]
13223 fn task_item_done_paragraph_wrapper_roundtrip() {
13224 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"}]}]}]}]}"#;
13225 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13226 let md = adf_to_markdown(&doc).unwrap();
13227 assert!(md.contains("- [x]"), "should render as done: {md}");
13228 let rt = markdown_to_adf(&md).unwrap();
13229 let item = &rt.content[0].content.as_ref().unwrap()[0];
13230 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13231 let content = item.content.as_ref().unwrap();
13232 assert_eq!(content[0].node_type, "paragraph");
13233 }
13234
13235 #[test]
13237 fn task_item_mixed_paragraph_and_unwrapped_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":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"wrapped"}]}]},{"type":"taskItem","attrs":{"localId":"ti-002","state":"DONE"},"content":[{"type":"text","text":"unwrapped"}]}]}]}"#;
13239 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13240 let md = adf_to_markdown(&doc).unwrap();
13241 let rt = markdown_to_adf(&md).unwrap();
13242 let items = rt.content[0].content.as_ref().unwrap();
13243 assert_eq!(items.len(), 2);
13244 let c1 = items[0].content.as_ref().unwrap();
13246 assert_eq!(
13247 c1[0].node_type, "paragraph",
13248 "first item should have paragraph wrapper"
13249 );
13250 let c2 = items[1].content.as_ref().unwrap();
13252 assert_eq!(
13253 c2[0].node_type, "text",
13254 "second item should remain unwrapped"
13255 );
13256 }
13257
13258 #[test]
13260 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13261 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"}]}]}]}]}]}"#;
13262 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13263 let md = adf_to_markdown(&doc).unwrap();
13264 let rt = markdown_to_adf(&md).unwrap();
13265 let item = &rt.content[0].content.as_ref().unwrap()[0];
13266 let content = item.content.as_ref().unwrap();
13267 assert_eq!(content[0].node_type, "paragraph");
13268 let para_children = content[0].content.as_ref().unwrap();
13269 assert!(
13270 para_children.len() >= 2,
13271 "paragraph should contain multiple inline nodes"
13272 );
13273 }
13274
13275 #[test]
13277 fn task_item_paragraph_wrapper_stripped_with_option() {
13278 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"}]}]}]}]}"#;
13279 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13280 let opts = RenderOptions {
13281 strip_local_ids: true,
13282 };
13283 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13284 assert!(
13285 !md.contains("paraLocalId"),
13286 "paraLocalId should be stripped: {md}"
13287 );
13288 assert!(
13289 !md.contains("localId"),
13290 "all localIds should be stripped: {md}"
13291 );
13292 }
13293
13294 #[test]
13295 fn trailing_space_preserved_with_hex_localid() {
13296 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 "}]}]}]}]}"#;
13299 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13300 let md = adf_to_markdown(&doc).unwrap();
13301 let rt = markdown_to_adf(&md).unwrap();
13302 let item = &rt.content[0].content.as_ref().unwrap()[0];
13303 assert_eq!(
13304 item.attrs.as_ref().unwrap()["localId"],
13305 "aabb112233cc",
13306 "localId should round-trip"
13307 );
13308 let para = &item.content.as_ref().unwrap()[0];
13309 let inlines = para.content.as_ref().unwrap();
13310 let last = inlines.last().unwrap();
13311 assert!(
13312 last.text.as_deref().unwrap_or("").ends_with(' '),
13313 "trailing space should be preserved, got nodes: {:?}",
13314 inlines
13315 .iter()
13316 .map(|n| (&n.node_type, &n.text))
13317 .collect::<Vec<_>>()
13318 );
13319 }
13320
13321 #[test]
13322 fn extract_trailing_local_id_preserves_trailing_space() {
13323 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13325 assert_eq!(before, "trailing space ");
13326 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13327 }
13328
13329 #[test]
13330 fn extract_trailing_local_id_no_trailing_space() {
13331 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13332 assert_eq!(before, "text");
13333 assert_eq!(lid.as_deref(), Some("abc123"));
13334 }
13335
13336 #[test]
13337 fn extract_trailing_local_id_no_attrs() {
13338 let (before, lid, pid) = extract_trailing_local_id("plain text");
13339 assert_eq!(before, "plain text");
13340 assert!(lid.is_none());
13341 assert!(pid.is_none());
13342 }
13343
13344 #[test]
13345 fn list_item_localid_stripped() {
13346 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"}]}]}]}]}"#;
13347 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13348 let opts = RenderOptions {
13349 strip_local_ids: true,
13350 };
13351 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13352 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13353 }
13354
13355 #[test]
13356 fn paragraph_localid_in_list_item_roundtrip() {
13357 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"}]}]}]}]}"#;
13359 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13360 let md = adf_to_markdown(&doc).unwrap();
13361 assert!(
13362 md.contains("paraLocalId=para-001"),
13363 "paragraph localId should be in md: {md}"
13364 );
13365 let rt = markdown_to_adf(&md).unwrap();
13366 let item = &rt.content[0].content.as_ref().unwrap()[0];
13367 assert_eq!(
13368 item.attrs.as_ref().unwrap()["localId"],
13369 "item-001",
13370 "listItem localId should survive"
13371 );
13372 let para = &item.content.as_ref().unwrap()[0];
13373 assert_eq!(
13374 para.attrs.as_ref().unwrap()["localId"],
13375 "para-001",
13376 "paragraph localId should survive round-trip"
13377 );
13378 }
13379
13380 #[test]
13381 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13382 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"}]}]}]}]}"#;
13384 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13385 let md = adf_to_markdown(&doc).unwrap();
13386 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13387 let rt = markdown_to_adf(&md).unwrap();
13388 let item = &rt.content[0].content.as_ref().unwrap()[0];
13389 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13390 let para = &item.content.as_ref().unwrap()[0];
13391 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13392 }
13393
13394 #[test]
13395 fn paragraph_localid_only_in_list_item() {
13396 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"}]}]}]}]}"#;
13398 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13399 let md = adf_to_markdown(&doc).unwrap();
13400 assert!(
13401 md.contains("paraLocalId=para-only"),
13402 "paragraph localId should be emitted: {md}"
13403 );
13404 let rt = markdown_to_adf(&md).unwrap();
13405 let item = &rt.content[0].content.as_ref().unwrap()[0];
13406 assert!(item.attrs.is_none(), "listItem should have no attrs");
13407 let para = &item.content.as_ref().unwrap()[0];
13408 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13409 }
13410
13411 #[test]
13412 fn paragraph_localid_in_table_header_roundtrip() {
13413 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"}]}]}]}]}]}"#;
13415 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13416 let md = adf_to_markdown(&doc).unwrap();
13417 assert!(
13419 md.contains("localId=aaaa-aaaa"),
13420 "paragraph localId should be in md: {md}"
13421 );
13422 let rt = markdown_to_adf(&md).unwrap();
13423 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13424 .content
13425 .as_ref()
13426 .unwrap()[0];
13427 let para = &cell.content.as_ref().unwrap()[0];
13428 assert_eq!(
13429 para.attrs.as_ref().unwrap()["localId"],
13430 "aaaa-aaaa",
13431 "paragraph localId should survive round-trip in tableHeader"
13432 );
13433 }
13434
13435 #[test]
13436 fn paragraph_localid_in_table_cell_roundtrip() {
13437 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"}]}]}]}]}]}"#;
13439 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13440 let md = adf_to_markdown(&doc).unwrap();
13441 assert!(
13442 md.contains("localId=cell-para"),
13443 "paragraph localId should be in md: {md}"
13444 );
13445 let rt = markdown_to_adf(&md).unwrap();
13446 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13448 .content
13449 .as_ref()
13450 .unwrap()[0];
13451 let para = &cell.content.as_ref().unwrap()[0];
13452 assert_eq!(
13453 para.attrs.as_ref().unwrap()["localId"],
13454 "cell-para",
13455 "paragraph localId should survive round-trip in tableCell"
13456 );
13457 }
13458
13459 #[test]
13460 fn nbsp_paragraph_with_localid_roundtrip() {
13461 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13463 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13464 let md = adf_to_markdown(&doc).unwrap();
13465 assert!(
13466 md.contains("::paragraph["),
13467 "nbsp should use directive form: {md}"
13468 );
13469 assert!(
13470 md.contains("localId=nbsp-para"),
13471 "localId should be in directive: {md}"
13472 );
13473 let rt = markdown_to_adf(&md).unwrap();
13474 let para = &rt.content[0];
13475 assert_eq!(
13476 para.attrs.as_ref().unwrap()["localId"],
13477 "nbsp-para",
13478 "localId should survive round-trip"
13479 );
13480 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13481 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13482 }
13483
13484 #[test]
13485 fn empty_paragraph_with_localid_roundtrip() {
13486 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13488 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13489 let md = adf_to_markdown(&doc).unwrap();
13490 assert!(
13491 md.contains("::paragraph{localId=empty-para}"),
13492 "empty paragraph should include localId in directive: {md}"
13493 );
13494 let rt = markdown_to_adf(&md).unwrap();
13495 assert_eq!(
13496 rt.content[0].attrs.as_ref().unwrap()["localId"],
13497 "empty-para"
13498 );
13499 }
13500
13501 #[test]
13502 fn paragraph_localid_stripped_from_list_item() {
13503 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"}]}]}]}]}"#;
13505 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506 let opts = RenderOptions {
13507 strip_local_ids: true,
13508 };
13509 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13510 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13511 assert!(
13512 !md.contains("paraLocalId"),
13513 "paraLocalId should be stripped: {md}"
13514 );
13515 }
13516
13517 #[test]
13518 fn date_directive() {
13519 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13520 let content = doc.content[0].content.as_ref().unwrap();
13521 assert_eq!(content[1].node_type, "date");
13522 assert_eq!(
13524 content[1].attrs.as_ref().unwrap()["timestamp"],
13525 "1776211200000"
13526 );
13527 }
13528
13529 #[test]
13530 fn adf_date_to_markdown() {
13531 let doc = AdfDocument {
13533 version: 1,
13534 doc_type: "doc".to_string(),
13535 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13536 };
13537 let md = adf_to_markdown(&doc).unwrap();
13538 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13539 }
13540
13541 #[test]
13542 fn adf_date_iso_passthrough() {
13543 let doc = AdfDocument {
13545 version: 1,
13546 doc_type: "doc".to_string(),
13547 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13548 };
13549 let md = adf_to_markdown(&doc).unwrap();
13550 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13551 }
13552
13553 #[test]
13554 fn round_trip_date() {
13555 let md = "Due by :date[2026-04-15].\n";
13556 let doc = markdown_to_adf(md).unwrap();
13557 let result = adf_to_markdown(&doc).unwrap();
13558 assert!(result.contains(":date[2026-04-15]"));
13559 }
13560
13561 #[test]
13562 fn round_trip_date_non_midnight_timestamp() {
13563 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13566 let md = adf_to_markdown(&doc).unwrap();
13567 assert!(
13569 md.contains("timestamp=1700000000000"),
13570 "JFM should preserve original timestamp: {md}"
13571 );
13572 let doc2 = markdown_to_adf(&md).unwrap();
13574 let content = doc2.content[0].content.as_ref().unwrap();
13575 assert_eq!(
13576 content[0].attrs.as_ref().unwrap()["timestamp"],
13577 "1700000000000",
13578 "Round-trip must preserve original non-midnight timestamp"
13579 );
13580 }
13581
13582 #[test]
13583 fn date_epoch_ms_passthrough() {
13584 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13586 let content = doc.content[0].content.as_ref().unwrap();
13587 assert_eq!(
13588 content[1].attrs.as_ref().unwrap()["timestamp"],
13589 "1776211200000"
13590 );
13591 }
13592
13593 #[test]
13594 fn date_timestamp_attr_preferred_over_content() {
13595 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13597 let doc = markdown_to_adf(md).unwrap();
13598 let content = doc.content[0].content.as_ref().unwrap();
13599 assert_eq!(
13600 content[0].attrs.as_ref().unwrap()["timestamp"],
13601 "1700000000000",
13602 "timestamp attr should be used directly"
13603 );
13604 }
13605
13606 #[test]
13607 fn date_without_timestamp_attr_backward_compat() {
13608 let md = ":date[2026-04-15]\n";
13610 let doc = markdown_to_adf(md).unwrap();
13611 let content = doc.content[0].content.as_ref().unwrap();
13612 assert_eq!(
13613 content[0].attrs.as_ref().unwrap()["timestamp"],
13614 "1776211200000",
13615 "Should fall back to computing timestamp from date string"
13616 );
13617 }
13618
13619 #[test]
13620 fn date_with_local_id_and_timestamp() {
13621 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13623 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13624 let md = adf_to_markdown(&doc).unwrap();
13625 assert!(
13626 md.contains("timestamp=1700000000000"),
13627 "Should contain timestamp: {md}"
13628 );
13629 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13630 let doc2 = markdown_to_adf(&md).unwrap();
13632 let content = doc2.content[0].content.as_ref().unwrap();
13633 let attrs = content[0].attrs.as_ref().unwrap();
13634 assert_eq!(attrs["timestamp"], "1700000000000");
13635 assert_eq!(attrs["localId"], "d-001");
13636 }
13637
13638 #[test]
13639 fn mention_directive() {
13640 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13641 let content = doc.content[0].content.as_ref().unwrap();
13642 assert_eq!(content[1].node_type, "mention");
13643 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13644 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13645 }
13646
13647 #[test]
13648 fn adf_mention_to_markdown() {
13649 let doc = AdfDocument {
13650 version: 1,
13651 doc_type: "doc".to_string(),
13652 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13653 "abc123", "Alice",
13654 )])],
13655 };
13656 let md = adf_to_markdown(&doc).unwrap();
13657 assert!(md.contains(":mention[Alice]{id=abc123}"));
13658 }
13659
13660 #[test]
13661 fn round_trip_mention() {
13662 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13663 let doc = markdown_to_adf(md).unwrap();
13664 let result = adf_to_markdown(&doc).unwrap();
13665 assert!(result.contains(":mention[Alice]{id=abc123}"));
13666 }
13667
13668 #[test]
13669 fn mention_with_empty_access_level_round_trips() {
13670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13672 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13673 ]}]}"#;
13674 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13675
13676 let md = adf_to_markdown(&doc).unwrap();
13677 let round_tripped = markdown_to_adf(&md).unwrap();
13678 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13679 assert_eq!(
13680 mention.node_type, "mention",
13681 "mention with empty accessLevel was not parsed as mention, got: {}",
13682 mention.node_type
13683 );
13684 }
13685
13686 #[test]
13687 fn span_with_color() {
13688 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13689 let content = doc.content[0].content.as_ref().unwrap();
13690 assert_eq!(content[1].node_type, "text");
13691 assert_eq!(content[1].text.as_deref(), Some("red text"));
13692 let marks = content[1].marks.as_ref().unwrap();
13693 assert_eq!(marks[0].mark_type, "textColor");
13694 }
13695
13696 #[test]
13697 fn adf_text_color_to_markdown() {
13698 let doc = AdfDocument {
13699 version: 1,
13700 doc_type: "doc".to_string(),
13701 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13702 "red text",
13703 vec![AdfMark::text_color("#ff5630")],
13704 )])],
13705 };
13706 let md = adf_to_markdown(&doc).unwrap();
13707 assert!(md.contains(":span[red text]{color=#ff5630}"));
13708 }
13709
13710 #[test]
13711 fn round_trip_span_color() {
13712 let md = "This is :span[red text]{color=#ff5630}.\n";
13713 let doc = markdown_to_adf(md).unwrap();
13714 let result = adf_to_markdown(&doc).unwrap();
13715 assert!(result.contains(":span[red text]{color=#ff5630}"));
13716 }
13717
13718 #[test]
13719 fn text_color_and_link_marks_both_preserved() {
13720 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13722 {"type":"text","text":"red link","marks":[
13723 {"type":"link","attrs":{"href":"https://example.com"}},
13724 {"type":"textColor","attrs":{"color":"#ff0000"}}
13725 ]}
13726 ]}]}"##;
13727 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13728 let md = adf_to_markdown(&doc).unwrap();
13729 assert!(
13730 md.contains(":span[red link]{color=#ff0000}"),
13731 "JFM should contain span with color, got: {md}"
13732 );
13733 assert!(
13734 md.contains("](https://example.com)"),
13735 "JFM should contain link href, got: {md}"
13736 );
13737 let rt = markdown_to_adf(&md).unwrap();
13739 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13740 let marks = text_node.marks.as_ref().expect("should have marks");
13741 assert!(
13742 marks.iter().any(|m| m.mark_type == "textColor"),
13743 "should have textColor mark, got: {:?}",
13744 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13745 );
13746 assert!(
13747 marks.iter().any(|m| m.mark_type == "link"),
13748 "should have link mark, got: {:?}",
13749 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13750 );
13751 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13753 assert_eq!(
13754 link_mark.attrs.as_ref().unwrap()["href"],
13755 "https://example.com"
13756 );
13757 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13758 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13759 }
13760
13761 #[test]
13762 fn bg_color_and_link_marks_both_preserved() {
13763 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13764 {"type":"text","text":"highlighted link","marks":[
13765 {"type":"link","attrs":{"href":"https://example.com"}},
13766 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13767 ]}
13768 ]}]}"##;
13769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13770 let md = adf_to_markdown(&doc).unwrap();
13771 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13772 assert!(
13773 md.contains("](https://example.com)"),
13774 "should have link: {md}"
13775 );
13776 let rt = markdown_to_adf(&md).unwrap();
13777 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13778 let marks = text_node.marks.as_ref().expect("should have marks");
13779 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13780 assert!(marks.iter().any(|m| m.mark_type == "link"));
13781 }
13782
13783 #[test]
13784 fn text_color_link_and_strong_rendering() {
13785 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13787 {"type":"text","text":"bold red link","marks":[
13788 {"type":"strong"},
13789 {"type":"link","attrs":{"href":"https://example.com"}},
13790 {"type":"textColor","attrs":{"color":"#ff0000"}}
13791 ]}
13792 ]}]}"##;
13793 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13794 let md = adf_to_markdown(&doc).unwrap();
13795 assert!(
13796 md.starts_with("**") && md.trim().ends_with("**"),
13797 "should have bold wrapping: {md}"
13798 );
13799 assert!(md.contains("color=#ff0000"), "should have color: {md}");
13800 assert!(
13801 md.contains("](https://example.com)"),
13802 "should have link: {md}"
13803 );
13804 }
13805
13806 #[test]
13807 fn subsup_and_link_marks_both_preserved() {
13808 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13809 {"type":"text","text":"note","marks":[
13810 {"type":"link","attrs":{"href":"https://example.com"}},
13811 {"type":"subsup","attrs":{"type":"sup"}}
13812 ]}
13813 ]}]}"#;
13814 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13815 let md = adf_to_markdown(&doc).unwrap();
13816 assert!(md.contains("sup"), "should have sup: {md}");
13817 assert!(
13818 md.contains("](https://example.com)"),
13819 "should have link: {md}"
13820 );
13821 let rt = markdown_to_adf(&md).unwrap();
13822 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13823 let marks = text_node.marks.as_ref().expect("should have marks");
13824 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13825 assert!(marks.iter().any(|m| m.mark_type == "link"));
13826 }
13827
13828 #[test]
13829 fn text_color_without_link_unchanged() {
13830 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13832 {"type":"text","text":"just red","marks":[
13833 {"type":"textColor","attrs":{"color":"#ff0000"}}
13834 ]}
13835 ]}]}"##;
13836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13837 let md = adf_to_markdown(&doc).unwrap();
13838 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13839 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13840 }
13841
13842 #[test]
13843 fn inline_extension_directive() {
13844 let doc =
13845 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13846 let content = doc.content[0].content.as_ref().unwrap();
13847 assert_eq!(content[1].node_type, "inlineExtension");
13848 assert_eq!(
13849 content[1].attrs.as_ref().unwrap()["extensionType"],
13850 "com.app"
13851 );
13852 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13853 }
13854
13855 #[test]
13856 fn adf_inline_extension_to_markdown() {
13857 let doc = AdfDocument {
13858 version: 1,
13859 doc_type: "doc".to_string(),
13860 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13861 "com.app",
13862 "widget",
13863 Some("fallback"),
13864 )])],
13865 };
13866 let md = adf_to_markdown(&doc).unwrap();
13867 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13868 }
13869
13870 #[test]
13873 fn parse_ordered_list_marker_valid() {
13874 let result = parse_ordered_list_marker("1. Hello");
13875 assert_eq!(result, Some((1, "Hello")));
13876 }
13877
13878 #[test]
13879 fn parse_ordered_list_marker_high_number() {
13880 let result = parse_ordered_list_marker("42. Item");
13881 assert_eq!(result, Some((42, "Item")));
13882 }
13883
13884 #[test]
13885 fn parse_ordered_list_marker_not_a_list() {
13886 assert!(parse_ordered_list_marker("not a list").is_none());
13887 assert!(parse_ordered_list_marker("1.no space").is_none());
13888 }
13889
13890 #[test]
13891 fn is_list_start_various() {
13892 assert!(is_list_start("- item"));
13893 assert!(is_list_start("* item"));
13894 assert!(is_list_start("+ item"));
13895 assert!(is_list_start("1. item"));
13896 assert!(!is_list_start("not a list"));
13897 }
13898
13899 #[test]
13900 fn is_horizontal_rule_various() {
13901 assert!(is_horizontal_rule("---"));
13902 assert!(is_horizontal_rule("***"));
13903 assert!(is_horizontal_rule("___"));
13904 assert!(is_horizontal_rule("------"));
13905 assert!(!is_horizontal_rule("--"));
13906 assert!(!is_horizontal_rule("abc"));
13907 }
13908
13909 #[test]
13910 fn is_table_separator_valid() {
13911 assert!(is_table_separator("| --- | --- |"));
13912 assert!(is_table_separator("|:---:|:---|"));
13913 assert!(!is_table_separator("no pipes here"));
13914 }
13915
13916 #[test]
13917 fn parse_table_row_cells() {
13918 let cells = parse_table_row("| A | B | C |");
13919 assert_eq!(cells, vec!["A", "B", "C"]);
13920 }
13921
13922 #[test]
13923 fn parse_table_row_escaped_pipe_in_cell() {
13924 let cells = parse_table_row(r"| a\|b | c |");
13926 assert_eq!(cells, vec!["a|b", "c"]);
13927 }
13928
13929 #[test]
13930 fn parse_table_row_escaped_pipe_in_code_span() {
13931 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
13933 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
13934 }
13935
13936 #[test]
13937 fn parse_table_row_preserves_other_backslashes() {
13938 let cells = parse_table_row(r"| a\\b | c\*d |");
13940 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
13941 }
13942
13943 #[test]
13944 fn parse_image_syntax_valid() {
13945 let result = parse_image_syntax("");
13946 assert_eq!(result, Some(("alt", "url")));
13947 }
13948
13949 #[test]
13950 fn parse_image_syntax_not_image() {
13951 assert!(parse_image_syntax("not an image").is_none());
13952 }
13953
13954 #[test]
13957 fn find_closing_paren_simple() {
13958 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
13959 }
13960
13961 #[test]
13962 fn find_closing_paren_nested() {
13963 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
13964 }
13965
13966 #[test]
13967 fn find_closing_paren_unmatched() {
13968 assert_eq!(find_closing_paren("(no close", 0), None);
13969 }
13970
13971 #[test]
13972 fn find_closing_paren_offset() {
13973 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
13975 }
13976
13977 #[test]
13980 fn try_parse_link_url_with_parens() {
13981 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
13982 let result = try_parse_link(input, 0);
13983 assert_eq!(
13984 result,
13985 Some((
13986 input.len(),
13987 "here",
13988 "https://example.com/faq#access-(permissions)-rest"
13989 ))
13990 );
13991 }
13992
13993 #[test]
13994 fn try_parse_link_url_no_parens() {
13995 let input = "[text](https://example.com)";
13996 let result = try_parse_link(input, 0);
13997 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
13998 }
13999
14000 #[test]
14001 fn try_parse_link_url_with_multiple_nested_parens() {
14002 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14003 let result = try_parse_link(input, 0);
14004 assert_eq!(
14005 result,
14006 Some((
14007 input.len(),
14008 "x",
14009 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14010 ))
14011 );
14012 }
14013
14014 #[test]
14015 fn parse_image_syntax_url_with_parens() {
14016 let result = parse_image_syntax(")");
14017 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14018 }
14019
14020 #[test]
14021 fn parse_image_syntax_url_no_parens() {
14022 let result = parse_image_syntax("");
14023 assert_eq!(result, Some(("alt", "https://example.com")));
14024 }
14025
14026 #[test]
14027 fn link_with_parens_round_trip() {
14028 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14029 let mut text_node = AdfNode::text("here");
14030 text_node.marks = Some(vec![AdfMark::link(href)]);
14031 let adf_input = AdfDocument {
14032 version: 1,
14033 doc_type: "doc".to_string(),
14034 content: vec![AdfNode::paragraph(vec![text_node])],
14035 };
14036
14037 let jfm = adf_to_markdown(&adf_input).unwrap();
14038 let adf_output = markdown_to_adf(&jfm).unwrap();
14039
14040 let para = &adf_output.content[0];
14042 let text_node = ¶.content.as_ref().unwrap()[0];
14043 let mark = &text_node.marks.as_ref().unwrap()[0];
14044 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14045
14046 assert_eq!(result_href, href);
14047 }
14048
14049 #[test]
14050 fn flush_plain_empty_range() {
14051 let mut nodes = Vec::new();
14052 flush_plain("hello", 3, 3, &mut nodes);
14053 assert!(nodes.is_empty());
14054 }
14055
14056 #[test]
14057 fn add_mark_to_unmarked_node() {
14058 let mut node = AdfNode::text("test");
14059 add_mark(&mut node, AdfMark::strong());
14060 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14061 }
14062
14063 #[test]
14064 fn add_mark_to_marked_node() {
14065 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14066 add_mark(&mut node, AdfMark::em());
14067 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14068 }
14069
14070 #[test]
14073 fn directive_table_basic() {
14074 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";
14075 let doc = markdown_to_adf(md).unwrap();
14076 assert_eq!(doc.content[0].node_type, "table");
14077 let rows = doc.content[0].content.as_ref().unwrap();
14078 assert_eq!(rows.len(), 2);
14079 assert_eq!(
14080 rows[0].content.as_ref().unwrap()[0].node_type,
14081 "tableHeader"
14082 );
14083 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14084 }
14085
14086 #[test]
14087 fn directive_table_with_block_content() {
14088 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";
14089 let doc = markdown_to_adf(md).unwrap();
14090 let rows = doc.content[0].content.as_ref().unwrap();
14091 let cell = &rows[0].content.as_ref().unwrap()[0];
14092 let content = cell.content.as_ref().unwrap();
14094 assert!(content.len() >= 2);
14095 assert_eq!(content[1].node_type, "bulletList");
14096 }
14097
14098 #[test]
14099 fn directive_table_with_cell_attrs() {
14100 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14101 let doc = markdown_to_adf(md).unwrap();
14102 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14103 .content
14104 .as_ref()
14105 .unwrap()[0];
14106 let attrs = cell.attrs.as_ref().unwrap();
14107 assert_eq!(attrs["colspan"], 2);
14108 assert_eq!(attrs["background"], "#DEEBFF");
14109 }
14110
14111 #[test]
14112 fn directive_table_with_css_var_background() {
14113 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14114 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14115 let doc = markdown_to_adf(&md).unwrap();
14116 let row = &doc.content[0].content.as_ref().unwrap()[0];
14117 let cells = row.content.as_ref().unwrap();
14118 assert_eq!(cells.len(), 1, "row must have at least one cell");
14119 let attrs = cells[0].attrs.as_ref().unwrap();
14120 assert_eq!(attrs["background"], bg);
14121 }
14122
14123 #[test]
14124 fn css_var_background_round_trips() {
14125 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14126 let adf = AdfDocument {
14127 version: 1,
14128 doc_type: "doc".to_string(),
14129 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14130 AdfNode::table_header_with_attrs(
14131 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14132 serde_json::json!({"background": bg}),
14133 ),
14134 ])])],
14135 };
14136 let md = adf_to_markdown(&adf).unwrap();
14137 assert!(
14138 md.contains(&format!("bg=\"{bg}\"")),
14139 "bg value must be quoted in markdown: {md}"
14140 );
14141
14142 let round_tripped = markdown_to_adf(&md).unwrap();
14143 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14144 let cells = row.content.as_ref().unwrap();
14145 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14146 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14147 assert_eq!(rt_attrs["background"], bg);
14148 }
14149
14150 #[test]
14151 fn directive_table_with_table_attrs() {
14152 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14153 let doc = markdown_to_adf(md).unwrap();
14154 let attrs = doc.content[0].attrs.as_ref().unwrap();
14155 assert_eq!(attrs["layout"], "wide");
14156 assert_eq!(attrs["isNumberColumnEnabled"], true);
14157 }
14158
14159 #[test]
14160 fn adf_table_with_block_content_renders_directive_form() {
14161 let doc = AdfDocument {
14163 version: 1,
14164 doc_type: "doc".to_string(),
14165 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14166 AdfNode::table_cell(vec![
14167 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14168 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14169 AdfNode::text("Item 1"),
14170 ])])]),
14171 ]),
14172 ])])],
14173 };
14174 let md = adf_to_markdown(&doc).unwrap();
14175 assert!(md.contains("::::table"));
14176 assert!(md.contains(":::td"));
14177 assert!(md.contains("- Item 1"));
14178 }
14179
14180 #[test]
14181 fn adf_table_inline_only_renders_pipe_form() {
14182 let doc = AdfDocument {
14184 version: 1,
14185 doc_type: "doc".to_string(),
14186 content: vec![AdfNode::table(vec![
14187 AdfNode::table_row(vec![
14188 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14189 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14190 ]),
14191 AdfNode::table_row(vec![
14192 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14193 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14194 ]),
14195 ])],
14196 };
14197 let md = adf_to_markdown(&doc).unwrap();
14198 assert!(md.contains("| H1 | H2 |"));
14199 assert!(!md.contains("::::table"));
14200 }
14201
14202 #[test]
14203 fn adf_table_header_outside_first_row_renders_directive() {
14204 let doc = AdfDocument {
14205 version: 1,
14206 doc_type: "doc".to_string(),
14207 content: vec![AdfNode::table(vec![
14208 AdfNode::table_row(vec![
14209 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14210 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14211 ]),
14212 AdfNode::table_row(vec![
14213 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14214 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14215 ]),
14216 ])],
14217 };
14218 let md = adf_to_markdown(&doc).unwrap();
14219 assert!(md.contains("::::table"));
14220 assert!(md.contains(":::th"));
14221 }
14222
14223 #[test]
14224 fn adf_table_cell_attrs_rendered() {
14225 let doc = AdfDocument {
14226 version: 1,
14227 doc_type: "doc".to_string(),
14228 content: vec![AdfNode::table(vec![
14229 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14230 AdfNode::text("H"),
14231 ])])]),
14232 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14233 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14234 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14235 )]),
14236 ])],
14237 };
14238 let md = adf_to_markdown(&doc).unwrap();
14239 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14240 }
14241
14242 #[test]
14245 fn pipe_table_cell_attrs() {
14246 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14247 let doc = markdown_to_adf(md).unwrap();
14248 let rows = doc.content[0].content.as_ref().unwrap();
14249 let cell = &rows[1].content.as_ref().unwrap()[0];
14250 let attrs = cell.attrs.as_ref().unwrap();
14251 assert_eq!(attrs["background"], "#DEEBFF");
14252 }
14253
14254 #[test]
14255 fn pipe_table_cell_colspan() {
14256 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14257 let doc = markdown_to_adf(md).unwrap();
14258 let rows = doc.content[0].content.as_ref().unwrap();
14259 let cell = &rows[1].content.as_ref().unwrap()[0];
14260 let attrs = cell.attrs.as_ref().unwrap();
14261 assert_eq!(attrs["colspan"], 2);
14262 }
14263
14264 #[test]
14265 fn trailing_space_after_mention_in_table_cell_preserved() {
14266 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":[
14268 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14269 {"type":"text","text":" "}
14270 ]}]}]}]}]}"#;
14271 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14272 let md = adf_to_markdown(&doc).unwrap();
14273 let round_tripped = markdown_to_adf(&md).unwrap();
14274 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14275 .content
14276 .as_ref()
14277 .unwrap()[0];
14278 let para = &cell.content.as_ref().unwrap()[0];
14279 let inlines = para.content.as_ref().unwrap();
14280 assert!(
14281 inlines.len() >= 2,
14282 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14283 inlines.len(),
14284 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14285 );
14286 assert_eq!(inlines[0].node_type, "mention");
14287 assert_eq!(inlines[1].node_type, "text");
14288 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14289 }
14290
14291 #[test]
14294 fn pipe_table_column_alignment() {
14295 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14296 let doc = markdown_to_adf(md).unwrap();
14297 let rows = doc.content[0].content.as_ref().unwrap();
14298 let h_cells = rows[0].content.as_ref().unwrap();
14300 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14302 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14304 .marks
14305 .as_ref()
14306 .unwrap();
14307 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14308 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14310 .marks
14311 .as_ref()
14312 .unwrap();
14313 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14314 }
14315
14316 #[test]
14317 fn adf_table_alignment_roundtrip() {
14318 let doc = AdfDocument {
14319 version: 1,
14320 doc_type: "doc".to_string(),
14321 content: vec![AdfNode::table(vec![
14322 AdfNode::table_row(vec![
14323 AdfNode::table_header(vec![{
14324 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14325 p.marks = Some(vec![AdfMark::alignment("center")]);
14326 p
14327 }]),
14328 AdfNode::table_header(vec![{
14329 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14330 p.marks = Some(vec![AdfMark::alignment("end")]);
14331 p
14332 }]),
14333 ]),
14334 AdfNode::table_row(vec![
14335 AdfNode::table_cell(vec![{
14336 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14337 p.marks = Some(vec![AdfMark::alignment("center")]);
14338 p
14339 }]),
14340 AdfNode::table_cell(vec![{
14341 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14342 p.marks = Some(vec![AdfMark::alignment("end")]);
14343 p
14344 }]),
14345 ]),
14346 ])],
14347 };
14348 let md = adf_to_markdown(&doc).unwrap();
14349 assert!(md.contains(":---:"));
14350 assert!(md.contains("---:"));
14351 }
14352
14353 #[test]
14356 fn panel_custom_attrs_round_trip() {
14357 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14358 let doc = markdown_to_adf(md).unwrap();
14359 let panel = &doc.content[0];
14360 let attrs = panel.attrs.as_ref().unwrap();
14361 assert_eq!(attrs["panelType"], "custom");
14362 assert_eq!(attrs["panelIcon"], ":star:");
14363 assert_eq!(attrs["panelColor"], "#DEEBFF");
14364
14365 let result = adf_to_markdown(&doc).unwrap();
14366 assert!(result.contains("type=custom"));
14367 assert!(result.contains("icon="));
14368 assert!(result.contains("color="));
14369 }
14370
14371 #[test]
14374 fn block_card_with_layout() {
14375 let md = "::card[https://example.com]{layout=wide}\n";
14376 let doc = markdown_to_adf(md).unwrap();
14377 let attrs = doc.content[0].attrs.as_ref().unwrap();
14378 assert_eq!(attrs["layout"], "wide");
14379
14380 let result = adf_to_markdown(&doc).unwrap();
14381 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14382 }
14383
14384 #[test]
14387 fn extension_with_params() {
14388 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14389 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14390 let attrs = doc.content[0].attrs.as_ref().unwrap();
14391 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14392 }
14393
14394 #[test]
14395 fn leaf_extension_layout_preserved_in_roundtrip() {
14396 let adf_json = r#"{"version":1,"type":"doc","content":[
14398 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14399 ]}"#;
14400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14401 let md = adf_to_markdown(&doc).unwrap();
14402 assert!(
14403 md.contains("layout=default"),
14404 "JFM should contain layout=default, got: {md}"
14405 );
14406 let round_tripped = markdown_to_adf(&md).unwrap();
14407 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14408 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14409 assert_eq!(attrs["extensionKey"], "toc");
14410 }
14411
14412 #[test]
14413 fn bodied_extension_layout_preserved_in_roundtrip() {
14414 let adf_json = r#"{"version":1,"type":"doc","content":[
14416 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14417 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14418 ]}"#;
14419 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14420 let md = adf_to_markdown(&doc).unwrap();
14421 assert!(
14422 md.contains("layout=wide"),
14423 "JFM should contain layout=wide, got: {md}"
14424 );
14425 let round_tripped = markdown_to_adf(&md).unwrap();
14426 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14427 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14428 }
14429
14430 #[test]
14431 fn bodied_extension_parameters_preserved_in_roundtrip() {
14432 let adf_json = r#"{"version":1,"type":"doc","content":[
14434 {"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":{}}},
14435 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14436 ]}"#;
14437 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14438 let md = adf_to_markdown(&doc).unwrap();
14439 assert!(
14440 md.contains("params="),
14441 "JFM should contain params attribute, got: {md}"
14442 );
14443 let round_tripped = markdown_to_adf(&md).unwrap();
14444 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14445 assert_eq!(
14446 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14447 "parameters should be preserved in round-trip"
14448 );
14449 assert_eq!(attrs["extensionKey"], "details");
14450 assert_eq!(attrs["layout"], "default");
14451 assert_eq!(attrs["localId"], "aabbccdd-1234");
14452 }
14453
14454 #[test]
14455 fn bodied_extension_malformed_params_ignored() {
14456 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14458 let doc = markdown_to_adf(md).unwrap();
14459 let attrs = doc.content[0].attrs.as_ref().unwrap();
14460 assert_eq!(attrs["extensionKey"], "details");
14461 assert!(attrs.get("parameters").is_none());
14463 }
14464
14465 #[test]
14466 fn leaf_extension_localid_preserved_in_roundtrip() {
14467 let adf_json = r#"{"version":1,"type":"doc","content":[
14469 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14470 ]}"#;
14471 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14472 let md = adf_to_markdown(&doc).unwrap();
14473 let round_tripped = markdown_to_adf(&md).unwrap();
14474 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14475 assert_eq!(attrs["layout"], "default");
14476 assert_eq!(attrs["localId"], "abc-123");
14477 }
14478
14479 #[test]
14482 fn mention_with_user_type() {
14483 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14484 let doc = markdown_to_adf(md).unwrap();
14485 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14486 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14487
14488 let result = adf_to_markdown(&doc).unwrap();
14489 assert!(result.contains("userType=DEFAULT"));
14490 }
14491
14492 #[test]
14495 fn directive_table_colwidth() {
14496 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14497 let doc = markdown_to_adf(md).unwrap();
14498 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14499 .content
14500 .as_ref()
14501 .unwrap()[0];
14502 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14503 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14504 }
14505
14506 #[test]
14507 fn directive_table_colwidth_float_roundtrip() {
14508 let adf_doc = serde_json::json!({
14511 "type": "doc",
14512 "version": 1,
14513 "content": [{
14514 "type": "table",
14515 "content": [{
14516 "type": "tableRow",
14517 "content": [
14518 {
14519 "type": "tableHeader",
14520 "attrs": { "colwidth": [157.0] },
14521 "content": [{ "type": "paragraph" }]
14522 },
14523 {
14524 "type": "tableHeader",
14525 "attrs": { "colwidth": [863.0] },
14526 "content": [{ "type": "paragraph" }]
14527 }
14528 ]
14529 }]
14530 }]
14531 });
14532 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14533 let md = adf_to_markdown(&doc).unwrap();
14534 assert!(
14535 md.contains("colwidth=157.0"),
14536 "expected colwidth=157.0 in markdown, got: {md}"
14537 );
14538 assert!(
14539 md.contains("colwidth=863.0"),
14540 "expected colwidth=863.0 in markdown, got: {md}"
14541 );
14542 let doc2 = markdown_to_adf(&md).unwrap();
14544 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14545 let header1 = &row.content.as_ref().unwrap()[0];
14546 let header2 = &row.content.as_ref().unwrap()[1];
14547 assert_eq!(
14548 header1.attrs.as_ref().unwrap()["colwidth"]
14549 .as_array()
14550 .unwrap(),
14551 &[serde_json::json!(157.0)]
14552 );
14553 assert_eq!(
14554 header2.attrs.as_ref().unwrap()["colwidth"]
14555 .as_array()
14556 .unwrap(),
14557 &[serde_json::json!(863.0)]
14558 );
14559 }
14560
14561 #[test]
14562 fn colwidth_float_preserved_in_roundtrip() {
14563 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":[]}]}]}]}]}"#;
14565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14566 let md = adf_to_markdown(&doc).unwrap();
14567 let round_tripped = markdown_to_adf(&md).unwrap();
14568 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14569 .content
14570 .as_ref()
14571 .unwrap()[0];
14572 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14573 assert_eq!(
14574 colwidth,
14575 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14576 "colwidth should preserve float values"
14577 );
14578 }
14579
14580 #[test]
14581 fn colwidth_integer_preserved_in_roundtrip() {
14582 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"}]}]}]}]}]}"#;
14584 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14585 let md = adf_to_markdown(&doc).unwrap();
14586 assert!(
14587 md.contains("colwidth=150"),
14588 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14589 );
14590 assert!(
14591 !md.contains("colwidth=150.0"),
14592 "colwidth should not have .0 suffix for integers, got: {md}"
14593 );
14594 let round_tripped = markdown_to_adf(&md).unwrap();
14596 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14597 .content
14598 .as_ref()
14599 .unwrap()[0];
14600 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14601 assert_eq!(
14602 colwidth,
14603 &[serde_json::json!(150)],
14604 "colwidth should preserve integer values"
14605 );
14606 let json_output = serde_json::to_string(&round_tripped).unwrap();
14608 assert!(
14609 json_output.contains(r#""colwidth":[150]"#),
14610 "JSON should contain integer colwidth, got: {json_output}"
14611 );
14612 }
14613
14614 #[test]
14615 fn colwidth_mixed_int_and_float_roundtrip() {
14616 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14619 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14620
14621 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14623 let int_md = adf_to_markdown(&int_doc).unwrap();
14624 assert!(
14625 int_md.contains("colwidth=100,200"),
14626 "integer colwidth in md: {int_md}"
14627 );
14628 let int_rt = markdown_to_adf(&int_md).unwrap();
14629 let int_serial = serde_json::to_string(&int_rt).unwrap();
14630 assert!(
14631 int_serial.contains(r#""colwidth":[100,200]"#),
14632 "integer colwidth in JSON: {int_serial}"
14633 );
14634
14635 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14637 let float_md = adf_to_markdown(&float_doc).unwrap();
14638 assert!(
14639 float_md.contains("colwidth=100.0,200.0"),
14640 "float colwidth in md: {float_md}"
14641 );
14642 let float_rt = markdown_to_adf(&float_md).unwrap();
14643 let float_serial = serde_json::to_string(&float_rt).unwrap();
14644 assert!(
14645 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14646 "float colwidth in JSON: {float_serial}"
14647 );
14648 }
14649
14650 #[test]
14651 fn colwidth_fractional_float_preserved() {
14652 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"}]}]}]}]}]}"#;
14654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14655 let md = adf_to_markdown(&doc).unwrap();
14656 assert!(
14657 md.contains("colwidth=100.5"),
14658 "expected colwidth=100.5 in markdown, got: {md}"
14659 );
14660 }
14661
14662 #[test]
14663 fn colwidth_non_numeric_values_skipped() {
14664 let adf_doc = serde_json::json!({
14666 "type": "doc",
14667 "version": 1,
14668 "content": [{
14669 "type": "table",
14670 "content": [{
14671 "type": "tableRow",
14672 "content": [{
14673 "type": "tableCell",
14674 "attrs": { "colwidth": ["invalid"] },
14675 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14676 }]
14677 }]
14678 }]
14679 });
14680 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14681 let md = adf_to_markdown(&doc).unwrap();
14682 assert!(
14684 !md.contains("colwidth"),
14685 "non-numeric colwidth should be filtered out, got: {md}"
14686 );
14687 }
14688
14689 #[test]
14690 fn default_rowspan_colspan_preserved_in_roundtrip() {
14691 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"}]}]}]}]}]}"#;
14693 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14694 let md = adf_to_markdown(&doc).unwrap();
14695 let round_tripped = markdown_to_adf(&md).unwrap();
14696 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14697 .content
14698 .as_ref()
14699 .unwrap()[0];
14700 let attrs = cell.attrs.as_ref().unwrap();
14701 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14702 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14703 }
14704
14705 #[test]
14708 fn table_localid_preserved_in_roundtrip() {
14709 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"}]}]}]}]}]}"#;
14711 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14712 let md = adf_to_markdown(&doc).unwrap();
14713 assert!(
14714 md.contains("localId="),
14715 "JFM should contain localId, got: {md}"
14716 );
14717 let round_tripped = markdown_to_adf(&md).unwrap();
14718 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14719 assert_eq!(
14720 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14721 "localId should be preserved"
14722 );
14723 }
14724
14725 #[test]
14726 fn paragraph_localid_preserved_in_roundtrip() {
14727 let adf_json = r#"{"version":1,"type":"doc","content":[
14729 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14730 ]}"#;
14731 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14732 let md = adf_to_markdown(&doc).unwrap();
14733 assert!(
14734 md.contains("localId=abc-123"),
14735 "JFM should contain localId, got: {md}"
14736 );
14737 let round_tripped = markdown_to_adf(&md).unwrap();
14738 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14739 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14740 }
14741
14742 #[test]
14743 fn heading_localid_preserved_in_roundtrip() {
14744 let adf_json = r#"{"version":1,"type":"doc","content":[
14745 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14746 ]}"#;
14747 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14748 let md = adf_to_markdown(&doc).unwrap();
14749 let round_tripped = markdown_to_adf(&md).unwrap();
14750 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14751 assert_eq!(attrs["localId"], "h-456");
14752 }
14753
14754 #[test]
14755 fn localid_with_alignment_preserved() {
14756 let adf_json = r#"{"version":1,"type":"doc","content":[
14758 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14759 "content":[{"type":"text","text":"centered"}]}
14760 ]}"#;
14761 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762 let md = adf_to_markdown(&doc).unwrap();
14763 assert!(md.contains("localId=p-789"), "should have localId: {md}");
14764 assert!(md.contains("align=center"), "should have align: {md}");
14765 let round_tripped = markdown_to_adf(&md).unwrap();
14766 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14767 assert_eq!(attrs["localId"], "p-789");
14768 let marks = round_tripped.content[0].marks.as_ref().unwrap();
14769 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14770 }
14771
14772 #[test]
14773 fn table_layout_default_preserved_in_roundtrip() {
14774 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"}]}]}]}]}]}"#;
14776 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14777 let md = adf_to_markdown(&doc).unwrap();
14778 let round_tripped = markdown_to_adf(&md).unwrap();
14779 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14780 assert_eq!(
14781 attrs["layout"], "default",
14782 "layout='default' should be preserved"
14783 );
14784 }
14785
14786 #[test]
14787 fn table_is_number_column_enabled_false_preserved() {
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["isNumberColumnEnabled"], false,
14796 "isNumberColumnEnabled=false should be preserved"
14797 );
14798 }
14799
14800 #[test]
14801 fn table_is_number_column_enabled_true_preserved() {
14802 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"}]}]}]}]}]}"#;
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"], true,
14810 "isNumberColumnEnabled=true should be preserved"
14811 );
14812 }
14813
14814 #[test]
14815 fn directive_table_is_number_column_enabled_false_preserved() {
14816 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14819 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14820 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14821 ]}]}]}]}"#;
14822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14823 let md = adf_to_markdown(&doc).unwrap();
14824 assert!(md.contains("::::table"), "should use directive table form");
14825 assert!(
14826 md.contains("numbered=false"),
14827 "should contain numbered=false, got: {md}"
14828 );
14829 let round_tripped = markdown_to_adf(&md).unwrap();
14830 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14831 assert_eq!(attrs["isNumberColumnEnabled"], false);
14832 assert_eq!(attrs["layout"], "default");
14833 }
14834
14835 #[test]
14836 fn directive_table_is_number_column_enabled_true_preserved() {
14837 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14839 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14840 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14841 ]}]}]}]}"#;
14842 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14843 let md = adf_to_markdown(&doc).unwrap();
14844 assert!(md.contains("::::table"), "should use directive table form");
14845 assert!(
14846 md.contains("numbered}") || md.contains("numbered "),
14847 "should contain numbered flag, got: {md}"
14848 );
14849 let round_tripped = markdown_to_adf(&md).unwrap();
14850 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14851 assert_eq!(attrs["isNumberColumnEnabled"], true);
14852 }
14853
14854 #[test]
14855 fn trailing_space_in_bullet_list_item_preserved() {
14856 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14858 {"type":"listItem","content":[{"type":"paragraph","content":[
14859 {"type":"text","text":"Before link "},
14860 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14861 {"type":"text","text":" "}
14862 ]}]}
14863 ]}]}"#;
14864 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14865 let md = adf_to_markdown(&doc).unwrap();
14866 let round_tripped = markdown_to_adf(&md).unwrap();
14867 let list = &round_tripped.content[0];
14868 let item = &list.content.as_ref().unwrap()[0];
14869 let para = &item.content.as_ref().unwrap()[0];
14870 let inlines = para.content.as_ref().unwrap();
14871 let last = inlines.last().unwrap();
14872 assert_eq!(
14873 last.text.as_deref(),
14874 Some(" "),
14875 "trailing space text node should be preserved, got nodes: {:?}",
14876 inlines
14877 .iter()
14878 .map(|n| (&n.node_type, &n.text))
14879 .collect::<Vec<_>>()
14880 );
14881 }
14882
14883 #[test]
14884 fn trailing_space_after_mention_in_bullet_list_preserved() {
14885 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14887 {"type":"listItem","content":[{"type":"paragraph","content":[
14888 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14889 {"type":"text","text":" "}
14890 ]}]}
14891 ]}]}"#;
14892 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14893 let md = adf_to_markdown(&doc).unwrap();
14894 let round_tripped = markdown_to_adf(&md).unwrap();
14895 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14896 .content
14897 .as_ref()
14898 .unwrap()[0];
14899 let inlines = para.content.as_ref().unwrap();
14900 assert!(
14901 inlines.len() >= 2,
14902 "should have mention + trailing space, got {} nodes",
14903 inlines.len()
14904 );
14905 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
14906 }
14907
14908 #[test]
14909 fn trailing_space_in_ordered_list_item_preserved() {
14910 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14912 {"type":"listItem","content":[{"type":"paragraph","content":[
14913 {"type":"text","text":"item "},
14914 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14915 {"type":"text","text":" "}
14916 ]}]}
14917 ]}]}"#;
14918 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14919 let md = adf_to_markdown(&doc).unwrap();
14920 let round_tripped = markdown_to_adf(&md).unwrap();
14921 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14922 .content
14923 .as_ref()
14924 .unwrap()[0];
14925 let inlines = para.content.as_ref().unwrap();
14926 let last = inlines.last().unwrap();
14927 assert_eq!(
14928 last.text.as_deref(),
14929 Some(" "),
14930 "trailing space should be preserved in ordered list item"
14931 );
14932 }
14933
14934 #[test]
14935 fn trailing_space_in_heading_text_preserved() {
14936 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
14938 {"type":"text","text":"Firefighting Engineers "}
14939 ]}]}"#;
14940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14941 let md = adf_to_markdown(&doc).unwrap();
14942 let round_tripped = markdown_to_adf(&md).unwrap();
14943 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14944 assert_eq!(
14945 inlines[0].text.as_deref(),
14946 Some("Firefighting Engineers "),
14947 "trailing space in heading should be preserved"
14948 );
14949 }
14950
14951 #[test]
14952 fn trailing_space_in_heading_before_bold_preserved() {
14953 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14955 {"type":"text","text":"Classic "},
14956 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
14957 ]}]}"#;
14958 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14959 let md = adf_to_markdown(&doc).unwrap();
14960 let round_tripped = markdown_to_adf(&md).unwrap();
14961 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14962 assert_eq!(
14963 inlines[0].text.as_deref(),
14964 Some("Classic "),
14965 "trailing space in heading text before bold should be preserved"
14966 );
14967 }
14968
14969 #[test]
14970 fn leading_space_in_heading_text_preserved() {
14971 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
14973 {"type":"text","text":" #general-channel"}
14974 ]}]}"#;
14975 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14976 let md = adf_to_markdown(&doc).unwrap();
14977 let round_tripped = markdown_to_adf(&md).unwrap();
14978 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14979 assert_eq!(
14980 inlines[0].text.as_deref(),
14981 Some(" #general-channel"),
14982 "leading spaces in heading text should be preserved"
14983 );
14984 }
14985
14986 #[test]
14987 fn leading_space_in_heading_before_bold_preserved() {
14988 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14990 {"type":"text","text":" indented"},
14991 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
14992 ]}]}"#;
14993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14994 let md = adf_to_markdown(&doc).unwrap();
14995 let round_tripped = markdown_to_adf(&md).unwrap();
14996 let inlines = round_tripped.content[0].content.as_ref().unwrap();
14997 assert_eq!(
14998 inlines[0].text.as_deref(),
14999 Some(" indented"),
15000 "leading spaces in heading text before bold should be preserved"
15001 );
15002 }
15003
15004 #[test]
15005 fn heading_multiple_leading_spaces_markdown_parse() {
15006 let md = "### \t #general-channel";
15008 let doc = markdown_to_adf(md).unwrap();
15009 let inlines = doc.content[0].content.as_ref().unwrap();
15010 assert_eq!(
15011 inlines[0].text.as_deref(),
15012 Some("\t #general-channel"),
15013 "leading whitespace in heading text should be preserved during JFM parsing"
15014 );
15015 }
15016
15017 #[test]
15018 fn trailing_space_in_paragraph_text_preserved() {
15019 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15021 {"type":"text","text":"word followed by space "},
15022 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15023 ]}]}"#;
15024 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15025 let md = adf_to_markdown(&doc).unwrap();
15026 let round_tripped = markdown_to_adf(&md).unwrap();
15027 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15028 assert_eq!(
15029 inlines[0].text.as_deref(),
15030 Some("word followed by space "),
15031 "trailing space in paragraph text should be preserved"
15032 );
15033 }
15034
15035 #[test]
15036 fn nested_bullet_list_roundtrip() {
15037 let adf_doc = serde_json::json!({
15039 "type": "doc",
15040 "version": 1,
15041 "content": [{
15042 "type": "bulletList",
15043 "content": [{
15044 "type": "listItem",
15045 "content": [
15046 {
15047 "type": "paragraph",
15048 "content": [{"type": "text", "text": "parent item"}]
15049 },
15050 {
15051 "type": "bulletList",
15052 "content": [
15053 {
15054 "type": "listItem",
15055 "content": [{
15056 "type": "paragraph",
15057 "content": [{"type": "text", "text": "sub item 1"}]
15058 }]
15059 },
15060 {
15061 "type": "listItem",
15062 "content": [{
15063 "type": "paragraph",
15064 "content": [{"type": "text", "text": "sub item 2"}]
15065 }]
15066 }
15067 ]
15068 }
15069 ]
15070 }]
15071 }]
15072 });
15073 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15074 let md = adf_to_markdown(&doc).unwrap();
15075 assert!(
15076 md.contains("- parent item\n"),
15077 "expected top-level item in markdown, got: {md}"
15078 );
15079 assert!(
15080 md.contains(" - sub item 1\n"),
15081 "expected indented sub item 1 in markdown, got: {md}"
15082 );
15083 assert!(
15084 md.contains(" - sub item 2\n"),
15085 "expected indented sub item 2 in markdown, got: {md}"
15086 );
15087
15088 let doc2 = markdown_to_adf(&md).unwrap();
15090 let list = &doc2.content[0];
15091 assert_eq!(list.node_type, "bulletList");
15092 let item = &list.content.as_ref().unwrap()[0];
15093 assert_eq!(item.node_type, "listItem");
15094 let item_content = item.content.as_ref().unwrap();
15095 assert_eq!(
15096 item_content.len(),
15097 2,
15098 "listItem should have paragraph + nested list"
15099 );
15100 assert_eq!(item_content[0].node_type, "paragraph");
15101 assert_eq!(item_content[1].node_type, "bulletList");
15102 let sub_items = item_content[1].content.as_ref().unwrap();
15103 assert_eq!(sub_items.len(), 2);
15104 }
15105
15106 #[test]
15107 fn nested_bullet_in_table_cell_roundtrip() {
15108 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15109 let doc = markdown_to_adf(md).unwrap();
15110 let table = &doc.content[0];
15111 let row = &table.content.as_ref().unwrap()[0];
15112 let cell = &row.content.as_ref().unwrap()[0];
15113 let list = &cell.content.as_ref().unwrap()[0];
15114 assert_eq!(list.node_type, "bulletList");
15115 let item = &list.content.as_ref().unwrap()[0];
15116 let item_content = item.content.as_ref().unwrap();
15117 assert_eq!(
15118 item_content.len(),
15119 2,
15120 "listItem should have paragraph + nested list"
15121 );
15122 assert_eq!(item_content[1].node_type, "bulletList");
15123
15124 let md2 = adf_to_markdown(&doc).unwrap();
15126 assert!(
15127 md2.contains(" - child"),
15128 "expected indented child in round-tripped markdown, got: {md2}"
15129 );
15130 }
15131
15132 #[test]
15133 fn nested_ordered_list_roundtrip() {
15134 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15136 {"type":"listItem","content":[
15137 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15138 {"type":"orderedList","attrs":{"order":1},"content":[
15139 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15140 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15141 ]}
15142 ]},
15143 {"type":"listItem","content":[
15144 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15145 ]}
15146 ]}]}"#;
15147 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15148 let md = adf_to_markdown(&doc).unwrap();
15149 let round_tripped = markdown_to_adf(&md).unwrap();
15150
15151 let outer = &round_tripped.content[0];
15153 assert_eq!(outer.node_type, "orderedList");
15154 assert_eq!(
15155 outer.attrs.as_ref().unwrap()["order"],
15156 1,
15157 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15158 );
15159 let outer_items = outer.content.as_ref().unwrap();
15160 assert_eq!(
15161 outer_items.len(),
15162 2,
15163 "outer list should have 2 items, got {}",
15164 outer_items.len()
15165 );
15166
15167 let first_item = &outer_items[0];
15169 let first_content = first_item.content.as_ref().unwrap();
15170 assert_eq!(
15171 first_content.len(),
15172 2,
15173 "first listItem should have paragraph + nested list, got {}",
15174 first_content.len()
15175 );
15176 assert_eq!(first_content[0].node_type, "paragraph");
15177 assert_eq!(first_content[1].node_type, "orderedList");
15178 let nested_items = first_content[1].content.as_ref().unwrap();
15179 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15180 }
15181
15182 #[test]
15183 fn nested_ordered_list_markdown_parsing() {
15184 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15186 let doc = markdown_to_adf(md).unwrap();
15187 let outer = &doc.content[0];
15188 assert_eq!(outer.node_type, "orderedList");
15189 let outer_items = outer.content.as_ref().unwrap();
15190 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15191
15192 let first_content = outer_items[0].content.as_ref().unwrap();
15193 assert_eq!(
15194 first_content.len(),
15195 2,
15196 "first item should have paragraph + nested list"
15197 );
15198 assert_eq!(first_content[1].node_type, "orderedList");
15199 }
15200
15201 #[test]
15202 fn bullet_list_nested_inside_ordered_list() {
15203 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15205 let doc = markdown_to_adf(md).unwrap();
15206 let outer = &doc.content[0];
15207 assert_eq!(outer.node_type, "orderedList");
15208 let outer_items = outer.content.as_ref().unwrap();
15209 assert_eq!(outer_items.len(), 2);
15210
15211 let first_content = outer_items[0].content.as_ref().unwrap();
15212 assert_eq!(
15213 first_content.len(),
15214 2,
15215 "first item should have paragraph + nested list"
15216 );
15217 assert_eq!(first_content[1].node_type, "bulletList");
15218 let sub_items = first_content[1].content.as_ref().unwrap();
15219 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15220 }
15221
15222 #[test]
15223 fn ordered_list_order_attr_one_is_elided() {
15224 let md = "1. A\n2. B\n";
15228 let doc = markdown_to_adf(md).unwrap();
15229 assert!(
15230 doc.content[0].attrs.is_none(),
15231 "attrs should be elided when order=1"
15232 );
15233
15234 let md2 = adf_to_markdown(&doc).unwrap();
15236 let doc2 = markdown_to_adf(&md2).unwrap();
15237 assert!(
15238 doc2.content[0].attrs.is_none(),
15239 "attrs should remain elided after round-trip"
15240 );
15241 }
15242
15243 #[test]
15244 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15245 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"}]}]}]}]}"#;
15248 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15249 let md = adf_to_markdown(&doc).unwrap();
15250 let rt = markdown_to_adf(&md).unwrap();
15251 assert!(
15252 rt.content[0].attrs.is_none(),
15253 "round-tripped orderedList should not have attrs, got: {:?}",
15254 rt.content[0].attrs
15255 );
15256
15257 let rt_json = serde_json::to_string(&rt).unwrap();
15259 assert!(
15260 !rt_json.contains("\"order\""),
15261 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15262 );
15263 }
15264
15265 fn assert_roundtrip_byte_identical(adf_json: &str) {
15271 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15272 let md = adf_to_markdown(&doc).unwrap();
15273 let rt = markdown_to_adf(&md).unwrap();
15274
15275 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15276 let canonical_rt: serde_json::Value =
15277 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15278 assert_eq!(
15279 canonical_src, canonical_rt,
15280 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15281 );
15282 }
15283
15284 #[test]
15285 fn issue_547_single_item_no_attrs_roundtrip() {
15286 assert_roundtrip_byte_identical(
15287 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15288 );
15289 }
15290
15291 #[test]
15292 fn issue_547_many_items_no_attrs_roundtrip() {
15293 assert_roundtrip_byte_identical(
15294 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"}]}]}]}]}"#,
15295 );
15296 }
15297
15298 #[test]
15299 fn issue_547_non_default_order_preserved() {
15300 assert_roundtrip_byte_identical(
15303 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15304 );
15305 }
15306
15307 #[test]
15308 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15309 assert_roundtrip_byte_identical(
15311 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"}]}]}]}]}]}]}"#,
15312 );
15313 }
15314
15315 #[test]
15316 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15317 assert_roundtrip_byte_identical(
15318 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"}]}]}]}]}]}]}"#,
15319 );
15320 }
15321
15322 #[test]
15323 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15324 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":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15326 );
15327 }
15328
15329 #[test]
15330 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15331 assert_roundtrip_byte_identical(
15332 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"}]}]}"#,
15333 );
15334 }
15335
15336 #[test]
15337 fn issue_547_ordered_list_with_marked_text_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":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15340 );
15341 }
15342
15343 #[test]
15344 fn issue_547_ordered_list_with_link_roundtrip() {
15345 assert_roundtrip_byte_identical(
15346 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"}}]}]}]}]}]}"#,
15347 );
15348 }
15349
15350 #[test]
15351 fn issue_547_ordered_list_with_hardbreak_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":"a"},{"type":"hardBreak"},{"type":"text","text":"b"}]}]}]}]}"#,
15354 );
15355 }
15356
15357 #[test]
15358 fn issue_547_triple_nested_ordered_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":"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"}]}]}]}]}]}]}]}]}"#,
15361 );
15362 }
15363
15364 #[test]
15365 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15366 assert_roundtrip_byte_identical(
15367 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"}]}"#,
15368 );
15369 }
15370
15371 #[test]
15372 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15373 assert_roundtrip_byte_identical(
15375 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15376 );
15377 }
15378
15379 #[test]
15380 fn issue_547_explicit_order_one_preserved_roundtrip() {
15381 assert_roundtrip_byte_identical(
15386 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15387 );
15388 }
15389
15390 #[test]
15391 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15392 assert_roundtrip_byte_identical(
15395 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"}]}]}]}]}]}]}"#,
15396 );
15397 }
15398
15399 #[test]
15400 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15401 assert_roundtrip_byte_identical(
15404 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"}]}]}]}]}"#,
15405 );
15406 }
15407
15408 #[test]
15409 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15410 assert_roundtrip_byte_identical(
15414 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"}]}]}]}]}"#,
15415 );
15416 }
15417
15418 #[test]
15419 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15420 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15424 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"}]}]}]}]}"#;
15425 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"}]}]}]}]}"#;
15426
15427 let md_no =
15428 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15429 let md_one =
15430 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15431 let md_five =
15432 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15433
15434 assert!(
15435 !md_no.contains("{order="),
15436 "no-attrs source must not emit order signal, got: {md_no:?}"
15437 );
15438 assert!(
15439 md_one.contains("{order=1}"),
15440 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15441 );
15442 assert!(
15443 !md_five.contains("{order="),
15444 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15445 );
15446 }
15447
15448 #[test]
15451 fn file_media_roundtrip() {
15452 let adf_doc = serde_json::json!({
15454 "type": "doc",
15455 "version": 1,
15456 "content": [{
15457 "type": "mediaSingle",
15458 "attrs": {"layout": "center"},
15459 "content": [{
15460 "type": "media",
15461 "attrs": {
15462 "type": "file",
15463 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15464 "collection": "contentId-8220672100",
15465 "height": 56,
15466 "width": 312,
15467 "alt": "Screenshot.png"
15468 }
15469 }]
15470 }]
15471 });
15472 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15473 let md = adf_to_markdown(&doc).unwrap();
15474 assert!(
15475 md.contains("type=file"),
15476 "expected type=file in markdown, got: {md}"
15477 );
15478 assert!(
15479 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15480 "expected id in markdown, got: {md}"
15481 );
15482 assert!(
15483 md.contains("collection=contentId-8220672100"),
15484 "expected collection in markdown, got: {md}"
15485 );
15486 let doc2 = markdown_to_adf(&md).unwrap();
15488 let ms = &doc2.content[0];
15489 assert_eq!(ms.node_type, "mediaSingle");
15490 let media = &ms.content.as_ref().unwrap()[0];
15491 assert_eq!(media.node_type, "media");
15492 let attrs = media.attrs.as_ref().unwrap();
15493 assert_eq!(attrs["type"], "file");
15494 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15495 assert_eq!(attrs["collection"], "contentId-8220672100");
15496 assert_eq!(attrs["height"], 56);
15497 assert_eq!(attrs["width"], 312);
15498 assert_eq!(attrs["alt"], "Screenshot.png");
15499 }
15500
15501 #[test]
15505 fn file_media_roundtrip_issue_550_reproducer() {
15506 let adf_json = r#"{
15507 "version": 1,
15508 "type": "doc",
15509 "content": [
15510 {
15511 "type": "mediaSingle",
15512 "attrs": {"layout": "center"},
15513 "content": [
15514 {
15515 "type": "media",
15516 "attrs": {
15517 "type": "file",
15518 "id": "abc-123-def-456",
15519 "collection": "my-collection",
15520 "width": 941,
15521 "height": 655
15522 }
15523 }
15524 ]
15525 }
15526 ]
15527 }"#;
15528 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15529 let md = adf_to_markdown(&doc).unwrap();
15530 let rt = markdown_to_adf(&md).unwrap();
15531 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15532 let actual = serde_json::to_value(&rt).unwrap();
15533 assert_eq!(
15534 actual, expected,
15535 "roundtrip should preserve file media attrs; md was:\n{md}"
15536 );
15537 }
15538
15539 #[test]
15545 fn file_media_roundtrip_id_with_spaces() {
15546 let adf_json = r#"{
15547 "version": 1,
15548 "type": "doc",
15549 "content": [
15550 {
15551 "type": "mediaSingle",
15552 "attrs": {"layout": "center"},
15553 "content": [
15554 {
15555 "type": "media",
15556 "attrs": {
15557 "type": "file",
15558 "id": "abc 123 def 456",
15559 "collection": "my-collection",
15560 "width": 800,
15561 "height": 600
15562 }
15563 }
15564 ]
15565 }
15566 ]
15567 }"#;
15568 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15569 let md = adf_to_markdown(&doc).unwrap();
15570 assert!(
15571 md.contains(r#"id="abc 123 def 456""#),
15572 "id with spaces should be quoted in JFM, got:\n{md}"
15573 );
15574 let rt = markdown_to_adf(&md).unwrap();
15575 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15576 let actual = serde_json::to_value(&rt).unwrap();
15577 assert_eq!(
15578 actual, expected,
15579 "space-containing id must round-trip; md was:\n{md}"
15580 );
15581 }
15582
15583 #[test]
15585 fn file_media_roundtrip_collection_with_spaces() {
15586 let adf_json = r#"{
15587 "version": 1,
15588 "type": "doc",
15589 "content": [
15590 {
15591 "type": "mediaSingle",
15592 "attrs": {"layout": "center"},
15593 "content": [
15594 {
15595 "type": "media",
15596 "attrs": {
15597 "type": "file",
15598 "id": "abc-123",
15599 "collection": "my collection with spaces"
15600 }
15601 }
15602 ]
15603 }
15604 ]
15605 }"#;
15606 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15607 let md = adf_to_markdown(&doc).unwrap();
15608 let rt = markdown_to_adf(&md).unwrap();
15609 let media = &rt.content[0].content.as_ref().unwrap()[0];
15610 assert_eq!(
15611 media.attrs.as_ref().unwrap()["collection"],
15612 "my collection with spaces"
15613 );
15614 }
15615
15616 #[test]
15618 fn file_media_roundtrip_occurrence_key_with_spaces() {
15619 let adf_json = r#"{
15620 "version": 1,
15621 "type": "doc",
15622 "content": [
15623 {
15624 "type": "mediaSingle",
15625 "attrs": {"layout": "center"},
15626 "content": [
15627 {
15628 "type": "media",
15629 "attrs": {
15630 "type": "file",
15631 "id": "x",
15632 "collection": "y",
15633 "occurrenceKey": "key with spaces"
15634 }
15635 }
15636 ]
15637 }
15638 ]
15639 }"#;
15640 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15641 let md = adf_to_markdown(&doc).unwrap();
15642 let rt = markdown_to_adf(&md).unwrap();
15643 let media = &rt.content[0].content.as_ref().unwrap()[0];
15644 assert_eq!(
15645 media.attrs.as_ref().unwrap()["occurrenceKey"],
15646 "key with spaces"
15647 );
15648 }
15649
15650 #[test]
15652 fn file_media_roundtrip_id_with_quote_char() {
15653 let adf_json = r#"{
15654 "version": 1,
15655 "type": "doc",
15656 "content": [
15657 {
15658 "type": "mediaSingle",
15659 "attrs": {"layout": "center"},
15660 "content": [
15661 {
15662 "type": "media",
15663 "attrs": {
15664 "type": "file",
15665 "id": "a\"b\"c",
15666 "collection": "col"
15667 }
15668 }
15669 ]
15670 }
15671 ]
15672 }"#;
15673 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15674 let md = adf_to_markdown(&doc).unwrap();
15675 let rt = markdown_to_adf(&md).unwrap();
15676 let media = &rt.content[0].content.as_ref().unwrap()[0];
15677 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15678 }
15679
15680 #[test]
15683 fn media_inline_roundtrip_id_with_spaces() {
15684 let adf_json = r#"{
15685 "version": 1,
15686 "type": "doc",
15687 "content": [
15688 {
15689 "type": "paragraph",
15690 "content": [
15691 {"type": "text", "text": "before "},
15692 {
15693 "type": "mediaInline",
15694 "attrs": {
15695 "type": "file",
15696 "id": "a b c",
15697 "collection": "my col"
15698 }
15699 },
15700 {"type": "text", "text": " after"}
15701 ]
15702 }
15703 ]
15704 }"#;
15705 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15706 let md = adf_to_markdown(&doc).unwrap();
15707 let rt = markdown_to_adf(&md).unwrap();
15708 let inline = &rt.content[0].content.as_ref().unwrap()[1];
15709 assert_eq!(inline.node_type, "mediaInline");
15710 let attrs = inline.attrs.as_ref().unwrap();
15711 assert_eq!(attrs["id"], "a b c");
15712 assert_eq!(attrs["collection"], "my col");
15713 }
15714
15715 #[test]
15718 fn file_media_roundtrip_preserves_occurrence_key() {
15719 let adf_json = r#"{
15720 "version": 1,
15721 "type": "doc",
15722 "content": [
15723 {
15724 "type": "mediaSingle",
15725 "attrs": {"layout": "center"},
15726 "content": [
15727 {
15728 "type": "media",
15729 "attrs": {
15730 "type": "file",
15731 "id": "abc-123",
15732 "collection": "my-collection",
15733 "occurrenceKey": "unique-key-xyz",
15734 "width": 200,
15735 "height": 100
15736 }
15737 }
15738 ]
15739 }
15740 ]
15741 }"#;
15742 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15743 let md = adf_to_markdown(&doc).unwrap();
15744 assert!(
15745 md.contains("occurrenceKey=unique-key-xyz"),
15746 "expected occurrenceKey in markdown, got: {md}"
15747 );
15748 let rt = markdown_to_adf(&md).unwrap();
15749 let media = &rt.content[0].content.as_ref().unwrap()[0];
15750 let attrs = media.attrs.as_ref().unwrap();
15751 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15752 assert_eq!(attrs["type"], "file");
15753 assert_eq!(attrs["id"], "abc-123");
15754 assert_eq!(attrs["collection"], "my-collection");
15755 }
15756
15757 #[test]
15760 fn media_single_caption_adf_to_markdown() {
15761 let adf_doc = serde_json::json!({
15762 "type": "doc",
15763 "version": 1,
15764 "content": [{
15765 "type": "mediaSingle",
15766 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15767 "content": [
15768 {
15769 "type": "media",
15770 "attrs": {
15771 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15772 "type": "file",
15773 "collection": "contentId-123456",
15774 "width": 800,
15775 "height": 600
15776 }
15777 },
15778 {
15779 "type": "caption",
15780 "content": [{"type": "text", "text": "An image caption here"}]
15781 }
15782 ]
15783 }]
15784 });
15785 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15786 let md = adf_to_markdown(&doc).unwrap();
15787 assert!(
15788 md.contains(":::caption"),
15789 "expected :::caption in markdown, got: {md}"
15790 );
15791 assert!(
15792 md.contains("An image caption here"),
15793 "expected caption text in markdown, got: {md}"
15794 );
15795 }
15796
15797 #[test]
15798 fn media_single_caption_markdown_to_adf() {
15799 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15800 let doc = markdown_to_adf(md).unwrap();
15801 let ms = &doc.content[0];
15802 assert_eq!(ms.node_type, "mediaSingle");
15803 let content = ms.content.as_ref().unwrap();
15804 assert_eq!(content.len(), 2, "expected media + caption children");
15805 assert_eq!(content[0].node_type, "media");
15806 assert_eq!(content[1].node_type, "caption");
15807 let caption_content = content[1].content.as_ref().unwrap();
15808 assert_eq!(
15809 caption_content[0].text.as_deref(),
15810 Some("An image caption here")
15811 );
15812 }
15813
15814 #[test]
15815 fn media_single_caption_round_trip() {
15816 let adf_doc = serde_json::json!({
15818 "type": "doc",
15819 "version": 1,
15820 "content": [{
15821 "type": "mediaSingle",
15822 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15823 "content": [
15824 {
15825 "type": "media",
15826 "attrs": {
15827 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15828 "type": "file",
15829 "collection": "contentId-123456",
15830 "width": 800,
15831 "height": 600
15832 }
15833 },
15834 {
15835 "type": "caption",
15836 "content": [{"type": "text", "text": "An image caption here"}]
15837 }
15838 ]
15839 }]
15840 });
15841 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15842 let md = adf_to_markdown(&doc).unwrap();
15843 let doc2 = markdown_to_adf(&md).unwrap();
15844 let ms = &doc2.content[0];
15845 assert_eq!(ms.node_type, "mediaSingle");
15846 let content = ms.content.as_ref().unwrap();
15847 assert_eq!(
15848 content.len(),
15849 2,
15850 "expected media + caption after round-trip"
15851 );
15852 assert_eq!(content[1].node_type, "caption");
15853 let caption_content = content[1].content.as_ref().unwrap();
15854 assert_eq!(
15855 caption_content[0].text.as_deref(),
15856 Some("An image caption here")
15857 );
15858 }
15859
15860 #[test]
15861 fn media_single_caption_with_inline_marks() {
15862 let adf_doc = serde_json::json!({
15863 "type": "doc",
15864 "version": 1,
15865 "content": [{
15866 "type": "mediaSingle",
15867 "attrs": {"layout": "center"},
15868 "content": [
15869 {
15870 "type": "media",
15871 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15872 },
15873 {
15874 "type": "caption",
15875 "content": [
15876 {"type": "text", "text": "A "},
15877 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15878 {"type": "text", "text": " caption"}
15879 ]
15880 }
15881 ]
15882 }]
15883 });
15884 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15885 let md = adf_to_markdown(&doc).unwrap();
15886 assert!(
15887 md.contains("**bold**"),
15888 "expected bold in caption, got: {md}"
15889 );
15890
15891 let doc2 = markdown_to_adf(&md).unwrap();
15892 let content = doc2.content[0].content.as_ref().unwrap();
15893 assert_eq!(content.len(), 2, "expected media + caption");
15894 assert_eq!(content[1].node_type, "caption");
15895 let caption_inlines = content[1].content.as_ref().unwrap();
15896 let bold_node = caption_inlines
15897 .iter()
15898 .find(|n| n.text.as_deref() == Some("bold"))
15899 .unwrap();
15900 let marks = bold_node.marks.as_ref().unwrap();
15901 assert_eq!(marks[0].mark_type, "strong");
15902 }
15903
15904 #[test]
15905 fn media_single_no_caption_unaffected() {
15906 let adf_doc = serde_json::json!({
15908 "type": "doc",
15909 "version": 1,
15910 "content": [{
15911 "type": "mediaSingle",
15912 "attrs": {"layout": "center"},
15913 "content": [{
15914 "type": "media",
15915 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15916 }]
15917 }]
15918 });
15919 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15920 let md = adf_to_markdown(&doc).unwrap();
15921 assert!(
15922 !md.contains(":::caption"),
15923 "should not emit caption when none present"
15924 );
15925 let doc2 = markdown_to_adf(&md).unwrap();
15926 let content = doc2.content[0].content.as_ref().unwrap();
15927 assert_eq!(content.len(), 1, "should only have media child");
15928 assert_eq!(content[0].node_type, "media");
15929 }
15930
15931 #[test]
15932 fn media_single_empty_caption_round_trip() {
15933 let adf_doc = serde_json::json!({
15935 "type": "doc",
15936 "version": 1,
15937 "content": [{
15938 "type": "mediaSingle",
15939 "attrs": {"layout": "center"},
15940 "content": [
15941 {
15942 "type": "media",
15943 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15944 },
15945 {
15946 "type": "caption"
15947 }
15948 ]
15949 }]
15950 });
15951 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15952 let md = adf_to_markdown(&doc).unwrap();
15953 assert!(
15954 md.contains(":::caption"),
15955 "expected :::caption even for empty caption, got: {md}"
15956 );
15957 assert!(
15958 md.contains(":::\n"),
15959 "expected closing ::: fence, got: {md}"
15960 );
15961 }
15962
15963 #[test]
15964 fn media_single_external_caption_round_trip() {
15965 let md = "\n:::caption\nImage description\n:::\n";
15967 let doc = markdown_to_adf(md).unwrap();
15968 let ms = &doc.content[0];
15969 assert_eq!(ms.node_type, "mediaSingle");
15970 let content = ms.content.as_ref().unwrap();
15971 assert_eq!(content.len(), 2);
15972 assert_eq!(content[0].node_type, "media");
15973 assert_eq!(content[1].node_type, "caption");
15974
15975 let md2 = adf_to_markdown(&doc).unwrap();
15976 let doc2 = markdown_to_adf(&md2).unwrap();
15977 let content2 = doc2.content[0].content.as_ref().unwrap();
15978 assert_eq!(content2.len(), 2);
15979 assert_eq!(content2[1].node_type, "caption");
15980 let caption_text = content2[1].content.as_ref().unwrap();
15981 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
15982 }
15983
15984 #[test]
15987 fn media_single_caption_localid_roundtrip() {
15988 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"}]}]}]}"#;
15989 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15990 let md = adf_to_markdown(&doc).unwrap();
15991 assert!(
15992 md.contains("localId=9da8c2104471"),
15993 "caption localId should appear in markdown: {md}"
15994 );
15995 let rt = markdown_to_adf(&md).unwrap();
15996 let content = rt.content[0].content.as_ref().unwrap();
15997 let caption = &content[1];
15998 assert_eq!(caption.node_type, "caption");
15999 assert_eq!(
16000 caption.attrs.as_ref().unwrap()["localId"],
16001 "9da8c2104471",
16002 "caption localId should round-trip"
16003 );
16004 }
16005
16006 #[test]
16007 fn media_single_caption_without_localid() {
16008 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16009 let doc = markdown_to_adf(md).unwrap();
16010 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16011 assert_eq!(caption.node_type, "caption");
16012 assert!(
16013 caption.attrs.is_none(),
16014 "caption without localId should not gain attrs"
16015 );
16016 let md2 = adf_to_markdown(&doc).unwrap();
16017 assert!(
16018 !md2.contains("localId"),
16019 "no localId should appear in output: {md2}"
16020 );
16021 }
16022
16023 #[test]
16024 fn media_single_caption_localid_stripped_when_option_set() {
16025 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"}]}]}]}"#;
16026 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16027 let opts = RenderOptions {
16028 strip_local_ids: true,
16029 ..Default::default()
16030 };
16031 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16032 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16033 }
16034
16035 #[test]
16036 fn table_width_roundtrip() {
16037 let adf_doc = serde_json::json!({
16039 "type": "doc",
16040 "version": 1,
16041 "content": [{
16042 "type": "table",
16043 "attrs": {"layout": "default", "width": 760.0},
16044 "content": [{
16045 "type": "tableRow",
16046 "content": [{
16047 "type": "tableHeader",
16048 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16049 }]
16050 }]
16051 }]
16052 });
16053 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16054 let md = adf_to_markdown(&doc).unwrap();
16055 assert!(
16056 md.contains("width=760.0"),
16057 "expected width=760.0 in markdown (float preserved), got: {md}"
16058 );
16059 let doc2 = markdown_to_adf(&md).unwrap();
16061 let table = &doc2.content[0];
16062 assert_eq!(table.node_type, "table");
16063 let table_attrs = table.attrs.as_ref().unwrap();
16064 assert_eq!(table_attrs["width"], 760.0);
16065 assert!(
16066 table_attrs["width"].is_f64(),
16067 "expected float width to be preserved as f64, got: {:?}",
16068 table_attrs["width"]
16069 );
16070 }
16071
16072 #[test]
16073 fn table_integer_width_roundtrip_preserves_integer() {
16074 let adf_doc = serde_json::json!({
16077 "type": "doc",
16078 "version": 1,
16079 "content": [{
16080 "type": "table",
16081 "attrs": {
16082 "isNumberColumnEnabled": false,
16083 "layout": "center",
16084 "localId": "abc-123",
16085 "width": 1420
16086 },
16087 "content": [{
16088 "type": "tableRow",
16089 "content": [{
16090 "type": "tableCell",
16091 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16092 }]
16093 }]
16094 }]
16095 });
16096 let doc: crate::atlassian::adf::AdfDocument =
16097 serde_json::from_value(adf_doc.clone()).unwrap();
16098 let md = adf_to_markdown(&doc).unwrap();
16099 assert!(
16100 md.contains("width=1420"),
16101 "expected width=1420 in markdown, got: {md}"
16102 );
16103 assert!(
16104 !md.contains("width=1420.0"),
16105 "integer width should not be rendered with decimal: {md}"
16106 );
16107
16108 let doc2 = markdown_to_adf(&md).unwrap();
16109 let table = &doc2.content[0];
16110 assert_eq!(table.node_type, "table");
16111 let table_attrs = table.attrs.as_ref().unwrap();
16112 assert_eq!(table_attrs["width"], 1420);
16113 assert!(
16114 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16115 "width should remain an integer, got: {:?}",
16116 table_attrs["width"]
16117 );
16118 assert!(
16119 !table_attrs["width"].is_f64(),
16120 "width should not be a float, got: {:?}",
16121 table_attrs["width"]
16122 );
16123
16124 let roundtripped = serde_json::to_value(&doc2).unwrap();
16126 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16127 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16128 assert_eq!(
16129 orig_width, rt_width,
16130 "width value must roundtrip byte-for-byte"
16131 );
16132 }
16133
16134 #[test]
16135 fn table_fractional_width_roundtrip() {
16136 let adf_doc = serde_json::json!({
16138 "type": "doc",
16139 "version": 1,
16140 "content": [{
16141 "type": "table",
16142 "attrs": {"layout": "default", "width": 760.5},
16143 "content": [{
16144 "type": "tableRow",
16145 "content": [{
16146 "type": "tableHeader",
16147 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16148 }]
16149 }]
16150 }]
16151 });
16152 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16153 let md = adf_to_markdown(&doc).unwrap();
16154 assert!(
16155 md.contains("width=760.5"),
16156 "expected width=760.5 in markdown, got: {md}"
16157 );
16158 let doc2 = markdown_to_adf(&md).unwrap();
16159 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16160 assert_eq!(table_attrs["width"], 760.5);
16161 assert!(table_attrs["width"].is_f64());
16162 }
16163
16164 #[test]
16165 fn pipe_table_integer_width_roundtrip() {
16166 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16168 let doc = markdown_to_adf(md).unwrap();
16169 let table = &doc.content[0];
16170 assert_eq!(table.node_type, "table");
16171 let attrs = table.attrs.as_ref().unwrap();
16172 assert_eq!(attrs["width"], 1420);
16173 assert!(
16174 attrs["width"].is_u64() || attrs["width"].is_i64(),
16175 "pipe-table width must stay integer, got: {:?}",
16176 attrs["width"]
16177 );
16178 }
16179
16180 #[test]
16181 fn file_media_width_type_roundtrip() {
16182 let adf_doc = serde_json::json!({
16184 "type": "doc",
16185 "version": 1,
16186 "content": [{
16187 "type": "mediaSingle",
16188 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16189 "content": [{
16190 "type": "media",
16191 "attrs": {
16192 "type": "file",
16193 "id": "abc123",
16194 "collection": "contentId-999",
16195 "height": 56,
16196 "width": 312
16197 }
16198 }]
16199 }]
16200 });
16201 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16202 let md = adf_to_markdown(&doc).unwrap();
16203 assert!(
16204 md.contains("widthType=pixel"),
16205 "expected widthType=pixel in markdown, got: {md}"
16206 );
16207 let doc2 = markdown_to_adf(&md).unwrap();
16208 let ms = &doc2.content[0];
16209 let ms_attrs = ms.attrs.as_ref().unwrap();
16210 assert_eq!(ms_attrs["widthType"], "pixel");
16211 assert_eq!(ms_attrs["width"], 312);
16212 }
16213
16214 #[test]
16215 fn file_media_mode_roundtrip() {
16216 let adf_doc = serde_json::json!({
16218 "type": "doc",
16219 "version": 1,
16220 "content": [{
16221 "type": "mediaSingle",
16222 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16223 "content": [{
16224 "type": "media",
16225 "attrs": {
16226 "type": "file",
16227 "id": "abc123",
16228 "collection": "test",
16229 "width": 1200,
16230 "height": 600
16231 }
16232 }]
16233 }]
16234 });
16235 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16236 let md = adf_to_markdown(&doc).unwrap();
16237 assert!(
16238 md.contains("mode=wide"),
16239 "expected mode=wide in markdown, got: {md}"
16240 );
16241 let doc2 = markdown_to_adf(&md).unwrap();
16242 let ms = &doc2.content[0];
16243 let ms_attrs = ms.attrs.as_ref().unwrap();
16244 assert_eq!(ms_attrs["mode"], "wide");
16245 assert_eq!(ms_attrs["layout"], "wide");
16246 assert_eq!(ms_attrs["width"], 1200);
16247 }
16248
16249 #[test]
16250 fn external_media_mode_roundtrip() {
16251 let adf_doc = serde_json::json!({
16253 "type": "doc",
16254 "version": 1,
16255 "content": [{
16256 "type": "mediaSingle",
16257 "attrs": {"layout": "wide", "mode": "wide"},
16258 "content": [{
16259 "type": "media",
16260 "attrs": {
16261 "type": "external",
16262 "url": "https://example.com/image.png"
16263 }
16264 }]
16265 }]
16266 });
16267 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16268 let md = adf_to_markdown(&doc).unwrap();
16269 assert!(
16270 md.contains("mode=wide"),
16271 "expected mode=wide in markdown, got: {md}"
16272 );
16273 let doc2 = markdown_to_adf(&md).unwrap();
16274 let ms = &doc2.content[0];
16275 let ms_attrs = ms.attrs.as_ref().unwrap();
16276 assert_eq!(ms_attrs["mode"], "wide");
16277 assert_eq!(ms_attrs["layout"], "wide");
16278 }
16279
16280 #[test]
16281 fn media_mode_only_roundtrip() {
16282 let adf_doc = serde_json::json!({
16284 "type": "doc",
16285 "version": 1,
16286 "content": [{
16287 "type": "mediaSingle",
16288 "attrs": {"layout": "center", "mode": "default"},
16289 "content": [{
16290 "type": "media",
16291 "attrs": {
16292 "type": "external",
16293 "url": "https://example.com/image.png"
16294 }
16295 }]
16296 }]
16297 });
16298 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16299 let md = adf_to_markdown(&doc).unwrap();
16300 assert!(
16301 md.contains("mode=default"),
16302 "expected mode=default in markdown, got: {md}"
16303 );
16304 let doc2 = markdown_to_adf(&md).unwrap();
16305 let ms = &doc2.content[0];
16306 let ms_attrs = ms.attrs.as_ref().unwrap();
16307 assert_eq!(ms_attrs["mode"], "default");
16308 }
16309
16310 #[test]
16311 fn file_media_hex_localid_roundtrip() {
16312 let adf_doc = serde_json::json!({
16314 "type": "doc",
16315 "version": 1,
16316 "content": [{
16317 "type": "mediaSingle",
16318 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16319 "content": [{
16320 "type": "media",
16321 "attrs": {
16322 "type": "file",
16323 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16324 "collection": "contentId-123",
16325 "height": 484,
16326 "width": 915,
16327 "alt": "image.png",
16328 "localId": "0e79f58ac382"
16329 }
16330 }]
16331 }]
16332 });
16333 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16334 let md = adf_to_markdown(&doc).unwrap();
16335 assert!(
16336 md.contains("localId=0e79f58ac382"),
16337 "expected localId=0e79f58ac382 in markdown, got: {md}"
16338 );
16339 let doc2 = markdown_to_adf(&md).unwrap();
16340 let ms = &doc2.content[0];
16341 let media = &ms.content.as_ref().unwrap()[0];
16342 let attrs = media.attrs.as_ref().unwrap();
16343 assert_eq!(attrs["localId"], "0e79f58ac382");
16344 }
16345
16346 #[test]
16347 fn file_media_uuid_localid_roundtrip() {
16348 let adf_doc = serde_json::json!({
16350 "type": "doc",
16351 "version": 1,
16352 "content": [{
16353 "type": "mediaSingle",
16354 "attrs": {"layout": "center"},
16355 "content": [{
16356 "type": "media",
16357 "attrs": {
16358 "type": "file",
16359 "id": "abc-123",
16360 "collection": "contentId-456",
16361 "height": 100,
16362 "width": 200,
16363 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16364 }
16365 }]
16366 }]
16367 });
16368 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16369 let md = adf_to_markdown(&doc).unwrap();
16370 assert!(
16371 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16372 "expected UUID localId in markdown, got: {md}"
16373 );
16374 let doc2 = markdown_to_adf(&md).unwrap();
16375 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16376 let attrs = media.attrs.as_ref().unwrap();
16377 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16378 }
16379
16380 #[test]
16381 fn file_media_null_uuid_localid_stripped() {
16382 let adf_doc = serde_json::json!({
16384 "type": "doc",
16385 "version": 1,
16386 "content": [{
16387 "type": "mediaSingle",
16388 "attrs": {"layout": "center"},
16389 "content": [{
16390 "type": "media",
16391 "attrs": {
16392 "type": "file",
16393 "id": "abc-123",
16394 "collection": "contentId-456",
16395 "height": 100,
16396 "width": 200,
16397 "localId": "00000000-0000-0000-0000-000000000000"
16398 }
16399 }]
16400 }]
16401 });
16402 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16403 let md = adf_to_markdown(&doc).unwrap();
16404 assert!(
16405 !md.contains("localId="),
16406 "null UUID localId should be stripped, got: {md}"
16407 );
16408 }
16409
16410 #[test]
16411 fn file_media_localid_stripped_when_option_set() {
16412 let adf_doc = serde_json::json!({
16414 "type": "doc",
16415 "version": 1,
16416 "content": [{
16417 "type": "mediaSingle",
16418 "attrs": {"layout": "center"},
16419 "content": [{
16420 "type": "media",
16421 "attrs": {
16422 "type": "file",
16423 "id": "abc-123",
16424 "collection": "contentId-456",
16425 "height": 100,
16426 "width": 200,
16427 "localId": "0e79f58ac382"
16428 }
16429 }]
16430 }]
16431 });
16432 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16433 let opts = RenderOptions {
16434 strip_local_ids: true,
16435 ..Default::default()
16436 };
16437 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16438 assert!(
16439 !md.contains("localId="),
16440 "localId should be stripped with strip_local_ids, got: {md}"
16441 );
16442 }
16443
16444 #[test]
16445 fn external_media_localid_roundtrip() {
16446 let adf_doc = serde_json::json!({
16448 "type": "doc",
16449 "version": 1,
16450 "content": [{
16451 "type": "mediaSingle",
16452 "attrs": {"layout": "center"},
16453 "content": [{
16454 "type": "media",
16455 "attrs": {
16456 "type": "external",
16457 "url": "https://example.com/image.png",
16458 "alt": "test",
16459 "localId": "deadbeef1234"
16460 }
16461 }]
16462 }]
16463 });
16464 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16465 let md = adf_to_markdown(&doc).unwrap();
16466 assert!(
16467 md.contains("localId=deadbeef1234"),
16468 "expected localId in markdown for external media, got: {md}"
16469 );
16470 let doc2 = markdown_to_adf(&md).unwrap();
16471 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16472 let attrs = media.attrs.as_ref().unwrap();
16473 assert_eq!(attrs["localId"], "deadbeef1234");
16474 }
16475
16476 #[test]
16477 fn bracket_in_text_not_parsed_as_link() {
16478 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16480 let doc = markdown_to_adf(md).unwrap();
16481 let para = &doc.content[0];
16482 assert_eq!(para.node_type, "paragraph");
16483 let content = para.content.as_ref().unwrap();
16484 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16486 let has_task_bracket = text_nodes
16487 .iter()
16488 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16489 assert!(
16490 has_task_bracket,
16491 "expected [Task] in plain text, nodes: {content:?}"
16492 );
16493 let link_nodes: Vec<_> = content
16495 .iter()
16496 .filter(|n| {
16497 n.marks
16498 .as_ref()
16499 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16500 })
16501 .collect();
16502 assert!(!link_nodes.is_empty(), "expected a link node");
16503 assert_eq!(
16504 link_nodes[0].text.as_deref(),
16505 Some("Link"),
16506 "link text should be 'Link'"
16507 );
16508 }
16509
16510 #[test]
16511 fn empty_paragraph_roundtrip() {
16512 let mut adf_in = AdfDocument::new();
16514 adf_in.content = vec![
16515 AdfNode::paragraph(vec![AdfNode::text("before")]),
16516 AdfNode::paragraph(vec![]),
16517 AdfNode::paragraph(vec![AdfNode::text("after")]),
16518 ];
16519 let md = adf_to_markdown(&adf_in).unwrap();
16520 let adf_out = markdown_to_adf(&md).unwrap();
16521 assert_eq!(
16522 adf_out.content.len(),
16523 3,
16524 "should have 3 blocks, markdown:\n{md}"
16525 );
16526 assert_eq!(adf_out.content[0].node_type, "paragraph");
16527 assert_eq!(adf_out.content[1].node_type, "paragraph");
16528 assert!(
16529 adf_out.content[1].content.is_none(),
16530 "middle paragraph should be empty"
16531 );
16532 assert_eq!(adf_out.content[2].node_type, "paragraph");
16533 }
16534
16535 #[test]
16536 fn nbsp_paragraph_roundtrip() {
16537 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16540 let md = adf_to_markdown(&doc).unwrap();
16541 assert!(
16542 md.contains("::paragraph["),
16543 "NBSP paragraph should use directive form: {md}"
16544 );
16545 let rt = markdown_to_adf(&md).unwrap();
16546 assert_eq!(rt.content.len(), 1, "should have 1 block");
16547 assert_eq!(rt.content[0].node_type, "paragraph");
16548 let text = rt.content[0].content.as_ref().unwrap()[0]
16549 .text
16550 .as_deref()
16551 .unwrap_or("");
16552 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16553 }
16554
16555 #[test]
16556 fn nbsp_in_nested_expand_roundtrip() {
16557 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16560 let md = adf_to_markdown(&doc).unwrap();
16561 let rt = markdown_to_adf(&md).unwrap();
16562 let ne = &rt.content[0];
16563 assert_eq!(ne.node_type, "nestedExpand");
16564 let inner = ne.content.as_ref().unwrap();
16565 assert_eq!(inner.len(), 1, "should have 1 inner block");
16566 assert_eq!(inner[0].node_type, "paragraph");
16567 let content = inner[0].content.as_ref().unwrap();
16568 assert!(!content.is_empty(), "paragraph should not be empty");
16569 let text = content[0].text.as_deref().unwrap_or("");
16570 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16571 }
16572
16573 #[test]
16574 fn nbsp_followed_by_content() {
16575 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\"}]}]}";
16577 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16578 let md = adf_to_markdown(&doc).unwrap();
16579 let rt = markdown_to_adf(&md).unwrap();
16580 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16581 let after_para = rt.content.iter().find(|n| {
16583 n.node_type == "paragraph"
16584 && n.content
16585 .as_ref()
16586 .and_then(|c| c.first())
16587 .and_then(|n| n.text.as_deref())
16588 .map_or(false, |t| t.contains("after"))
16589 });
16590 assert!(after_para.is_some(), "should have paragraph with 'after'");
16591 }
16592
16593 #[test]
16594 fn nbsp_paragraph_with_marks_survives() {
16595 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16598 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16599 let md = adf_to_markdown(&doc).unwrap();
16600 assert!(md.contains("**"), "should have bold markers: {md}");
16601 let rt = markdown_to_adf(&md).unwrap();
16602 let content = rt.content[0].content.as_ref().unwrap();
16603 assert!(!content.is_empty(), "should preserve content");
16604 }
16605
16606 #[test]
16607 fn regular_paragraph_unchanged() {
16608 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16610 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16611 let md = adf_to_markdown(&doc).unwrap();
16612 assert!(
16613 !md.contains("::paragraph"),
16614 "regular paragraphs should not use directive form: {md}"
16615 );
16616 assert!(md.contains("hello"));
16617 }
16618
16619 #[test]
16620 fn paragraph_directive_with_content_parsed() {
16621 let md = "::paragraph[\u{00a0}]\n";
16623 let doc = markdown_to_adf(md).unwrap();
16624 assert_eq!(doc.content.len(), 1);
16625 assert_eq!(doc.content[0].node_type, "paragraph");
16626 let content = doc.content[0].content.as_ref().unwrap();
16627 assert!(!content.is_empty(), "should have inline content");
16628 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16629 }
16630
16631 #[test]
16632 fn nbsp_paragraph_in_list_item_with_nested_list() {
16633 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"}]}]}]}]}]}]}"#;
16635 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16636 let md = adf_to_markdown(&doc).unwrap();
16637 let rt = markdown_to_adf(&md).unwrap();
16638 let list = &rt.content[0];
16639 assert_eq!(list.node_type, "bulletList");
16640 let item = &list.content.as_ref().unwrap()[0];
16641 let item_content = item.content.as_ref().unwrap();
16642 assert_eq!(
16643 item_content.len(),
16644 2,
16645 "listItem should have paragraph + nested list, got: {item_content:?}"
16646 );
16647 let para = &item_content[0];
16648 assert_eq!(para.node_type, "paragraph");
16649 let para_content = para
16650 .content
16651 .as_ref()
16652 .expect("paragraph should have content");
16653 assert!(
16654 !para_content.is_empty(),
16655 "NBSP paragraph content should not be empty"
16656 );
16657 assert_eq!(
16658 para_content[0].text.as_deref().unwrap(),
16659 "\u{00a0}",
16660 "NBSP should survive round-trip inside listItem"
16661 );
16662 }
16663
16664 #[test]
16665 fn nbsp_paragraph_in_list_item_with_local_ids() {
16666 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"}]}]}]}]}]}]}"#;
16668 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16669 let md = adf_to_markdown(&doc).unwrap();
16670 let rt = markdown_to_adf(&md).unwrap();
16671 let list = &rt.content[0];
16672 let item = &list.content.as_ref().unwrap()[0];
16673 assert_eq!(
16675 item.attrs.as_ref().unwrap()["localId"],
16676 "li-001",
16677 "listItem localId should survive"
16678 );
16679 let item_content = item.content.as_ref().unwrap();
16680 assert_eq!(item_content.len(), 2);
16681 let para = &item_content[0];
16683 assert_eq!(
16684 para.attrs.as_ref().unwrap()["localId"],
16685 "p-001",
16686 "paragraph localId should survive"
16687 );
16688 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16689 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16690 }
16691
16692 #[test]
16693 fn nbsp_paragraph_in_list_item_without_nested_list() {
16694 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"}]}]}]}]}"#;
16696 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16697 let md = adf_to_markdown(&doc).unwrap();
16698 let rt = markdown_to_adf(&md).unwrap();
16699 let list = &rt.content[0];
16700 let item = &list.content.as_ref().unwrap()[0];
16701 let para = &item.content.as_ref().unwrap()[0];
16702 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16703 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16704 }
16705
16706 #[test]
16707 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16708 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"}]}]}]}]}]}]}"#;
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 item_content = item.content.as_ref().unwrap();
16716 assert_eq!(item_content.len(), 2);
16717 let para = &item_content[0];
16718 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16719 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16720 }
16721
16722 #[test]
16723 fn list_item_leading_space_preserved() {
16724 let md = "- hello world\n- - text";
16726 let doc = markdown_to_adf(md).unwrap();
16727 let list = &doc.content[0];
16728 assert_eq!(list.node_type, "bulletList");
16729 let items = list.content.as_ref().unwrap();
16730 let first_para = &items[0].content.as_ref().unwrap()[0];
16732 let first_text = &first_para.content.as_ref().unwrap()[0];
16733 assert_eq!(first_text.text.as_deref(), Some("hello world"));
16734 }
16735
16736 #[test]
16737 fn list_item_leading_space_not_stripped() {
16738 let md = "- leading space text";
16741 let doc = markdown_to_adf(md).unwrap();
16742 let list = &doc.content[0];
16743 let items = list.content.as_ref().unwrap();
16744 let para = &items[0].content.as_ref().unwrap()[0];
16745 let text_node = ¶.content.as_ref().unwrap()[0];
16746 assert_eq!(
16748 text_node.text.as_deref(),
16749 Some(" leading space text"),
16750 "leading space should be preserved"
16751 );
16752 }
16753
16754 #[test]
16759 fn hardbreak_in_cell_uses_directive_table() {
16760 let adf = AdfDocument {
16763 version: 1,
16764 doc_type: "doc".to_string(),
16765 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16766 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16767 AdfNode::text("before"),
16768 AdfNode::hard_break(),
16769 AdfNode::text("after"),
16770 ])]),
16771 ])])],
16772 };
16773 let md = adf_to_markdown(&adf).unwrap();
16774 assert!(
16776 md.contains(":::td") || md.contains("::::table"),
16777 "Table with hardBreak should use directive form, got:\n{md}"
16778 );
16779 assert!(
16780 !md.contains("| before"),
16781 "Should NOT use pipe syntax with hardBreak"
16782 );
16783 }
16784
16785 #[test]
16786 fn hardbreak_in_cell_roundtrips() {
16787 let adf = AdfDocument {
16789 version: 1,
16790 doc_type: "doc".to_string(),
16791 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16792 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16793 AdfNode::text("line one"),
16794 AdfNode::hard_break(),
16795 AdfNode::text("line two"),
16796 ])]),
16797 ])])],
16798 };
16799 let md = adf_to_markdown(&adf).unwrap();
16800 let roundtripped = markdown_to_adf(&md).unwrap();
16801
16802 assert_eq!(roundtripped.content.len(), 1);
16804 assert_eq!(roundtripped.content[0].node_type, "table");
16805 let rows = roundtripped.content[0].content.as_ref().unwrap();
16806 assert_eq!(
16807 rows.len(),
16808 1,
16809 "Should have exactly 1 row, got {}",
16810 rows.len()
16811 );
16812 }
16813
16814 #[test]
16815 fn hardbreak_in_paragraph_roundtrips() {
16816 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16818 {"type":"text","text":"line one"},
16819 {"type":"hardBreak"},
16820 {"type":"text","text":"line two"}
16821 ]}]}"#;
16822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16823 let md = adf_to_markdown(&doc).unwrap();
16824 let round_tripped = markdown_to_adf(&md).unwrap();
16825 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16826 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16827 assert_eq!(
16828 types,
16829 vec!["text", "hardBreak", "text"],
16830 "hardBreak should be preserved, got: {types:?}"
16831 );
16832 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16833 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16834 }
16835
16836 #[test]
16837 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16838 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16840 {"type":"text","text":"before"},
16841 {"type":"hardBreak"},
16842 {"type":"hardBreak"},
16843 {"type":"text","text":"after"}
16844 ]}]}"#;
16845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846 let md = adf_to_markdown(&doc).unwrap();
16847 let round_tripped = markdown_to_adf(&md).unwrap();
16848 assert_eq!(
16849 round_tripped.content.len(),
16850 1,
16851 "Should remain a single paragraph, got {} blocks",
16852 round_tripped.content.len()
16853 );
16854 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16855 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16856 assert_eq!(
16857 types,
16858 vec!["text", "hardBreak", "hardBreak", "text"],
16859 "Both hardBreaks should be preserved, got: {types:?}"
16860 );
16861 assert_eq!(inlines[0].text.as_deref(), Some("before"));
16862 assert_eq!(inlines[3].text.as_deref(), Some("after"));
16863 }
16864
16865 #[test]
16866 fn hardbreak_only_paragraph_roundtrips() {
16867 let adf_json = r#"{"version":1,"type":"doc","content":[
16869 {"type":"paragraph","content":[{"type":"hardBreak"}]}
16870 ]}"#;
16871 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16872 let md = adf_to_markdown(&doc).unwrap();
16873 let round_tripped = markdown_to_adf(&md).unwrap();
16874 assert_eq!(
16875 round_tripped.content.len(),
16876 1,
16877 "Paragraph should not be dropped, got {} blocks",
16878 round_tripped.content.len()
16879 );
16880 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16881 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16882 assert_eq!(
16883 types,
16884 vec!["hardBreak"],
16885 "hardBreak-only paragraph should preserve its content, got: {types:?}"
16886 );
16887 }
16888
16889 #[test]
16890 fn issue_410_full_reproducer_roundtrips() {
16891 let adf_json = r#"{"version":1,"type":"doc","content":[
16893 {"type":"paragraph","content":[
16894 {"type":"text","text":"before"},
16895 {"type":"hardBreak"},
16896 {"type":"hardBreak"},
16897 {"type":"text","text":"after"}
16898 ]},
16899 {"type":"paragraph","content":[
16900 {"type":"hardBreak"}
16901 ]}
16902 ]}"#;
16903 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16904 let md = adf_to_markdown(&doc).unwrap();
16905 let round_tripped = markdown_to_adf(&md).unwrap();
16906 assert_eq!(
16907 round_tripped.content.len(),
16908 2,
16909 "Should have exactly 2 paragraphs, got {}",
16910 round_tripped.content.len()
16911 );
16912 let p1 = round_tripped.content[0].content.as_ref().unwrap();
16914 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16915 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
16916 let p2 = round_tripped.content[1].content.as_ref().unwrap();
16918 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
16919 assert_eq!(types2, vec!["hardBreak"]);
16920 }
16921
16922 #[test]
16923 fn trailing_space_hardbreak_still_parsed() {
16924 let md = "line one \nline two\n";
16926 let doc = markdown_to_adf(md).unwrap();
16927 let inlines = doc.content[0].content.as_ref().unwrap();
16928 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16929 assert_eq!(
16930 types,
16931 vec!["text", "hardBreak", "text"],
16932 "Trailing-space hardBreak should still parse, got: {types:?}"
16933 );
16934 }
16935
16936 #[test]
16937 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
16938 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16940 {"type":"text","text":"text"},
16941 {"type":"hardBreak"}
16942 ]}]}"#;
16943 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16944 let md = adf_to_markdown(&doc).unwrap();
16945 let round_tripped = markdown_to_adf(&md).unwrap();
16946 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16947 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16948 assert_eq!(
16949 types,
16950 vec!["text", "hardBreak"],
16951 "Trailing hardBreak should be preserved, got: {types:?}"
16952 );
16953 }
16954
16955 #[test]
16956 #[test]
16957 fn table_with_header_row_uses_pipe_syntax() {
16958 let adf = AdfDocument {
16960 version: 1,
16961 doc_type: "doc".to_string(),
16962 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16963 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
16964 ])])],
16965 };
16966 let md = adf_to_markdown(&adf).unwrap();
16967 assert!(
16968 md.contains("| header cell |"),
16969 "Table with header row should use pipe syntax, got:\n{md}"
16970 );
16971 }
16972
16973 #[test]
16974 fn table_without_header_row_uses_directive_syntax() {
16975 let adf = AdfDocument {
16978 version: 1,
16979 doc_type: "doc".to_string(),
16980 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16981 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
16982 ])])],
16983 };
16984 let md = adf_to_markdown(&adf).unwrap();
16985 assert!(
16986 md.contains("::::table"),
16987 "Table without header row should use directive syntax, got:\n{md}"
16988 );
16989 }
16990
16991 #[test]
16992 fn tablecell_first_row_preserved_on_roundtrip() {
16993 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
16995 {"type":"tableRow","content":[
16996 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
16997 ]},
16998 {"type":"tableRow","content":[
16999 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17000 ]}
17001 ]}]}"#;
17002 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17003 let md = adf_to_markdown(&doc).unwrap();
17004 let round_tripped = markdown_to_adf(&md).unwrap();
17005 let rows = round_tripped.content[0].content.as_ref().unwrap();
17006 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17007 assert_eq!(
17008 row0_cell.node_type, "tableCell",
17009 "first row cell should remain tableCell, got: {}",
17010 row0_cell.node_type
17011 );
17012 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17013 assert_eq!(row1_cell.node_type, "tableCell");
17014 }
17015
17016 #[test]
17017 fn mixed_header_and_cell_first_row_uses_pipe() {
17018 let adf = AdfDocument {
17020 version: 1,
17021 doc_type: "doc".to_string(),
17022 content: vec![AdfNode::table(vec![
17023 AdfNode::table_row(vec![
17024 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17025 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17026 ]),
17027 AdfNode::table_row(vec![
17028 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17029 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17030 ]),
17031 ])],
17032 };
17033 let md = adf_to_markdown(&adf).unwrap();
17034 assert!(
17035 md.contains("| H1 |"),
17036 "Table with header first row should use pipe syntax, got:\n{md}"
17037 );
17038 assert!(!md.contains("::::table"), "should not use directive syntax");
17039 }
17040
17041 #[test]
17044 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17045 let adf = AdfDocument {
17048 version: 1,
17049 doc_type: "doc".to_string(),
17050 content: vec![AdfNode::table(vec![
17051 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17052 AdfNode::text("Header"),
17053 ])])]),
17054 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17055 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17056 ])])]),
17057 ])],
17058 };
17059 let md = adf_to_markdown(&adf).unwrap();
17060 assert!(
17061 md.contains(r"`a\|b`"),
17062 "Pipe inside code span must be escaped, got:\n{md}"
17063 );
17064 }
17065
17066 #[test]
17067 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17068 let adf = AdfDocument {
17069 version: 1,
17070 doc_type: "doc".to_string(),
17071 content: vec![AdfNode::table(vec![
17072 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17073 AdfNode::text("Header"),
17074 ])])]),
17075 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17076 AdfNode::text("x|y"),
17077 ])])]),
17078 ])],
17079 };
17080 let md = adf_to_markdown(&adf).unwrap();
17081 assert!(
17082 md.contains(r"x\|y"),
17083 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17084 );
17085 }
17086
17087 #[test]
17088 fn code_span_with_pipe_in_table_cell_roundtrips() {
17089 let adf_json = r#"{
17091 "version": 1,
17092 "type": "doc",
17093 "content": [{
17094 "type": "table",
17095 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17096 "content": [
17097 {"type": "tableRow", "content": [
17098 {"type": "tableHeader", "attrs": {}, "content": [
17099 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17100 ]},
17101 {"type": "tableHeader", "attrs": {}, "content": [
17102 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17103 ]}
17104 ]},
17105 {"type": "tableRow", "content": [
17106 {"type": "tableCell", "attrs": {}, "content": [
17107 {"type": "paragraph", "content": [
17108 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17109 ]}
17110 ]},
17111 {"type": "tableCell", "attrs": {}, "content": [
17112 {"type": "paragraph", "content": [
17113 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17114 ]}
17115 ]}
17116 ]}
17117 ]
17118 }]
17119 }"#;
17120 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17121 let md = adf_to_markdown(&doc).unwrap();
17122 let round_tripped = markdown_to_adf(&md).unwrap();
17123
17124 let rows = round_tripped.content[0].content.as_ref().unwrap();
17125 assert_eq!(
17126 rows.len(),
17127 2,
17128 "Table should have 2 rows, got: {}",
17129 rows.len()
17130 );
17131
17132 let body_row = rows[1].content.as_ref().unwrap();
17133 assert_eq!(
17134 body_row.len(),
17135 2,
17136 "Body row should have 2 cells (not split by the pipe), got: {}",
17137 body_row.len()
17138 );
17139
17140 let second_cell = &body_row[1];
17141 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17142 let inlines = para.content.as_ref().unwrap();
17143 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17144 assert_eq!(
17145 inlines[0].text.as_deref(),
17146 Some("parser.decode[T|json]"),
17147 "Code-span text must be preserved with literal pipe"
17148 );
17149 let marks = inlines[0]
17150 .marks
17151 .as_ref()
17152 .expect("code mark must be preserved");
17153 assert!(
17154 marks.iter().any(|m| m.mark_type == "code"),
17155 "text node should carry the code mark"
17156 );
17157 }
17158
17159 #[test]
17160 fn plain_text_pipe_in_table_cell_roundtrips() {
17161 let adf = AdfDocument {
17163 version: 1,
17164 doc_type: "doc".to_string(),
17165 content: vec![AdfNode::table(vec![
17166 AdfNode::table_row(vec![
17167 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17168 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17169 ]),
17170 AdfNode::table_row(vec![
17171 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17172 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17173 ]),
17174 ])],
17175 };
17176 let md = adf_to_markdown(&adf).unwrap();
17177 let round_tripped = markdown_to_adf(&md).unwrap();
17178 let rows = round_tripped.content[0].content.as_ref().unwrap();
17179 let body_row = rows[1].content.as_ref().unwrap();
17180 assert_eq!(
17181 body_row.len(),
17182 2,
17183 "Body row should keep 2 cells, got: {}",
17184 body_row.len()
17185 );
17186 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17187 .content
17188 .as_ref()
17189 .unwrap()[0]
17190 .text
17191 .as_deref();
17192 assert_eq!(first_cell_text, Some("a|b"));
17193 }
17194
17195 #[test]
17196 fn cell_contains_hard_break_true() {
17197 let para = AdfNode::paragraph(vec![
17198 AdfNode::text("a"),
17199 AdfNode::hard_break(),
17200 AdfNode::text("b"),
17201 ]);
17202 assert!(cell_contains_hard_break(¶));
17203 }
17204
17205 #[test]
17206 fn cell_contains_hard_break_false() {
17207 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17208 assert!(!cell_contains_hard_break(¶));
17209 }
17210
17211 #[test]
17212 fn cell_contains_hard_break_empty() {
17213 let para = AdfNode::paragraph(vec![]);
17214 assert!(!cell_contains_hard_break(¶));
17215 }
17216
17217 #[test]
17220 fn multi_paragraph_panel_roundtrips() {
17221 let adf = AdfDocument {
17222 version: 1,
17223 doc_type: "doc".to_string(),
17224 content: vec![AdfNode {
17225 node_type: "panel".to_string(),
17226 attrs: Some(serde_json::json!({"panelType": "info"})),
17227 content: Some(vec![
17228 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17229 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17230 ]),
17231 text: None,
17232 marks: None,
17233 local_id: None,
17234 parameters: None,
17235 }],
17236 };
17237
17238 let md = adf_to_markdown(&adf).unwrap();
17239 assert!(
17241 md.contains("First paragraph.\n\nSecond paragraph."),
17242 "Panel should have blank line between paragraphs, got:\n{md}"
17243 );
17244
17245 let roundtripped = markdown_to_adf(&md).unwrap();
17247 assert_eq!(roundtripped.content.len(), 1);
17248 assert_eq!(roundtripped.content[0].node_type, "panel");
17249 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17250 assert_eq!(
17251 panel_content.len(),
17252 2,
17253 "Panel should have 2 paragraphs after round-trip, got {}",
17254 panel_content.len()
17255 );
17256 }
17257
17258 #[test]
17259 fn multi_paragraph_expand_roundtrips() {
17260 let adf = AdfDocument {
17261 version: 1,
17262 doc_type: "doc".to_string(),
17263 content: vec![AdfNode {
17264 node_type: "expand".to_string(),
17265 attrs: Some(serde_json::json!({"title": "Details"})),
17266 content: Some(vec![
17267 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17268 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17269 ]),
17270 text: None,
17271 marks: None,
17272 local_id: None,
17273 parameters: None,
17274 }],
17275 };
17276
17277 let md = adf_to_markdown(&adf).unwrap();
17278 let roundtripped = markdown_to_adf(&md).unwrap();
17279 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17280 assert_eq!(
17281 expand_content.len(),
17282 2,
17283 "Expand should have 2 paragraphs after round-trip, got {}",
17284 expand_content.len()
17285 );
17286 }
17287
17288 #[test]
17289 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17290 let cell_content = vec![
17291 AdfNode {
17292 node_type: "nestedExpand".to_string(),
17293 attrs: Some(serde_json::json!({"title": "First"})),
17294 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17295 text: None,
17296 marks: None,
17297 local_id: None,
17298 parameters: None,
17299 },
17300 AdfNode {
17301 node_type: "nestedExpand".to_string(),
17302 attrs: Some(serde_json::json!({"title": "Second"})),
17303 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17304 text: None,
17305 marks: None,
17306 local_id: None,
17307 parameters: None,
17308 },
17309 ];
17310 let adf = AdfDocument {
17311 version: 1,
17312 doc_type: "doc".to_string(),
17313 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17314 AdfNode::table_cell(cell_content),
17315 ])])],
17316 };
17317
17318 let md = adf_to_markdown(&adf).unwrap();
17319 assert!(
17320 md.contains(":::\n\n:::nested-expand"),
17321 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17322 );
17323
17324 let rt = markdown_to_adf(&md).unwrap();
17325 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17326 .content
17327 .as_ref()
17328 .unwrap()[0];
17329 let cell_nodes = cell.content.as_ref().unwrap();
17330 let expand_count = cell_nodes
17331 .iter()
17332 .filter(|n| n.node_type == "nestedExpand")
17333 .count();
17334 assert_eq!(
17335 expand_count, 2,
17336 "Both nested-expands should survive round-trip, got {expand_count}"
17337 );
17338 }
17339
17340 #[test]
17341 fn multi_paragraph_in_table_cell_roundtrip() {
17342 let adf = AdfDocument {
17344 version: 1,
17345 doc_type: "doc".to_string(),
17346 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17347 AdfNode::table_cell(vec![
17348 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17349 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17350 ]),
17351 ])])],
17352 };
17353
17354 let md = adf_to_markdown(&adf).unwrap();
17355 assert!(
17356 md.contains("Para one.\n\nPara two."),
17357 "Should have blank line between paragraphs in cell, got:\n{md}"
17358 );
17359
17360 let rt = markdown_to_adf(&md).unwrap();
17361 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17362 .content
17363 .as_ref()
17364 .unwrap()[0];
17365 let para_count = cell
17366 .content
17367 .as_ref()
17368 .unwrap()
17369 .iter()
17370 .filter(|n| n.node_type == "paragraph")
17371 .count();
17372 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17373 }
17374
17375 #[test]
17376 fn panel_inside_table_cell_roundtrip() {
17377 let adf = AdfDocument {
17379 version: 1,
17380 doc_type: "doc".to_string(),
17381 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17382 AdfNode::table_cell(vec![
17383 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17384 AdfNode {
17385 node_type: "panel".to_string(),
17386 attrs: Some(serde_json::json!({"panelType": "info"})),
17387 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17388 "Panel content",
17389 )])]),
17390 text: None,
17391 marks: None,
17392 local_id: None,
17393 parameters: None,
17394 },
17395 ]),
17396 ])])],
17397 };
17398
17399 let md = adf_to_markdown(&adf).unwrap();
17400 assert!(
17401 md.contains(":::panel"),
17402 "Should contain panel directive, got:\n{md}"
17403 );
17404
17405 let rt = markdown_to_adf(&md).unwrap();
17406 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17407 .content
17408 .as_ref()
17409 .unwrap()[0];
17410 let has_panel = cell
17411 .content
17412 .as_ref()
17413 .unwrap()
17414 .iter()
17415 .any(|n| n.node_type == "panel");
17416 assert!(has_panel, "Panel should survive round-trip in table cell");
17417 }
17418
17419 #[test]
17420 fn three_consecutive_expands_in_table_cell() {
17421 let make_expand = |title: &str| AdfNode {
17422 node_type: "nestedExpand".to_string(),
17423 attrs: Some(serde_json::json!({"title": title})),
17424 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17425 text: None,
17426 marks: None,
17427 local_id: None,
17428 parameters: None,
17429 };
17430 let adf = AdfDocument {
17431 version: 1,
17432 doc_type: "doc".to_string(),
17433 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17434 AdfNode::table_cell(vec![
17435 make_expand("First"),
17436 make_expand("Second"),
17437 make_expand("Third"),
17438 ]),
17439 ])])],
17440 };
17441
17442 let md = adf_to_markdown(&adf).unwrap();
17443 let rt = markdown_to_adf(&md).unwrap();
17444 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17445 .content
17446 .as_ref()
17447 .unwrap()[0];
17448 let expand_count = cell
17449 .content
17450 .as_ref()
17451 .unwrap()
17452 .iter()
17453 .filter(|n| n.node_type == "nestedExpand")
17454 .count();
17455 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17456 }
17457
17458 #[test]
17461 fn nested_expand_inside_panel() {
17462 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17463 let adf = markdown_to_adf(md).unwrap();
17464
17465 assert_eq!(adf.content.len(), 1);
17467 assert_eq!(adf.content[0].node_type, "panel");
17468
17469 let panel_content = adf.content[0].content.as_ref().unwrap();
17471 assert!(
17472 panel_content.len() >= 2,
17473 "Panel should contain expand + paragraph, got {} nodes",
17474 panel_content.len()
17475 );
17476 }
17477
17478 #[test]
17479 fn nested_expand_inside_table_cell() {
17480 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17481 let adf = markdown_to_adf(md).unwrap();
17482
17483 assert_eq!(adf.content.len(), 1);
17485 assert_eq!(adf.content[0].node_type, "table");
17486
17487 let rows = adf.content[0].content.as_ref().unwrap();
17489 assert_eq!(rows.len(), 1);
17490 let cells = rows[0].content.as_ref().unwrap();
17491 assert_eq!(cells.len(), 1);
17492 let cell_content = cells[0].content.as_ref().unwrap();
17493 assert!(
17494 cell_content.iter().any(|n| n.node_type == "expand"),
17495 "Cell should contain an expand node, got: {:?}",
17496 cell_content
17497 .iter()
17498 .map(|n| &n.node_type)
17499 .collect::<Vec<_>>()
17500 );
17501 }
17502
17503 #[test]
17504 fn nested_expand_inside_layout_column() {
17505 let md = ":::layout\n:::column{width=100}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::";
17506 let adf = markdown_to_adf(md).unwrap();
17507
17508 assert_eq!(adf.content.len(), 1);
17509 assert_eq!(adf.content[0].node_type, "layoutSection");
17510
17511 let columns = adf.content[0].content.as_ref().unwrap();
17512 assert_eq!(columns.len(), 1);
17513 let col_content = columns[0].content.as_ref().unwrap();
17514 assert!(
17515 col_content.iter().any(|n| n.node_type == "expand"),
17516 "Column should contain an expand node, got: {:?}",
17517 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17518 );
17519 }
17520
17521 #[test]
17522 fn expand_localid_in_directive_attrs() {
17523 let adf_json = r#"{"version":1,"type":"doc","content":[
17525 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17526 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17527 ]}
17528 ]}"#;
17529 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17530 let md = adf_to_markdown(&doc).unwrap();
17531 assert!(
17532 md.contains("localId=exp-001"),
17533 "should contain localId: {md}"
17534 );
17535 assert!(
17536 md.contains(":::expand{"),
17537 "should have expand directive with attrs: {md}"
17538 );
17539 assert!(
17540 !md.contains(":::\n{localId="),
17541 "localId should NOT be trailing: {md}"
17542 );
17543 }
17544
17545 #[test]
17546 fn expand_localid_roundtrip() {
17547 let adf_json = r#"{"version":1,"type":"doc","content":[
17548 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17549 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17550 ]}
17551 ]}"#;
17552 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17553 let md = adf_to_markdown(&doc).unwrap();
17554 let rt = markdown_to_adf(&md).unwrap();
17555 let expand = &rt.content[0];
17556 assert_eq!(expand.node_type, "expand");
17557 assert_eq!(
17558 expand.local_id.as_deref(),
17559 Some("exp-001"),
17560 "expand localId should survive round-trip"
17561 );
17562 assert_eq!(
17563 expand.attrs.as_ref().unwrap()["title"],
17564 "Details",
17565 "expand title should survive round-trip"
17566 );
17567 }
17568
17569 #[test]
17570 fn nested_expand_localid_roundtrip() {
17571 let adf_json = r#"{"version":1,"type":"doc","content":[
17572 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17573 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17574 ]}
17575 ]}"#;
17576 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17577 let md = adf_to_markdown(&doc).unwrap();
17578 assert!(
17579 md.contains(":::nested-expand{"),
17580 "should have directive: {md}"
17581 );
17582 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17583 let rt = markdown_to_adf(&md).unwrap();
17584 let ne = &rt.content[0];
17585 assert_eq!(ne.node_type, "nestedExpand");
17586 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17587 }
17588
17589 #[test]
17590 fn nested_expand_localid_followed_by_content() {
17591 let adf_json = "{\
17593 \"version\":1,\"type\":\"doc\",\"content\":[\
17594 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17595 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17596 ]},\
17597 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17598 ]}";
17599 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17600 let md = adf_to_markdown(&doc).unwrap();
17601 let rt = markdown_to_adf(&md).unwrap();
17602 let ne = &rt.content[0];
17604 assert_eq!(ne.node_type, "nestedExpand");
17605 assert_eq!(
17606 ne.local_id.as_deref(),
17607 Some("exp-001"),
17608 "nestedExpand should preserve localId"
17609 );
17610 let para = &rt.content[1];
17612 assert_eq!(para.node_type, "paragraph");
17613 let text = para.content.as_ref().unwrap()[0]
17614 .text
17615 .as_deref()
17616 .unwrap_or("");
17617 assert!(
17618 !text.contains("localId"),
17619 "following paragraph should not contain localId: {text}"
17620 );
17621 assert!(
17622 text.contains("after"),
17623 "following paragraph should contain 'after': {text}"
17624 );
17625 }
17626
17627 #[test]
17628 fn expand_localid_without_title() {
17629 let adf_json = r#"{"version":1,"type":"doc","content":[
17630 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17631 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17632 ]}
17633 ]}"#;
17634 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17635 let md = adf_to_markdown(&doc).unwrap();
17636 assert!(
17637 md.contains(":::expand{localId=exp-002}"),
17638 "should have localId without title: {md}"
17639 );
17640 let rt = markdown_to_adf(&md).unwrap();
17641 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17642 }
17643
17644 #[test]
17645 fn expand_localid_stripped() {
17646 let adf_json = r#"{"version":1,"type":"doc","content":[
17647 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17648 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17649 ]}
17650 ]}"#;
17651 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17652 let opts = RenderOptions {
17653 strip_local_ids: true,
17654 };
17655 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17656 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17657 assert!(
17658 md.contains(":::expand{title=\"X\"}"),
17659 "title should remain: {md}"
17660 );
17661 }
17662
17663 #[test]
17666 fn expand_top_level_localid_roundtrip() {
17667 let adf_json = r#"{"version":1,"type":"doc","content":[
17669 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17670 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17671 ]}
17672 ]}"#;
17673 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17674 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17675 let md = adf_to_markdown(&doc).unwrap();
17676 assert!(
17677 md.contains("localId=abc-123"),
17678 "JFM should contain localId: {md}"
17679 );
17680 let rt = markdown_to_adf(&md).unwrap();
17681 let expand = &rt.content[0];
17682 assert_eq!(expand.node_type, "expand");
17683 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17684 assert_eq!(
17685 expand.attrs.as_ref().unwrap()["title"],
17686 "My Section",
17687 "title should survive round-trip"
17688 );
17689 }
17690
17691 #[test]
17692 fn expand_parameters_roundtrip() {
17693 let adf_json = r#"{"version":1,"type":"doc","content":[
17695 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17696 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17697 ]}
17698 ]}"#;
17699 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17700 assert!(doc.content[0].parameters.is_some());
17701 let md = adf_to_markdown(&doc).unwrap();
17702 assert!(md.contains("params="), "JFM should contain params: {md}");
17703 let rt = markdown_to_adf(&md).unwrap();
17704 let expand = &rt.content[0];
17705 let params = expand
17706 .parameters
17707 .as_ref()
17708 .expect("parameters should survive round-trip");
17709 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17710 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17711 }
17712
17713 #[test]
17714 fn expand_localid_and_parameters_roundtrip() {
17715 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"}]}]}]}"#;
17717 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17718 let md = adf_to_markdown(&doc).unwrap();
17719 let rt = markdown_to_adf(&md).unwrap();
17720 let expand = &rt.content[0];
17721 assert_eq!(expand.node_type, "expand");
17722 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17723 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17724 let params = expand
17725 .parameters
17726 .as_ref()
17727 .expect("parameters should survive");
17728 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17729 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17730 }
17731
17732 #[test]
17733 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17734 let adf_json = r#"{"version":1,"type":"doc","content":[
17735 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17736 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17737 ]}
17738 ]}"#;
17739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17740 let md = adf_to_markdown(&doc).unwrap();
17741 assert!(
17742 md.contains(":::nested-expand{"),
17743 "should use nested-expand: {md}"
17744 );
17745 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17746 assert!(md.contains("params="), "should have params: {md}");
17747 let rt = markdown_to_adf(&md).unwrap();
17748 let ne = &rt.content[0];
17749 assert_eq!(ne.node_type, "nestedExpand");
17750 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17751 assert_eq!(
17752 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17753 "nm-001"
17754 );
17755 }
17756
17757 #[test]
17758 fn expand_top_level_localid_stripped() {
17759 let adf_json = r#"{"version":1,"type":"doc","content":[
17761 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17762 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17763 ]}
17764 ]}"#;
17765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17766 let opts = RenderOptions {
17767 strip_local_ids: true,
17768 };
17769 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17770 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17771 assert!(
17772 md.contains(":::expand{title=\"X\"}"),
17773 "title should remain: {md}"
17774 );
17775 }
17776
17777 #[test]
17778 fn expand_parameters_without_localid() {
17779 let adf_json = r#"{"version":1,"type":"doc","content":[
17781 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17782 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17783 ]}
17784 ]}"#;
17785 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17786 let md = adf_to_markdown(&doc).unwrap();
17787 assert!(!md.contains("localId"), "no localId: {md}");
17788 assert!(md.contains("params="), "has params: {md}");
17789 let rt = markdown_to_adf(&md).unwrap();
17790 assert!(rt.content[0].local_id.is_none());
17791 assert_eq!(
17792 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17793 "solo"
17794 );
17795 }
17796
17797 #[test]
17798 fn expand_localid_without_parameters() {
17799 let adf_json = r#"{"version":1,"type":"doc","content":[
17801 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17802 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17803 ]}
17804 ]}"#;
17805 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17806 let md = adf_to_markdown(&doc).unwrap();
17807 assert!(md.contains("localId=lid-only"), "has localId: {md}");
17808 assert!(!md.contains("params="), "no params: {md}");
17809 let rt = markdown_to_adf(&md).unwrap();
17810 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17811 assert!(rt.content[0].parameters.is_none());
17812 }
17813
17814 #[test]
17815 fn nested_panel_inside_panel() {
17816 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17817 let adf = markdown_to_adf(md).unwrap();
17818
17819 assert_eq!(adf.content.len(), 1);
17821 assert_eq!(adf.content[0].node_type, "panel");
17822
17823 let panel_content = adf.content[0].content.as_ref().unwrap();
17825 assert!(
17826 panel_content.iter().any(|n| n.node_type == "panel"),
17827 "Outer panel should contain an inner panel, got: {:?}",
17828 panel_content
17829 .iter()
17830 .map(|n| &n.node_type)
17831 .collect::<Vec<_>>()
17832 );
17833 }
17834
17835 #[test]
17836 fn content_after_directive_table_is_preserved() {
17837 let md = "\
17839## Before table
17840
17841::::table{layout=default}
17842:::tr
17843:::th{}
17844Cell
17845:::
17846:::
17847::::
17848
17849## After table
17850
17851Paragraph after.";
17852 let adf = markdown_to_adf(md).unwrap();
17853 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17854 assert_eq!(
17855 types,
17856 vec!["heading", "table", "heading", "paragraph"],
17857 "Content after table was dropped: got {types:?}"
17858 );
17859 }
17860
17861 #[test]
17862 fn paragraph_after_directive_table_is_preserved() {
17863 let md = "\
17865::::table{layout=default}
17866:::tr
17867:::th{}
17868Header
17869:::
17870:::
17871::::
17872
17873Just a paragraph.";
17874 let adf = markdown_to_adf(md).unwrap();
17875 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17876 assert_eq!(
17877 types,
17878 vec!["table", "paragraph"],
17879 "Paragraph after table was dropped: got {types:?}"
17880 );
17881 }
17882
17883 #[test]
17884 fn extension_after_directive_table_is_preserved() {
17885 let md = "\
17887::::table{layout=default}
17888:::tr
17889:::th{}
17890Header
17891:::
17892:::
17893::::
17894
17895::extension{type=com.atlassian.confluence.macro.core key=toc}";
17896 let adf = markdown_to_adf(md).unwrap();
17897 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17898 assert_eq!(
17899 types,
17900 vec!["table", "extension"],
17901 "Extension after table was dropped: got {types:?}"
17902 );
17903 }
17904
17905 #[test]
17906 fn multiple_blocks_after_directive_table() {
17907 let md = "\
17909## Heading 1
17910
17911::::table{layout=default}
17912:::tr
17913:::td{}
17914A
17915:::
17916:::td{}
17917B
17918:::
17919:::
17920::::
17921
17922## Heading 2
17923
17924Some text.
17925
17926---
17927
17928::::table{layout=default}
17929:::tr
17930:::th{}
17931C
17932:::
17933:::
17934::::
17935
17936## Heading 3";
17937 let adf = markdown_to_adf(md).unwrap();
17938 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17939 assert_eq!(
17940 types,
17941 vec![
17942 "heading",
17943 "table",
17944 "heading",
17945 "paragraph",
17946 "rule",
17947 "table",
17948 "heading"
17949 ],
17950 "Content after tables was dropped: got {types:?}"
17951 );
17952 }
17953
17954 #[test]
17957 fn adf_table_caption_to_markdown() {
17958 let doc = AdfDocument {
17959 version: 1,
17960 doc_type: "doc".to_string(),
17961 content: vec![AdfNode::table(vec![
17962 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17963 AdfNode::text("cell"),
17964 ])])]),
17965 AdfNode::caption(vec![AdfNode::text("Table caption")]),
17966 ])],
17967 };
17968 let md = adf_to_markdown(&doc).unwrap();
17969 assert!(
17970 md.contains("::::table"),
17971 "table with caption must use directive form"
17972 );
17973 assert!(
17974 md.contains(":::caption"),
17975 "caption directive missing, got: {md}"
17976 );
17977 assert!(
17978 md.contains("Table caption"),
17979 "caption text missing, got: {md}"
17980 );
17981 }
17982
17983 #[test]
17984 fn directive_table_caption_parses() {
17985 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
17986 let doc = markdown_to_adf(md).unwrap();
17987 let table = &doc.content[0];
17988 assert_eq!(table.node_type, "table");
17989 let children = table.content.as_ref().unwrap();
17990 assert_eq!(children.len(), 2, "expected row + caption");
17991 assert_eq!(children[0].node_type, "tableRow");
17992 assert_eq!(children[1].node_type, "caption");
17993 let caption_content = children[1].content.as_ref().unwrap();
17994 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
17995 }
17996
17997 #[test]
17998 fn table_caption_round_trip_from_adf_json() {
17999 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18000 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18001 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18002 ]}]}"#;
18003 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18004 let md = adf_to_markdown(&doc).unwrap();
18005 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18006 let round_tripped = markdown_to_adf(&md).unwrap();
18007 let children = round_tripped.content[0].content.as_ref().unwrap();
18008 let caption = children.iter().find(|n| n.node_type == "caption");
18009 assert!(caption.is_some(), "caption lost on round-trip");
18010 let caption_text = caption.unwrap().content.as_ref().unwrap();
18011 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18012 }
18013
18014 #[test]
18015 fn table_caption_with_inline_marks_round_trips() {
18016 let doc = AdfDocument {
18017 version: 1,
18018 doc_type: "doc".to_string(),
18019 content: vec![AdfNode::table(vec![
18020 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18021 AdfNode::text("data"),
18022 ])])]),
18023 AdfNode::caption(vec![
18024 AdfNode::text("Caption with "),
18025 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18026 ]),
18027 ])],
18028 };
18029 let md = adf_to_markdown(&doc).unwrap();
18030 assert!(md.contains("**bold**"), "bold mark missing in caption");
18031 let round_tripped = markdown_to_adf(&md).unwrap();
18032 let caption = round_tripped.content[0]
18033 .content
18034 .as_ref()
18035 .unwrap()
18036 .iter()
18037 .find(|n| n.node_type == "caption")
18038 .expect("caption node missing after round-trip");
18039 let inlines = caption.content.as_ref().unwrap();
18040 let bold_node = inlines.iter().find(|n| {
18041 n.marks
18042 .as_ref()
18043 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18044 });
18045 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18046 }
18047
18048 #[test]
18051 fn table_caption_localid_roundtrip() {
18052 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18053 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18054 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18055 ]}]}"#;
18056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18057 let md = adf_to_markdown(&doc).unwrap();
18058 assert!(
18059 md.contains("localId=abcdef123456"),
18060 "table caption localId should appear in markdown: {md}"
18061 );
18062 let rt = markdown_to_adf(&md).unwrap();
18063 let caption = rt.content[0]
18064 .content
18065 .as_ref()
18066 .unwrap()
18067 .iter()
18068 .find(|n| n.node_type == "caption")
18069 .expect("caption should survive round-trip");
18070 assert_eq!(
18071 caption.attrs.as_ref().unwrap()["localId"],
18072 "abcdef123456",
18073 "table caption localId should round-trip"
18074 );
18075 }
18076
18077 #[test]
18078 fn table_caption_without_localid_unchanged() {
18079 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18080 let doc = markdown_to_adf(md).unwrap();
18081 let caption = doc.content[0]
18082 .content
18083 .as_ref()
18084 .unwrap()
18085 .iter()
18086 .find(|n| n.node_type == "caption")
18087 .unwrap();
18088 assert!(
18089 caption.attrs.is_none(),
18090 "table caption without localId should not gain attrs"
18091 );
18092 let md2 = adf_to_markdown(&doc).unwrap();
18093 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18094 }
18095
18096 #[test]
18097 fn table_caption_localid_stripped_when_option_set() {
18098 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18099 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18100 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18101 ]}]}"#;
18102 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18103 let opts = RenderOptions {
18104 strip_local_ids: true,
18105 ..Default::default()
18106 };
18107 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18108 assert!(
18109 !md.contains("localId"),
18110 "table caption localId should be stripped: {md}"
18111 );
18112 }
18113
18114 #[test]
18115 #[test]
18116 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18117 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"}]}]}]}]}]}"#;
18119 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18120 let md = adf_to_markdown(&doc).unwrap();
18121 let round_tripped = markdown_to_adf(&md).unwrap();
18122 let rows = round_tripped.content[0].content.as_ref().unwrap();
18123 let cell = &rows[0].content.as_ref().unwrap()[0];
18124 assert!(
18125 cell.attrs.is_some(),
18126 "tableCell attrs should be preserved, got None"
18127 );
18128 assert_eq!(
18129 cell.attrs.as_ref().unwrap(),
18130 &serde_json::json!({}),
18131 "tableCell attrs should be an empty object"
18132 );
18133 }
18134
18135 #[test]
18136 fn tablecell_empty_attrs_serialized_in_json() {
18137 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"}]}]}]}]}]}"#;
18139 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18140 let md = adf_to_markdown(&doc).unwrap();
18141 let round_tripped = markdown_to_adf(&md).unwrap();
18142 let json = serde_json::to_string(&round_tripped).unwrap();
18143 assert!(
18144 json.contains(r#""attrs":{}"#),
18145 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18146 );
18147 }
18148
18149 #[test]
18150 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18151 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"}]}]}]}]}]}"#;
18153 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18154 let md = adf_to_markdown(&doc).unwrap();
18155 assert!(
18157 md.contains("{} hello"),
18158 "cell with empty attrs should render '{{}} hello', got: {md}"
18159 );
18160 assert!(
18161 !md.contains("{} world"),
18162 "cell without attrs should not render '{{}}', got: {md}"
18163 );
18164 }
18165
18166 #[test]
18167 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18168 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"}]}]}]}]}]}"#;
18170 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18171 let md = adf_to_markdown(&doc).unwrap();
18172 let round_tripped = markdown_to_adf(&md).unwrap();
18173 let rows = round_tripped.content[0].content.as_ref().unwrap();
18174 let cell = &rows[0].content.as_ref().unwrap()[0];
18175 assert!(
18176 cell.attrs.is_none(),
18177 "tableCell without attrs should stay None, got: {:?}",
18178 cell.attrs
18179 );
18180 }
18181
18182 #[test]
18183 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18184 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"}]}]}]}]}]}"##;
18186 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18187 let md = adf_to_markdown(&doc).unwrap();
18188 let round_tripped = markdown_to_adf(&md).unwrap();
18189 let rows = round_tripped.content[0].content.as_ref().unwrap();
18190 let cell = &rows[1].content.as_ref().unwrap()[0];
18191 let attrs = cell.attrs.as_ref().unwrap();
18192 assert_eq!(attrs["background"], "#DEEBFF");
18193 assert_eq!(attrs["colspan"], 2);
18194 }
18195
18196 #[test]
18197 fn pipe_table_not_used_when_caption_present() {
18198 let doc = AdfDocument {
18199 version: 1,
18200 doc_type: "doc".to_string(),
18201 content: vec![AdfNode::table(vec![
18202 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18203 AdfNode::text("H"),
18204 ])])]),
18205 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18206 AdfNode::text("D"),
18207 ])])]),
18208 AdfNode::caption(vec![AdfNode::text("cap")]),
18209 ])],
18210 };
18211 let md = adf_to_markdown(&doc).unwrap();
18212 assert!(
18213 md.contains("::::table"),
18214 "pipe syntax should not be used when caption is present"
18215 );
18216 }
18217
18218 #[test]
18221 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18222 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18225 {"type":"listItem","content":[{"type":"paragraph","content":[
18226 {"type":"text","text":"1. First item"},
18227 {"type":"hardBreak"},
18228 {"type":"text","text":"2. Honouring existing commitments"}
18229 ]}]}
18230 ]}]}"#;
18231 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18232 let md = adf_to_markdown(&doc).unwrap();
18233
18234 assert!(
18236 md.contains(" 2. Honouring"),
18237 "Continuation line should be indented, got:\n{md}"
18238 );
18239
18240 let rt = markdown_to_adf(&md).unwrap();
18242 let list = &rt.content[0];
18243 assert_eq!(list.node_type, "bulletList");
18244 let items = list.content.as_ref().unwrap();
18245 assert_eq!(
18246 items.len(),
18247 1,
18248 "Should be one list item, got {}",
18249 items.len()
18250 );
18251
18252 let para = &items[0].content.as_ref().unwrap()[0];
18253 let inlines = para.content.as_ref().unwrap();
18254 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18255 assert_eq!(
18256 types,
18257 vec!["text", "hardBreak", "text"],
18258 "Expected text+hardBreak+text, got {types:?}"
18259 );
18260 assert_eq!(
18261 inlines[2].text.as_deref().unwrap(),
18262 "2. Honouring existing commitments"
18263 );
18264 }
18265
18266 #[test]
18267 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18268 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18270 {"type":"listItem","content":[{"type":"paragraph","content":[
18271 {"type":"text","text":"Introduction "},
18272 {"type":"hardBreak"},
18273 {"type":"text","text":"3. Third point"}
18274 ]}]}
18275 ]}]}"#;
18276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18277 let md = adf_to_markdown(&doc).unwrap();
18278 let rt = markdown_to_adf(&md).unwrap();
18279
18280 let list = &rt.content[0];
18281 assert_eq!(list.node_type, "orderedList");
18282 let items = list.content.as_ref().unwrap();
18283 assert_eq!(items.len(), 1);
18284
18285 let para = &items[0].content.as_ref().unwrap()[0];
18286 let inlines = para.content.as_ref().unwrap();
18287 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18288 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18289 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18290 }
18291
18292 #[test]
18293 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18294 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18296 {"type":"listItem","content":[{"type":"paragraph","content":[
18297 {"type":"text","text":"Header "},
18298 {"type":"hardBreak"},
18299 {"type":"text","text":"- not a sub-item"}
18300 ]}]}
18301 ]}]}"#;
18302 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18303 let md = adf_to_markdown(&doc).unwrap();
18304 let rt = markdown_to_adf(&md).unwrap();
18305
18306 let list = &rt.content[0];
18307 assert_eq!(list.node_type, "bulletList");
18308 let items = list.content.as_ref().unwrap();
18309 assert_eq!(
18310 items.len(),
18311 1,
18312 "Should be one list item, not {}",
18313 items.len()
18314 );
18315
18316 let para = &items[0].content.as_ref().unwrap()[0];
18317 let inlines = para.content.as_ref().unwrap();
18318 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18319 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18320 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18321 }
18322
18323 #[test]
18324 fn hardbreak_continuation_followed_by_sub_list() {
18325 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18327 {"type":"listItem","content":[
18328 {"type":"paragraph","content":[
18329 {"type":"text","text":"Main item "},
18330 {"type":"hardBreak"},
18331 {"type":"text","text":"continued here"}
18332 ]},
18333 {"type":"bulletList","content":[
18334 {"type":"listItem","content":[{"type":"paragraph","content":[
18335 {"type":"text","text":"sub-item"}
18336 ]}]}
18337 ]}
18338 ]}
18339 ]}]}"#;
18340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18341 let md = adf_to_markdown(&doc).unwrap();
18342 let rt = markdown_to_adf(&md).unwrap();
18343
18344 let list = &rt.content[0];
18345 let items = list.content.as_ref().unwrap();
18346 assert_eq!(items.len(), 1);
18347
18348 let item_content = items[0].content.as_ref().unwrap();
18349 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18350 assert_eq!(item_content[0].node_type, "paragraph");
18351 assert_eq!(item_content[1].node_type, "bulletList");
18352
18353 let inlines = item_content[0].content.as_ref().unwrap();
18355 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18356 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18357 }
18358
18359 #[test]
18360 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18361 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18363 {"type":"listItem","content":[{"type":"paragraph","content":[
18364 {"type":"text","text":"Preamble "},
18365 {"type":"hardBreak"},
18366 {"type":"text","text":"1. Alpha "},
18367 {"type":"hardBreak"},
18368 {"type":"text","text":"2. Bravo"}
18369 ]}]}
18370 ]}]}"#;
18371 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18372 let md = adf_to_markdown(&doc).unwrap();
18373 let rt = markdown_to_adf(&md).unwrap();
18374
18375 let items = rt.content[0].content.as_ref().unwrap();
18376 assert_eq!(items.len(), 1);
18377
18378 let inlines = items[0].content.as_ref().unwrap()[0]
18379 .content
18380 .as_ref()
18381 .unwrap();
18382 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18383 assert_eq!(
18384 types,
18385 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18386 );
18387 }
18388
18389 #[test]
18390 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18391 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18395 {"type":"listItem","content":[{"type":"paragraph","content":[
18396 {"type":"text","text":"ends with break"},
18397 {"type":"hardBreak"}
18398 ]}]}
18399 ]}]}"#;
18400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18401 let md = adf_to_markdown(&doc).unwrap();
18402 let rt = markdown_to_adf(&md).unwrap();
18403
18404 let list = &rt.content[0];
18405 assert_eq!(list.node_type, "bulletList");
18406 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18407 .content
18408 .as_ref()
18409 .unwrap();
18410 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18411 assert_eq!(types, vec!["text", "hardBreak"]);
18412 }
18413
18414 #[test]
18415 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18416 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18419 {"type":"listItem","content":[{"type":"paragraph","content":[
18420 {"type":"text","text":"ends with break"},
18421 {"type":"hardBreak"}
18422 ]}]}
18423 ]}]}"#;
18424 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18425 let md = adf_to_markdown(&doc).unwrap();
18426 let rt = markdown_to_adf(&md).unwrap();
18427
18428 let list = &rt.content[0];
18429 assert_eq!(list.node_type, "orderedList");
18430 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18431 .content
18432 .as_ref()
18433 .unwrap();
18434 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18435 assert_eq!(types, vec!["text", "hardBreak"]);
18436 }
18437
18438 #[test]
18439 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18440 let md = "- first line \n 2. continued\n";
18444 let doc = markdown_to_adf(md).unwrap();
18445
18446 let list = &doc.content[0];
18447 assert_eq!(list.node_type, "bulletList");
18448 let items = list.content.as_ref().unwrap();
18449 assert_eq!(
18450 items.len(),
18451 1,
18452 "Should be one list item, got {}",
18453 items.len()
18454 );
18455
18456 let para = &items[0].content.as_ref().unwrap()[0];
18457 let inlines = para.content.as_ref().unwrap();
18458 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18459 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18460 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18461 }
18462
18463 #[test]
18464 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18465 let md = "1. first line \n - continued\n";
18468 let doc = markdown_to_adf(md).unwrap();
18469
18470 let list = &doc.content[0];
18471 assert_eq!(list.node_type, "orderedList");
18472 let items = list.content.as_ref().unwrap();
18473 assert_eq!(
18474 items.len(),
18475 1,
18476 "Should be one list item, got {}",
18477 items.len()
18478 );
18479
18480 let para = &items[0].content.as_ref().unwrap()[0];
18481 let inlines = para.content.as_ref().unwrap();
18482 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18483 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18484 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18485 }
18486
18487 #[test]
18488 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18489 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18492 {"type":"listItem","content":[
18493 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18494 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18495 ]}
18496 ]}]}"#;
18497 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18498 let md = adf_to_markdown(&doc).unwrap();
18499 let rt = markdown_to_adf(&md).unwrap();
18500
18501 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18502 let list = &rt.content[0];
18503 assert_eq!(list.node_type, "bulletList");
18504 let items = list.content.as_ref().unwrap();
18505 assert_eq!(items.len(), 1);
18506 let item_content = items[0].content.as_ref().unwrap();
18507 assert_eq!(
18508 item_content.len(),
18509 2,
18510 "Expected 2 paragraphs inside the list item, got {}",
18511 item_content.len()
18512 );
18513 assert_eq!(item_content[0].node_type, "paragraph");
18514 assert_eq!(item_content[1].node_type, "paragraph");
18515 let text = item_content[1].content.as_ref().unwrap()[0]
18516 .text
18517 .as_deref()
18518 .unwrap();
18519 assert_eq!(text, "2. Honouring existing commitments");
18520 }
18521
18522 #[test]
18523 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18524 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18526 {"type":"listItem","content":[
18527 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18528 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18529 ]}
18530 ]}]}"#;
18531 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18532 let md = adf_to_markdown(&doc).unwrap();
18533 let rt = markdown_to_adf(&md).unwrap();
18534
18535 let items = rt.content[0].content.as_ref().unwrap();
18536 assert_eq!(items.len(), 1);
18537 let item_content = items[0].content.as_ref().unwrap();
18538 assert_eq!(item_content.len(), 2);
18539 assert_eq!(item_content[1].node_type, "paragraph");
18540 let text = item_content[1].content.as_ref().unwrap()[0]
18541 .text
18542 .as_deref()
18543 .unwrap();
18544 assert_eq!(text, "- not a sub-item");
18545 }
18546
18547 #[test]
18548 fn backslash_escape_in_inline_text() {
18549 let nodes = parse_inline(r"2\. text");
18551 assert_eq!(nodes.len(), 1, "Should be one text node");
18552 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18553 }
18554
18555 #[test]
18556 fn escape_list_marker_ordered() {
18557 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18558 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18559 }
18560
18561 #[test]
18562 fn escape_list_marker_bullet() {
18563 assert_eq!(escape_list_marker("- text"), r"\- text");
18564 assert_eq!(escape_list_marker("* text"), r"\* text");
18565 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18566 }
18567
18568 #[test]
18569 fn escape_list_marker_plain() {
18570 assert_eq!(escape_list_marker("plain text"), "plain text");
18571 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18572 }
18573
18574 #[test]
18575 fn escape_emoji_shortcodes_basic() {
18576 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18577 assert_eq!(
18578 escape_emoji_shortcodes("hello :wave: world"),
18579 r"hello \:wave: world"
18580 );
18581 }
18582
18583 #[test]
18584 fn escape_emoji_shortcodes_double_colon() {
18585 assert_eq!(
18587 escape_emoji_shortcodes("Status::Active::Running"),
18588 r"Status:\:Active::Running"
18589 );
18590 }
18591
18592 #[test]
18593 fn escape_emoji_shortcodes_no_match() {
18594 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18596 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18597 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18598 assert_eq!(escape_emoji_shortcodes(":"), ":");
18599 }
18600
18601 #[test]
18602 fn escape_emoji_shortcodes_mixed() {
18603 assert_eq!(
18604 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18605 r"Alert \:fire: on pod:pod42"
18606 );
18607 }
18608
18609 #[test]
18610 fn escape_emoji_shortcodes_unicode() {
18611 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18616 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18617 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18618 assert_eq!(
18619 escape_emoji_shortcodes("ZBC::配置::Production"),
18620 r"ZBC:\:配置::Production"
18621 );
18622 }
18623
18624 #[test]
18625 fn escape_emoji_shortcodes_mixed_script_name() {
18626 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18629 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18630 }
18631
18632 #[test]
18633 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18634 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18639 }
18640
18641 #[test]
18642 fn escape_emoji_shortcodes_name_runs_to_end() {
18643 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18649 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18650 }
18651
18652 #[test]
18653 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18654 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18657 {"type":"text","text":"Visit :Café: today"}
18658 ]}]}"#;
18659 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18660
18661 let md = adf_to_markdown(&doc).unwrap();
18662 let round_tripped = markdown_to_adf(&md).unwrap();
18663 let content = round_tripped.content[0].content.as_ref().unwrap();
18664
18665 assert_eq!(
18666 content.len(),
18667 1,
18668 "should be a single text node, got: {content:?}"
18669 );
18670 assert_eq!(content[0].node_type, "text");
18671 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18672 }
18673
18674 #[test]
18675 fn unicode_double_colon_pattern_text_round_trips() {
18676 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18678 {"type":"text","text":"Use ZBC::配置::Production for prod"}
18679 ]}]}"#;
18680 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18681
18682 let md = adf_to_markdown(&doc).unwrap();
18683 let round_tripped = markdown_to_adf(&md).unwrap();
18684 let content = round_tripped.content[0].content.as_ref().unwrap();
18685
18686 assert_eq!(
18687 content.len(),
18688 1,
18689 "should be a single text node, got: {content:?}"
18690 );
18691 assert_eq!(
18692 content[0].text.as_deref().unwrap(),
18693 "Use ZBC::配置::Production for prod"
18694 );
18695 }
18696
18697 #[test]
18698 fn merge_adjacent_text_nodes() {
18699 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18700 merge_adjacent_text(&mut nodes);
18701 assert_eq!(nodes.len(), 1);
18702 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18703 }
18704
18705 #[test]
18708 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18709 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18712 {"type":"text","text":"Introduction: "},
18713 {"type":"hardBreak"},
18714 {"type":"text","text":"1. This text follows a hardBreak"}
18715 ]}]}"#;
18716 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18717 let md = adf_to_markdown(&doc).unwrap();
18718 let rt = markdown_to_adf(&md).unwrap();
18719
18720 assert_eq!(rt.content.len(), 1, "Should remain one block");
18721 assert_eq!(rt.content[0].node_type, "paragraph");
18722 let inlines = rt.content[0].content.as_ref().unwrap();
18723 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18724 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18725 assert_eq!(
18726 inlines[2].text.as_deref(),
18727 Some("1. This text follows a hardBreak")
18728 );
18729 }
18730
18731 #[test]
18732 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18733 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18735 {"type":"text","text":"Intro"},
18736 {"type":"hardBreak"},
18737 {"type":"text","text":"- not a list item"}
18738 ]}]}"#;
18739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18740 let md = adf_to_markdown(&doc).unwrap();
18741 let rt = markdown_to_adf(&md).unwrap();
18742
18743 assert_eq!(rt.content.len(), 1);
18744 assert_eq!(rt.content[0].node_type, "paragraph");
18745 let inlines = rt.content[0].content.as_ref().unwrap();
18746 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18747 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18748 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18749 }
18750
18751 #[test]
18752 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18753 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18755 {"type":"text","text":"Intro"},
18756 {"type":"hardBreak"},
18757 {"type":"text","text":"# not a heading"}
18758 ]}]}"##;
18759 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18760 let md = adf_to_markdown(&doc).unwrap();
18761 let rt = markdown_to_adf(&md).unwrap();
18762
18763 assert_eq!(rt.content.len(), 1);
18764 assert_eq!(rt.content[0].node_type, "paragraph");
18765 let inlines = rt.content[0].content.as_ref().unwrap();
18766 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18767 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18768 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18769 }
18770
18771 #[test]
18772 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18773 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18775 {"type":"text","text":"Intro"},
18776 {"type":"hardBreak"},
18777 {"type":"text","text":"> not a blockquote"}
18778 ]}]}"#;
18779 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18780 let md = adf_to_markdown(&doc).unwrap();
18781 let rt = markdown_to_adf(&md).unwrap();
18782
18783 assert_eq!(rt.content.len(), 1);
18784 assert_eq!(rt.content[0].node_type, "paragraph");
18785 let inlines = rt.content[0].content.as_ref().unwrap();
18786 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18787 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18788 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18789 }
18790
18791 #[test]
18792 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18793 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18795 {"type":"text","text":"Preamble"},
18796 {"type":"hardBreak"},
18797 {"type":"text","text":"1. First"},
18798 {"type":"hardBreak"},
18799 {"type":"text","text":"2. Second"},
18800 {"type":"hardBreak"},
18801 {"type":"text","text":"3. Third"}
18802 ]}]}"#;
18803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18804 let md = adf_to_markdown(&doc).unwrap();
18805 let rt = markdown_to_adf(&md).unwrap();
18806
18807 assert_eq!(rt.content.len(), 1);
18808 assert_eq!(rt.content[0].node_type, "paragraph");
18809 let inlines = rt.content[0].content.as_ref().unwrap();
18810 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18811 assert_eq!(
18812 types,
18813 vec![
18814 "text",
18815 "hardBreak",
18816 "text",
18817 "hardBreak",
18818 "text",
18819 "hardBreak",
18820 "text"
18821 ]
18822 );
18823 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18824 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18825 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18826 }
18827
18828 #[test]
18829 fn issue_455_paragraph_hardbreak_jfm_indentation() {
18830 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18832 {"type":"text","text":"Intro"},
18833 {"type":"hardBreak"},
18834 {"type":"text","text":"1. continued"}
18835 ]}]}"#;
18836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18837 let md = adf_to_markdown(&doc).unwrap();
18838 assert!(
18839 md.contains("Intro\\\n 1. continued"),
18840 "Continuation should be 2-space-indented, got: {md:?}"
18841 );
18842 }
18843
18844 #[test]
18845 fn issue_455_paragraph_hardbreak_from_jfm() {
18846 let md = "Intro\\\n 1. This is continuation text\n";
18849 let doc = markdown_to_adf(md).unwrap();
18850
18851 assert_eq!(doc.content.len(), 1);
18852 assert_eq!(doc.content[0].node_type, "paragraph");
18853 let inlines = doc.content[0].content.as_ref().unwrap();
18854 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18855 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18856 assert_eq!(
18857 inlines[2].text.as_deref(),
18858 Some("1. This is continuation text")
18859 );
18860 }
18861
18862 #[test]
18863 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18864 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18868 {"type":"text","text":"1. Starting with a number"},
18869 {"type":"hardBreak"},
18870 {"type":"text","text":"continuation after break"}
18871 ]}]}"#;
18872 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18873 let md = adf_to_markdown(&doc).unwrap();
18874 assert!(
18876 md.contains(r"1\. Starting with a number"),
18877 "First line should have escaped list marker, got: {md:?}"
18878 );
18879 let rt = markdown_to_adf(&md).unwrap();
18880
18881 assert_eq!(rt.content.len(), 1);
18882 assert_eq!(rt.content[0].node_type, "paragraph");
18883 let inlines = rt.content[0].content.as_ref().unwrap();
18884 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18885 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18886 assert_eq!(
18887 inlines[0].text.as_deref(),
18888 Some("1. Starting with a number")
18889 );
18890 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18891 }
18892
18893 #[test]
18894 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
18895 let adf_json = r#"{"version":1,"type":"doc","content":[{
18898 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
18899 "content":[{"type":"tableRow","content":[{
18900 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
18901 "content":[{"type":"paragraph","content":[
18902 {"type":"text","text":"2. Honouring existing commitments"}
18903 ]}]
18904 }]}]
18905 }]}"#;
18906 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18907 let md = adf_to_markdown(&doc).unwrap();
18908 let rt = markdown_to_adf(&md).unwrap();
18909
18910 let table = &rt.content[0];
18911 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
18912 let para = &cell.content.as_ref().unwrap()[0];
18913 assert_eq!(para.node_type, "paragraph");
18914 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18915 assert_eq!(text, "2. Honouring existing commitments");
18916 }
18917
18918 #[test]
18919 fn bullet_marker_paragraph_standalone_roundtrips() {
18920 let adf_json = r#"{"version":1,"type":"doc","content":[
18923 {"type":"paragraph","content":[
18924 {"type":"text","text":"- not a list item"}
18925 ]}
18926 ]}"#;
18927 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18928 let md = adf_to_markdown(&doc).unwrap();
18929 assert!(
18930 md.contains(r"\- not a list item"),
18931 "Should escape the leading dash, got:\n{md}"
18932 );
18933 let rt = markdown_to_adf(&md).unwrap();
18934 assert_eq!(rt.content[0].node_type, "paragraph");
18935 let text = rt.content[0].content.as_ref().unwrap()[0]
18936 .text
18937 .as_deref()
18938 .unwrap();
18939 assert_eq!(text, "- not a list item");
18940 }
18941
18942 #[test]
18943 fn merge_adjacent_text_skips_non_text_nodes() {
18944 let mut nodes = vec![
18947 AdfNode::text("a"),
18948 AdfNode::hard_break(),
18949 AdfNode::text("b"),
18950 ];
18951 merge_adjacent_text(&mut nodes);
18952 assert_eq!(nodes.len(), 3);
18953 }
18954
18955 #[test]
18956 fn star_bullet_paragraph_roundtrips() {
18957 let adf_json = r#"{"version":1,"type":"doc","content":[
18960 {"type":"paragraph","content":[
18961 {"type":"text","text":"* starred"}
18962 ]}
18963 ]}"#;
18964 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18965 let md = adf_to_markdown(&doc).unwrap();
18966 let rt = markdown_to_adf(&md).unwrap();
18967 assert_eq!(rt.content[0].node_type, "paragraph");
18968 assert_eq!(
18969 rt.content[0].content.as_ref().unwrap()[0]
18970 .text
18971 .as_deref()
18972 .unwrap(),
18973 "* starred"
18974 );
18975 }
18976
18977 #[test]
18980 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
18981 let adf_json = r#"{"version":1,"type":"doc","content":[
18984 {"type":"orderedList","attrs":{"order":1},"content":[
18985 {"type":"listItem","content":[
18986 {"type":"paragraph","content":[
18987 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
18988 {"type":"hardBreak"},
18989 {"type":"text","text":"Content after break"}
18990 ]}
18991 ]},
18992 {"type":"listItem","content":[
18993 {"type":"paragraph","content":[
18994 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
18995 {"type":"hardBreak"},
18996 {"type":"text","text":"More content"}
18997 ]}
18998 ]}
18999 ]}
19000 ]}"#;
19001 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19002 let md = adf_to_markdown(&doc).unwrap();
19003 let rt = markdown_to_adf(&md).unwrap();
19004
19005 assert_eq!(
19007 rt.content.len(),
19008 1,
19009 "Should be 1 block (orderedList), got {}",
19010 rt.content.len()
19011 );
19012 assert_eq!(rt.content[0].node_type, "orderedList");
19013 let items = rt.content[0].content.as_ref().unwrap();
19014 assert_eq!(
19015 items.len(),
19016 2,
19017 "Should have 2 listItems, got {}",
19018 items.len()
19019 );
19020
19021 let p1 = items[0].content.as_ref().unwrap()[0]
19023 .content
19024 .as_ref()
19025 .unwrap();
19026 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19027 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19028 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19029 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19030
19031 let p2 = items[1].content.as_ref().unwrap()[0]
19033 .content
19034 .as_ref()
19035 .unwrap();
19036 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19037 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19038 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19039 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19040 }
19041
19042 #[test]
19043 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19044 let adf_json = r#"{"version":1,"type":"doc","content":[
19046 {"type":"bulletList","content":[
19047 {"type":"listItem","content":[
19048 {"type":"paragraph","content":[
19049 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19050 {"type":"hardBreak"},
19051 {"type":"text","text":"details"}
19052 ]}
19053 ]},
19054 {"type":"listItem","content":[
19055 {"type":"paragraph","content":[
19056 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19057 {"type":"hardBreak"},
19058 {"type":"text","text":"more details"}
19059 ]}
19060 ]}
19061 ]}
19062 ]}"#;
19063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19064 let md = adf_to_markdown(&doc).unwrap();
19065 let rt = markdown_to_adf(&md).unwrap();
19066
19067 assert_eq!(rt.content.len(), 1);
19068 assert_eq!(rt.content[0].node_type, "bulletList");
19069 let items = rt.content[0].content.as_ref().unwrap();
19070 assert_eq!(items.len(), 2);
19071
19072 let p1 = items[0].content.as_ref().unwrap()[0]
19073 .content
19074 .as_ref()
19075 .unwrap();
19076 assert_eq!(p1[0].text.as_deref(), Some("First"));
19077 assert_eq!(p1[2].text.as_deref(), Some("details"));
19078
19079 let p2 = items[1].content.as_ref().unwrap()[0]
19080 .content
19081 .as_ref()
19082 .unwrap();
19083 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19084 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19085 }
19086
19087 #[test]
19088 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19089 let adf_json = r#"{"version":1,"type":"doc","content":[
19091 {"type":"orderedList","attrs":{"order":1},"content":[
19092 {"type":"listItem","content":[
19093 {"type":"paragraph","content":[
19094 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19095 {"type":"hardBreak"},
19096 {"type":"text","text":"body"}
19097 ]}
19098 ]}
19099 ]}
19100 ]}"#;
19101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19102 let md = adf_to_markdown(&doc).unwrap();
19103 assert!(
19104 md.contains("1. **heading**\\\n body"),
19105 "Continuation should be indented, got:\n{md}"
19106 );
19107 }
19108
19109 #[test]
19110 fn issue_388_ordered_list_hardbreak_from_jfm() {
19111 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19113 let doc = markdown_to_adf(md).unwrap();
19114
19115 assert_eq!(doc.content.len(), 1);
19116 assert_eq!(doc.content[0].node_type, "orderedList");
19117 let items = doc.content[0].content.as_ref().unwrap();
19118 assert_eq!(items.len(), 2);
19119
19120 let p1 = items[0].content.as_ref().unwrap()[0]
19121 .content
19122 .as_ref()
19123 .unwrap();
19124 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19125 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19126 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19127 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19128
19129 let p2 = items[1].content.as_ref().unwrap()[0]
19130 .content
19131 .as_ref()
19132 .unwrap();
19133 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19134 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19135 }
19136
19137 #[test]
19138 fn issue_388_bullet_list_hardbreak_from_jfm() {
19139 let md = "- first\\\n second\n- third\\\n fourth\n";
19141 let doc = markdown_to_adf(md).unwrap();
19142
19143 assert_eq!(doc.content.len(), 1);
19144 assert_eq!(doc.content[0].node_type, "bulletList");
19145 let items = doc.content[0].content.as_ref().unwrap();
19146 assert_eq!(items.len(), 2);
19147
19148 for (i, expected) in [("first", "second"), ("third", "fourth")]
19149 .iter()
19150 .enumerate()
19151 {
19152 let p = items[i].content.as_ref().unwrap()[0]
19153 .content
19154 .as_ref()
19155 .unwrap();
19156 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19157 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19158 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19159 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19160 }
19161 }
19162
19163 #[test]
19164 fn issue_433_heading_hardbreak_roundtrips() {
19165 let adf_json = r#"{"version":1,"type":"doc","content":[{
19167 "type":"heading",
19168 "attrs":{"level":1},
19169 "content":[
19170 {"type":"text","text":"Line one"},
19171 {"type":"hardBreak"},
19172 {"type":"text","text":"Line two"}
19173 ]
19174 }]}"#;
19175 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19176 let md = adf_to_markdown(&doc).unwrap();
19177 let rt = markdown_to_adf(&md).unwrap();
19178
19179 assert_eq!(
19180 rt.content.len(),
19181 1,
19182 "Should remain a single heading, got {} blocks",
19183 rt.content.len()
19184 );
19185 assert_eq!(rt.content[0].node_type, "heading");
19186 let inlines = rt.content[0].content.as_ref().unwrap();
19187 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19188 assert_eq!(
19189 types,
19190 vec!["text", "hardBreak", "text"],
19191 "hardBreak should be preserved, got: {types:?}"
19192 );
19193 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19194 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19195 }
19196
19197 #[test]
19198 fn issue_433_heading_hardbreak_jfm_indentation() {
19199 let adf_json = r#"{"version":1,"type":"doc","content":[{
19201 "type":"heading",
19202 "attrs":{"level":2},
19203 "content":[
19204 {"type":"text","text":"Title"},
19205 {"type":"hardBreak"},
19206 {"type":"text","text":"Subtitle"}
19207 ]
19208 }]}"#;
19209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19210 let md = adf_to_markdown(&doc).unwrap();
19211 assert!(
19212 md.contains("## Title\\\n Subtitle"),
19213 "Continuation should be indented, got:\n{md}"
19214 );
19215 }
19216
19217 #[test]
19218 fn issue_433_heading_hardbreak_from_jfm() {
19219 let md = "# First\\\n Second\n";
19221 let doc = markdown_to_adf(md).unwrap();
19222
19223 assert_eq!(doc.content.len(), 1);
19224 assert_eq!(doc.content[0].node_type, "heading");
19225 let inlines = doc.content[0].content.as_ref().unwrap();
19226 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19227 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19228 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19229 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19230 }
19231
19232 #[test]
19233 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19234 let adf_json = r#"{"version":1,"type":"doc","content":[{
19236 "type":"heading",
19237 "attrs":{"level":3},
19238 "content":[
19239 {"type":"text","text":"A"},
19240 {"type":"hardBreak"},
19241 {"type":"hardBreak"},
19242 {"type":"text","text":"B"}
19243 ]
19244 }]}"#;
19245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19246 let md = adf_to_markdown(&doc).unwrap();
19247 let rt = markdown_to_adf(&md).unwrap();
19248
19249 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19250 assert_eq!(rt.content[0].node_type, "heading");
19251 let inlines = rt.content[0].content.as_ref().unwrap();
19252 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19253 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19254 }
19255
19256 #[test]
19257 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19258 let adf_json = r#"{"version":1,"type":"doc","content":[{
19260 "type":"heading",
19261 "attrs":{"level":1},
19262 "content":[
19263 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19264 {"type":"hardBreak"},
19265 {"type":"text","text":"plain continuation"}
19266 ]
19267 }]}"#;
19268 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19269 let md = adf_to_markdown(&doc).unwrap();
19270 let rt = markdown_to_adf(&md).unwrap();
19271
19272 assert_eq!(rt.content.len(), 1);
19273 assert_eq!(rt.content[0].node_type, "heading");
19274 let inlines = rt.content[0].content.as_ref().unwrap();
19275 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19276 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19277 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19278 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19279 }
19280
19281 #[test]
19282 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19283 let adf_json = r#"{"version":1,"type":"doc","content":[{
19285 "type":"heading",
19286 "attrs":{"level":1},
19287 "content":[
19288 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19289 {"type":"hardBreak"},
19290 {"type":"text","text":"Subtitle text"}
19291 ]
19292 }]}"#;
19293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19294 let md = adf_to_markdown(&doc).unwrap();
19295 let rt = markdown_to_adf(&md).unwrap();
19296
19297 assert_eq!(rt.content.len(), 1);
19298 assert_eq!(rt.content[0].node_type, "heading");
19299 let inlines = rt.content[0].content.as_ref().unwrap();
19300 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19301 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19302 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19303 }
19304
19305 #[test]
19306 fn has_trailing_hard_break_backslash() {
19307 assert!(has_trailing_hard_break("text\\"));
19308 assert!(has_trailing_hard_break("**bold**\\"));
19309 }
19310
19311 #[test]
19312 fn has_trailing_hard_break_trailing_spaces() {
19313 assert!(has_trailing_hard_break("text "));
19314 assert!(has_trailing_hard_break("word "));
19315 }
19316
19317 #[test]
19318 fn has_trailing_hard_break_false() {
19319 assert!(!has_trailing_hard_break("plain text"));
19320 assert!(!has_trailing_hard_break("text "));
19321 assert!(!has_trailing_hard_break(""));
19322 }
19323
19324 #[test]
19325 fn collect_hardbreak_continuations_collects_indented() {
19326 let input = "first\\\n second\n third\n";
19329 let mut parser = MarkdownParser::new(input);
19330 parser.advance(); let mut text = "first\\".to_string();
19332 parser.collect_hardbreak_continuations(&mut text);
19333 assert_eq!(text, "first\\\nsecond");
19334 }
19335
19336 #[test]
19337 fn collect_hardbreak_continuations_stops_at_non_indented() {
19338 let input = "first\\\nnot indented\n";
19339 let mut parser = MarkdownParser::new(input);
19340 parser.advance();
19341 let mut text = "first\\".to_string();
19342 parser.collect_hardbreak_continuations(&mut text);
19343 assert_eq!(text, "first\\");
19345 }
19346
19347 #[test]
19348 fn collect_hardbreak_continuations_no_trailing_break() {
19349 let input = "plain\n indented\n";
19351 let mut parser = MarkdownParser::new(input);
19352 parser.advance();
19353 let mut text = "plain".to_string();
19354 parser.collect_hardbreak_continuations(&mut text);
19355 assert_eq!(text, "plain");
19356 }
19357
19358 #[test]
19359 fn collect_hardbreak_continuations_chained() {
19360 let input = "a\\\n b\\\n c\\\n d\n";
19362 let mut parser = MarkdownParser::new(input);
19363 parser.advance();
19364 let mut text = "a\\".to_string();
19365 parser.collect_hardbreak_continuations(&mut text);
19366 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19367 }
19368
19369 #[test]
19370 fn collect_hardbreak_continuations_stops_before_image_line() {
19371 let input = "text\\\n {type=file id=x}\n";
19374 let mut parser = MarkdownParser::new(input);
19375 parser.advance(); let mut text = "text\\".to_string();
19377 parser.collect_hardbreak_continuations(&mut text);
19378 assert_eq!(text, "text\\");
19380 assert!(!parser.at_end());
19382 assert!(parser.current_line().contains(""));
19383 }
19384
19385 #[test]
19386 fn is_block_level_continuation_marker_positive_cases() {
19387 assert!(is_block_level_continuation_marker(""));
19389 assert!(is_block_level_continuation_marker("```ruby"));
19390 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19391 }
19392
19393 #[test]
19394 fn is_block_level_continuation_marker_negative_cases() {
19395 assert!(!is_block_level_continuation_marker("plain text"));
19397 assert!(!is_block_level_continuation_marker("- nested item"));
19398 assert!(!is_block_level_continuation_marker("continuation\\"));
19399 assert!(!is_block_level_continuation_marker(""));
19400 assert!(!is_block_level_continuation_marker("::partial"));
19402 assert!(!is_block_level_continuation_marker("`inline`"));
19404 }
19405
19406 #[test]
19407 fn collect_hardbreak_continuations_stops_before_code_fence() {
19408 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19412 let mut parser = MarkdownParser::new(input);
19413 parser.advance();
19414 let mut text = "text\\".to_string();
19415 parser.collect_hardbreak_continuations(&mut text);
19416 assert_eq!(text, "text\\");
19417 assert!(!parser.at_end());
19418 assert!(parser.current_line().starts_with(" ```"));
19419 }
19420
19421 #[test]
19422 fn collect_hardbreak_continuations_stops_before_container_directive() {
19423 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19427 let mut parser = MarkdownParser::new(input);
19428 parser.advance();
19429 let mut text = "text\\".to_string();
19430 parser.collect_hardbreak_continuations(&mut text);
19431 assert_eq!(text, "text\\");
19432 assert!(!parser.at_end());
19433 assert!(parser.current_line().contains(":::panel"));
19434 }
19435
19436 #[test]
19437 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19438 let input = "text\\\n ```text\n :fire:\n ```\n";
19442 let mut parser = MarkdownParser::new(input);
19443 parser.advance();
19444 let mut text = "text\\".to_string();
19445 parser.collect_hardbreak_continuations(&mut text);
19446 assert_eq!(text, "text\\");
19447 assert!(!parser.at_end());
19448 assert!(parser.current_line().contains("```text"));
19449 }
19450
19451 #[test]
19452 fn ordered_list_with_sub_content_after_hardbreak() {
19453 let adf_json = r#"{"version":1,"type":"doc","content":[
19456 {"type":"orderedList","attrs":{"order":1},"content":[
19457 {"type":"listItem","content":[
19458 {"type":"paragraph","content":[
19459 {"type":"text","text":"parent"},
19460 {"type":"hardBreak"},
19461 {"type":"text","text":"continued"}
19462 ]},
19463 {"type":"bulletList","content":[
19464 {"type":"listItem","content":[
19465 {"type":"paragraph","content":[
19466 {"type":"text","text":"child"}
19467 ]}
19468 ]}
19469 ]}
19470 ]}
19471 ]}
19472 ]}"#;
19473 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19474 let md = adf_to_markdown(&doc).unwrap();
19475 let rt = markdown_to_adf(&md).unwrap();
19476
19477 assert_eq!(rt.content.len(), 1);
19478 assert_eq!(rt.content[0].node_type, "orderedList");
19479 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19480 .content
19481 .as_ref()
19482 .unwrap();
19483 let p = item_content[0].content.as_ref().unwrap();
19485 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19486 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19487 assert_eq!(p[0].text.as_deref(), Some("parent"));
19488 assert_eq!(p[2].text.as_deref(), Some("continued"));
19489 assert_eq!(item_content[1].node_type, "bulletList");
19491 }
19492
19493 #[test]
19494 fn render_list_item_content_no_content() {
19495 let item = AdfNode {
19497 node_type: "listItem".to_string(),
19498 attrs: None,
19499 content: None,
19500 text: None,
19501 marks: None,
19502 local_id: None,
19503 parameters: None,
19504 };
19505 let mut output = String::new();
19506 let opts = RenderOptions::default();
19507 render_list_item_content(&item, &mut output, &opts);
19508 assert_eq!(output, "\n");
19509 }
19510
19511 #[test]
19512 fn render_list_item_content_empty_content() {
19513 let item = AdfNode::list_item(vec![]);
19515 let mut output = String::new();
19516 let opts = RenderOptions::default();
19517 render_list_item_content(&item, &mut output, &opts);
19518 assert_eq!(output, "\n");
19519 }
19520
19521 #[test]
19522 fn plus_bullet_paragraph_roundtrips() {
19523 let adf_json = r#"{"version":1,"type":"doc","content":[
19526 {"type":"paragraph","content":[
19527 {"type":"text","text":"+ plus"}
19528 ]}
19529 ]}"#;
19530 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19531 let md = adf_to_markdown(&doc).unwrap();
19532 let rt = markdown_to_adf(&md).unwrap();
19533 assert_eq!(rt.content[0].node_type, "paragraph");
19534 assert_eq!(
19535 rt.content[0].content.as_ref().unwrap()[0]
19536 .text
19537 .as_deref()
19538 .unwrap(),
19539 "+ plus"
19540 );
19541 }
19542
19543 #[test]
19546 fn issue_430_file_media_in_bullet_list_roundtrip() {
19547 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19550 {"type":"listItem","content":[{
19551 "type":"mediaSingle",
19552 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19553 "content":[{
19554 "type":"media",
19555 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19556 }]
19557 }]}
19558 ]}]}"#;
19559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19560 let md = adf_to_markdown(&doc).unwrap();
19561 let rt = markdown_to_adf(&md).unwrap();
19562
19563 let list = &rt.content[0];
19564 assert_eq!(list.node_type, "bulletList");
19565 let item = &list.content.as_ref().unwrap()[0];
19566 assert_eq!(item.node_type, "listItem");
19567 let ms = &item.content.as_ref().unwrap()[0];
19568 assert_eq!(ms.node_type, "mediaSingle");
19569 let ms_attrs = ms.attrs.as_ref().unwrap();
19570 assert_eq!(ms_attrs["layout"], "center");
19571 assert_eq!(ms_attrs["width"], 1009);
19572 assert_eq!(ms_attrs["widthType"], "pixel");
19573 let media = &ms.content.as_ref().unwrap()[0];
19574 assert_eq!(media.node_type, "media");
19575 let m_attrs = media.attrs.as_ref().unwrap();
19576 assert_eq!(m_attrs["type"], "file");
19577 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19578 assert_eq!(m_attrs["collection"], "contentId-123");
19579 assert_eq!(m_attrs["height"], 576);
19580 assert_eq!(m_attrs["width"], 1009);
19581 }
19582
19583 #[test]
19584 fn issue_430_file_media_in_ordered_list_roundtrip() {
19585 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19587 {"type":"listItem","content":[{
19588 "type":"mediaSingle",
19589 "attrs":{"layout":"center"},
19590 "content":[{
19591 "type":"media",
19592 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19593 }]
19594 }]}
19595 ]}]}"#;
19596 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19597 let md = adf_to_markdown(&doc).unwrap();
19598 let rt = markdown_to_adf(&md).unwrap();
19599
19600 let list = &rt.content[0];
19601 assert_eq!(list.node_type, "orderedList");
19602 let item = &list.content.as_ref().unwrap()[0];
19603 assert_eq!(item.node_type, "listItem");
19604 let ms = &item.content.as_ref().unwrap()[0];
19605 assert_eq!(ms.node_type, "mediaSingle");
19606 let media = &ms.content.as_ref().unwrap()[0];
19607 assert_eq!(media.node_type, "media");
19608 let m_attrs = media.attrs.as_ref().unwrap();
19609 assert_eq!(m_attrs["type"], "file");
19610 assert_eq!(m_attrs["id"], "abc-123");
19611 assert_eq!(m_attrs["collection"], "contentId-456");
19612 }
19613
19614 #[test]
19615 fn issue_430_external_media_in_bullet_list_roundtrip() {
19616 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19618 {"type":"listItem","content":[{
19619 "type":"mediaSingle",
19620 "attrs":{"layout":"center"},
19621 "content":[{
19622 "type":"media",
19623 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19624 }]
19625 }]}
19626 ]}]}"#;
19627 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19628 let md = adf_to_markdown(&doc).unwrap();
19629 let rt = markdown_to_adf(&md).unwrap();
19630
19631 let list = &rt.content[0];
19632 assert_eq!(list.node_type, "bulletList");
19633 let item = &list.content.as_ref().unwrap()[0];
19634 let ms = &item.content.as_ref().unwrap()[0];
19635 assert_eq!(ms.node_type, "mediaSingle");
19636 let media = &ms.content.as_ref().unwrap()[0];
19637 assert_eq!(media.node_type, "media");
19638 let m_attrs = media.attrs.as_ref().unwrap();
19639 assert_eq!(m_attrs["type"], "external");
19640 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19641 }
19642
19643 #[test]
19644 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19645 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19648 {"type":"listItem","content":[
19649 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19650 {"type":"mediaSingle","attrs":{"layout":"center"},
19651 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19652 ]}
19653 ]}]}"#;
19654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19655 let md = adf_to_markdown(&doc).unwrap();
19656 let rt = markdown_to_adf(&md).unwrap();
19657
19658 let item = &rt.content[0].content.as_ref().unwrap()[0];
19659 let children = item.content.as_ref().unwrap();
19660 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19661 assert_eq!(children[0].node_type, "paragraph");
19662 assert_eq!(children[1].node_type, "mediaSingle");
19663 let media = &children[1].content.as_ref().unwrap()[0];
19664 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19665 }
19666
19667 #[test]
19668 fn issue_430_multiple_media_in_list_items() {
19669 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19671 {"type":"listItem","content":[{
19672 "type":"mediaSingle","attrs":{"layout":"center"},
19673 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19674 }]},
19675 {"type":"listItem","content":[{
19676 "type":"mediaSingle","attrs":{"layout":"center"},
19677 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19678 }]}
19679 ]}]}"#;
19680 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19681 let md = adf_to_markdown(&doc).unwrap();
19682 let rt = markdown_to_adf(&md).unwrap();
19683
19684 let items = rt.content[0].content.as_ref().unwrap();
19685 assert_eq!(items.len(), 2);
19686 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19687 let ms = &items[i].content.as_ref().unwrap()[0];
19688 assert_eq!(ms.node_type, "mediaSingle");
19689 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19690 assert_eq!(m_attrs["id"], expected_id.0);
19691 assert_eq!(m_attrs["collection"], expected_id.1);
19692 }
19693 }
19694
19695 #[test]
19696 fn issue_430_jfm_to_adf_media_in_bullet_item() {
19697 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19700 let doc = markdown_to_adf(md).unwrap();
19701
19702 let list = &doc.content[0];
19703 assert_eq!(list.node_type, "bulletList");
19704 let item = &list.content.as_ref().unwrap()[0];
19705 let ms = &item.content.as_ref().unwrap()[0];
19706 assert_eq!(
19707 ms.node_type, "mediaSingle",
19708 "expected mediaSingle, got {}",
19709 ms.node_type
19710 );
19711 let media = &ms.content.as_ref().unwrap()[0];
19712 assert_eq!(media.node_type, "media");
19713 let m_attrs = media.attrs.as_ref().unwrap();
19714 assert_eq!(m_attrs["type"], "file");
19715 assert_eq!(m_attrs["id"], "test-id");
19716 }
19717
19718 #[test]
19719 fn issue_430_jfm_to_adf_media_in_ordered_item() {
19720 let md = "1. \n";
19722 let doc = markdown_to_adf(md).unwrap();
19723
19724 let list = &doc.content[0];
19725 assert_eq!(list.node_type, "orderedList");
19726 let item = &list.content.as_ref().unwrap()[0];
19727 let ms = &item.content.as_ref().unwrap()[0];
19728 assert_eq!(
19729 ms.node_type, "mediaSingle",
19730 "expected mediaSingle, got {}",
19731 ms.node_type
19732 );
19733 }
19734
19735 #[test]
19736 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19737 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19740 {"type":"listItem","content":[
19741 {"type":"mediaSingle","attrs":{"layout":"center"},
19742 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19743 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19744 ]}
19745 ]}]}"#;
19746 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19747 let md = adf_to_markdown(&doc).unwrap();
19748 let rt = markdown_to_adf(&md).unwrap();
19749
19750 let item = &rt.content[0].content.as_ref().unwrap()[0];
19751 let children = item.content.as_ref().unwrap();
19752 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19753 assert_eq!(children[0].node_type, "mediaSingle");
19754 let media = &children[0].content.as_ref().unwrap()[0];
19755 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19756 assert_eq!(children[1].node_type, "paragraph");
19757 }
19758
19759 #[test]
19760 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19761 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19763 {"type":"listItem","content":[
19764 {"type":"mediaSingle","attrs":{"layout":"center"},
19765 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19766 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19767 ]}
19768 ]}]}"#;
19769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19770 let md = adf_to_markdown(&doc).unwrap();
19771 let rt = markdown_to_adf(&md).unwrap();
19772
19773 let item = &rt.content[0].content.as_ref().unwrap()[0];
19774 let children = item.content.as_ref().unwrap();
19775 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19776 assert_eq!(children[0].node_type, "mediaSingle");
19777 assert_eq!(children[1].node_type, "paragraph");
19778 }
19779
19780 #[test]
19781 fn issue_430_external_media_with_width_type_roundtrip() {
19782 let adf_json = r#"{"version":1,"type":"doc","content":[{
19784 "type":"mediaSingle",
19785 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19786 "content":[{
19787 "type":"media",
19788 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19789 }]
19790 }]}"#;
19791 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19792 let md = adf_to_markdown(&doc).unwrap();
19793 assert!(
19794 md.contains("widthType=pixel"),
19795 "expected widthType=pixel in markdown, got: {md}"
19796 );
19797 let rt = markdown_to_adf(&md).unwrap();
19798 let ms = &rt.content[0];
19799 assert_eq!(ms.node_type, "mediaSingle");
19800 let ms_attrs = ms.attrs.as_ref().unwrap();
19801 assert_eq!(ms_attrs["widthType"], "pixel");
19802 assert_eq!(ms_attrs["width"], 800);
19803 assert_eq!(ms_attrs["layout"], "wide");
19804 }
19805
19806 #[test]
19809 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19810 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19813 {"type":"listItem","content":[
19814 {"type":"paragraph","content":[
19815 {"type":"text","text":"Item with image:"},
19816 {"type":"hardBreak"}
19817 ]},
19818 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19819 "content":[{"type":"media","attrs":{
19820 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19821 "type":"file",
19822 "collection":"contentId-123456",
19823 "width":800,
19824 "height":600
19825 }}]}
19826 ]}
19827 ]}]}"#;
19828 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19829 let md = adf_to_markdown(&doc).unwrap();
19830 let rt = markdown_to_adf(&md).unwrap();
19831
19832 let item = &rt.content[0].content.as_ref().unwrap()[0];
19833 let children = item.content.as_ref().unwrap();
19834 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19835 assert_eq!(children[0].node_type, "paragraph");
19836 assert_eq!(
19837 children[1].node_type, "mediaSingle",
19838 "expected mediaSingle, got {:?}",
19839 children[1].node_type
19840 );
19841 let media = &children[1].content.as_ref().unwrap()[0];
19842 let m_attrs = media.attrs.as_ref().unwrap();
19843 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19844 assert_eq!(m_attrs["collection"], "contentId-123456");
19845 assert_eq!(m_attrs["height"], 600);
19846 assert_eq!(m_attrs["width"], 800);
19847 }
19848
19849 #[test]
19850 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19851 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19853 {"type":"listItem","content":[
19854 {"type":"paragraph","content":[
19855 {"type":"text","text":"Step with screenshot:"},
19856 {"type":"hardBreak"}
19857 ]},
19858 {"type":"mediaSingle","attrs":{"layout":"center"},
19859 "content":[{"type":"media","attrs":{
19860 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19861 }}]}
19862 ]}
19863 ]}]}"#;
19864 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19865 let md = adf_to_markdown(&doc).unwrap();
19866 let rt = markdown_to_adf(&md).unwrap();
19867
19868 let item = &rt.content[0].content.as_ref().unwrap()[0];
19869 let children = item.content.as_ref().unwrap();
19870 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19871 assert_eq!(children[0].node_type, "paragraph");
19872 assert_eq!(children[1].node_type, "mediaSingle");
19873 let media = &children[1].content.as_ref().unwrap()[0];
19874 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19875 }
19876
19877 #[test]
19878 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19879 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19882 let doc = markdown_to_adf(md).unwrap();
19883
19884 let item = &doc.content[0].content.as_ref().unwrap()[0];
19885 let children = item.content.as_ref().unwrap();
19886 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19887 assert_eq!(children[0].node_type, "paragraph");
19888 assert_eq!(
19889 children[1].node_type, "mediaSingle",
19890 "expected mediaSingle as second child, got {:?}",
19891 children[1].node_type
19892 );
19893 let media = &children[1].content.as_ref().unwrap()[0];
19894 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
19895 }
19896
19897 #[test]
19898 fn issue_490_hardbreak_continuation_still_works_for_text() {
19899 let md = "- first line\\\n second line\n";
19901 let doc = markdown_to_adf(md).unwrap();
19902
19903 let item = &doc.content[0].content.as_ref().unwrap()[0];
19904 let children = item.content.as_ref().unwrap();
19905 assert_eq!(
19906 children.len(),
19907 1,
19908 "expected 1 child (paragraph) in listItem"
19909 );
19910 assert_eq!(children[0].node_type, "paragraph");
19911 let inlines = children[0].content.as_ref().unwrap();
19912 assert_eq!(inlines.len(), 3);
19914 assert_eq!(inlines[0].node_type, "text");
19915 assert_eq!(inlines[1].node_type, "hardBreak");
19916 assert_eq!(inlines[2].node_type, "text");
19917 }
19918
19919 #[test]
19920 fn issue_490_external_media_after_hardbreak_roundtrip() {
19921 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19923 {"type":"listItem","content":[
19924 {"type":"paragraph","content":[
19925 {"type":"text","text":"See image:"},
19926 {"type":"hardBreak"}
19927 ]},
19928 {"type":"mediaSingle","attrs":{"layout":"center"},
19929 "content":[{"type":"media","attrs":{
19930 "type":"external","url":"https://example.com/photo.png","alt":"photo"
19931 }}]}
19932 ]}
19933 ]}]}"#;
19934 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19935 let md = adf_to_markdown(&doc).unwrap();
19936 let rt = markdown_to_adf(&md).unwrap();
19937
19938 let item = &rt.content[0].content.as_ref().unwrap()[0];
19939 let children = item.content.as_ref().unwrap();
19940 assert_eq!(children.len(), 2);
19941 assert_eq!(children[0].node_type, "paragraph");
19942 assert_eq!(children[1].node_type, "mediaSingle");
19943 let media = &children[1].content.as_ref().unwrap()[0];
19944 let m_attrs = media.attrs.as_ref().unwrap();
19945 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
19946 }
19947
19948 #[test]
19949 fn issue_490_multiple_hardbreaks_then_media_single() {
19950 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19952 {"type":"listItem","content":[
19953 {"type":"paragraph","content":[
19954 {"type":"text","text":"line one"},
19955 {"type":"hardBreak"},
19956 {"type":"text","text":"line two"},
19957 {"type":"hardBreak"}
19958 ]},
19959 {"type":"mediaSingle","attrs":{"layout":"center"},
19960 "content":[{"type":"media","attrs":{
19961 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
19962 }}]}
19963 ]}
19964 ]}]}"#;
19965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19966 let md = adf_to_markdown(&doc).unwrap();
19967 let rt = markdown_to_adf(&md).unwrap();
19968
19969 let item = &rt.content[0].content.as_ref().unwrap()[0];
19970 let children = item.content.as_ref().unwrap();
19971 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
19972 assert_eq!(children[0].node_type, "paragraph");
19973 assert_eq!(children[1].node_type, "mediaSingle");
19974 let media = &children[1].content.as_ref().unwrap()[0];
19975 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
19976 }
19977
19978 #[test]
19981 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
19982 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"}]}]}]}]}]}]}"#;
19985 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19986 let md = adf_to_markdown(&doc).unwrap();
19987 let rt = markdown_to_adf(&md).unwrap();
19988
19989 let list = &rt.content[0];
19990 assert_eq!(list.node_type, "bulletList");
19991 let item = &list.content.as_ref().unwrap()[0];
19992 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
19994 assert_eq!(
19995 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
19996 "listItem localId must survive round-trip"
19997 );
19998 let children = item.content.as_ref().unwrap();
19999 assert_eq!(
20000 children.len(),
20001 3,
20002 "expected mediaSingle + paragraph + bulletList"
20003 );
20004 assert_eq!(children[0].node_type, "mediaSingle");
20005 assert_eq!(children[1].node_type, "paragraph");
20006 assert_eq!(children[2].node_type, "bulletList");
20007 }
20008
20009 #[test]
20010 fn issue_525_listitem_localid_with_mediasingle_only() {
20011 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20013 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20014 {"type":"mediaSingle","attrs":{"layout":"center"},
20015 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20016 ]}
20017 ]}]}"#;
20018 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20019 let md = adf_to_markdown(&doc).unwrap();
20020 let rt = markdown_to_adf(&md).unwrap();
20021
20022 let item = &rt.content[0].content.as_ref().unwrap()[0];
20023 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20024 assert_eq!(
20025 item_attrs["localId"], "li-media-only",
20026 "listItem localId must survive when sole child is mediaSingle"
20027 );
20028 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20029 }
20030
20031 #[test]
20032 fn issue_525_listitem_localid_with_external_media() {
20033 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20035 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20036 {"type":"mediaSingle","attrs":{"layout":"center"},
20037 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20038 ]}
20039 ]}]}"#;
20040 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20041 let md = adf_to_markdown(&doc).unwrap();
20042 let rt = markdown_to_adf(&md).unwrap();
20043
20044 let item = &rt.content[0].content.as_ref().unwrap()[0];
20045 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20046 assert_eq!(
20047 item_attrs["localId"], "li-ext-media",
20048 "listItem localId must survive with external mediaSingle"
20049 );
20050 }
20051
20052 #[test]
20053 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20054 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20056 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20057 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20058 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20059 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20060 ]}
20061 ]}]}"#;
20062 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20063 let md = adf_to_markdown(&doc).unwrap();
20064 let rt = markdown_to_adf(&md).unwrap();
20065
20066 let item = &rt.content[0].content.as_ref().unwrap()[0];
20067 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20068 assert_eq!(
20069 item_attrs["localId"], "li-ord-media",
20070 "listItem localId must survive in ordered list with mediaSingle"
20071 );
20072 let children = item.content.as_ref().unwrap();
20073 assert_eq!(children[0].node_type, "mediaSingle");
20074 assert_eq!(children[1].node_type, "paragraph");
20075 }
20076
20077 #[test]
20078 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20079 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20082 let doc = markdown_to_adf(md).unwrap();
20083
20084 let item = &doc.content[0].content.as_ref().unwrap()[0];
20085 let item_attrs = item
20086 .attrs
20087 .as_ref()
20088 .expect("listItem attrs must be present from JFM");
20089 assert_eq!(item_attrs["localId"], "li-jfm-525");
20090 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20091 }
20092
20093 #[test]
20094 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20095 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20098 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20099 {"type":"mediaSingle","attrs":{"layout":"center"},
20100 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20101 ]}
20102 ]}]}"#;
20103 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20104 let md = adf_to_markdown(&doc).unwrap();
20105 assert!(
20106 md.contains("{localId=li-emit-check}"),
20107 "expected localId in JFM output, got: {md}"
20108 );
20109 for line in md.lines() {
20111 if line.contains("![") {
20112 assert!(
20113 line.contains("localId=li-emit-check"),
20114 "localId must be on the same line as the image: {line}"
20115 );
20116 }
20117 }
20118 }
20119
20120 #[test]
20123 fn adf_placeholder_to_markdown() {
20124 let doc = AdfDocument {
20125 version: 1,
20126 doc_type: "doc".to_string(),
20127 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20128 "Type something here",
20129 )])],
20130 };
20131 let md = adf_to_markdown(&doc).unwrap();
20132 assert!(
20133 md.contains(":placeholder[Type something here]"),
20134 "expected :placeholder directive, got: {md}"
20135 );
20136 }
20137
20138 #[test]
20139 fn markdown_placeholder_to_adf() {
20140 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20141 let content = doc.content[0].content.as_ref().unwrap();
20142 assert_eq!(content[1].node_type, "placeholder");
20143 let attrs = content[1].attrs.as_ref().unwrap();
20144 assert_eq!(attrs["text"], "Enter name");
20145 }
20146
20147 #[test]
20148 fn placeholder_round_trip() {
20149 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20151 let md = adf_to_markdown(&doc).unwrap();
20152 let rt = markdown_to_adf(&md).unwrap();
20153 let content = rt.content[0].content.as_ref().unwrap();
20154 assert_eq!(content.len(), 1);
20155 assert_eq!(content[0].node_type, "placeholder");
20156 let attrs = content[0].attrs.as_ref().unwrap();
20157 assert_eq!(attrs["text"], "Type something here");
20158 }
20159
20160 #[test]
20161 fn placeholder_empty_text() {
20162 let doc = AdfDocument {
20163 version: 1,
20164 doc_type: "doc".to_string(),
20165 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20166 };
20167 let md = adf_to_markdown(&doc).unwrap();
20168 assert!(
20169 md.contains(":placeholder[]"),
20170 "expected empty placeholder directive, got: {md}"
20171 );
20172 let rt = markdown_to_adf(&md).unwrap();
20173 let content = rt.content[0].content.as_ref().unwrap();
20174 assert_eq!(content[0].node_type, "placeholder");
20175 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20176 }
20177
20178 #[test]
20179 fn placeholder_with_surrounding_text() {
20180 let md = "Click :placeholder[here] to continue\n";
20181 let doc = markdown_to_adf(md).unwrap();
20182 let content = doc.content[0].content.as_ref().unwrap();
20183 assert_eq!(content[0].text.as_deref(), Some("Click "));
20184 assert_eq!(content[1].node_type, "placeholder");
20185 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20186 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20187 }
20188
20189 #[test]
20190 fn placeholder_missing_attrs() {
20191 let doc = AdfDocument {
20193 version: 1,
20194 doc_type: "doc".to_string(),
20195 content: vec![AdfNode::paragraph(vec![AdfNode {
20196 node_type: "placeholder".to_string(),
20197 attrs: None,
20198 content: None,
20199 text: None,
20200 marks: None,
20201 local_id: None,
20202 parameters: None,
20203 }])],
20204 };
20205 let md = adf_to_markdown(&doc).unwrap();
20206 assert!(!md.contains("placeholder"));
20208 }
20209
20210 #[test]
20212 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20213 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":" "}]}]}]}]}]}]}]}"#;
20214 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20215 let md = adf_to_markdown(&doc).unwrap();
20216 let rt = markdown_to_adf(&md).unwrap();
20217
20218 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20220 .content
20221 .as_ref()
20222 .unwrap()[0];
20223 let list = &cell.content.as_ref().unwrap()[0];
20224 let list_item = &list.content.as_ref().unwrap()[0];
20225
20226 assert!(
20228 list_item
20229 .attrs
20230 .as_ref()
20231 .and_then(|a| a.get("localId"))
20232 .is_none(),
20233 "localId should stay on the mention, not the listItem"
20234 );
20235
20236 let para = &list_item.content.as_ref().unwrap()[0];
20237 let inlines = para.content.as_ref().unwrap();
20238
20239 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20241
20242 assert_eq!(inlines[0].node_type, "text");
20243 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20244
20245 assert_eq!(inlines[1].node_type, "mention");
20246 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20247 assert_eq!(
20248 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20249 "mention id must be preserved"
20250 );
20251 assert_eq!(
20252 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20253 "mention localId must be preserved"
20254 );
20255 assert_eq!(mention_attrs["text"], "@Alice Example");
20256
20257 assert_eq!(inlines[2].node_type, "text");
20258 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20259 }
20260
20261 #[test]
20262 fn mention_in_bullet_list_preserves_id_and_local_id() {
20263 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":" "}]}]}]}]}"#;
20265 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20266 let md = adf_to_markdown(&doc).unwrap();
20267 let rt = markdown_to_adf(&md).unwrap();
20268
20269 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20270 assert!(
20271 list_item
20272 .attrs
20273 .as_ref()
20274 .and_then(|a| a.get("localId"))
20275 .is_none(),
20276 "localId should stay on the mention, not the listItem"
20277 );
20278
20279 let para = &list_item.content.as_ref().unwrap()[0];
20280 let inlines = para.content.as_ref().unwrap();
20281 assert_eq!(inlines[0].node_type, "mention");
20282 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20283 assert_eq!(mention_attrs["id"], "user123");
20284 assert_eq!(
20285 mention_attrs["localId"],
20286 "11111111-2222-3333-4444-555555555555"
20287 );
20288 }
20289
20290 #[test]
20291 fn mention_in_ordered_list_preserves_id_and_local_id() {
20292 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"}}]}]}]}]}"#;
20293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20294 let md = adf_to_markdown(&doc).unwrap();
20295 let rt = markdown_to_adf(&md).unwrap();
20296
20297 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20298 assert!(
20299 list_item
20300 .attrs
20301 .as_ref()
20302 .and_then(|a| a.get("localId"))
20303 .is_none(),
20304 "localId should stay on the mention, not the listItem"
20305 );
20306
20307 let para = &list_item.content.as_ref().unwrap()[0];
20308 let inlines = para.content.as_ref().unwrap();
20309 assert_eq!(inlines[1].node_type, "mention");
20310 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20311 assert_eq!(mention_attrs["id"], "xyz");
20312 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20313 }
20314
20315 #[test]
20316 fn list_item_own_local_id_with_mention_both_preserved() {
20317 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20320 let doc = markdown_to_adf(md).unwrap();
20321 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20322
20323 let item_attrs = list_item.attrs.as_ref().unwrap();
20325 assert_eq!(item_attrs["localId"], "item-lid");
20326
20327 let para = &list_item.content.as_ref().unwrap()[0];
20329 let inlines = para.content.as_ref().unwrap();
20330 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20331 let mention_attrs = mention.attrs.as_ref().unwrap();
20332 assert_eq!(mention_attrs["id"], "e1");
20333 assert_eq!(mention_attrs["localId"], "mention-lid");
20334 }
20335
20336 #[test]
20337 fn extract_trailing_local_id_ignores_directive_attrs() {
20338 let line = "text :mention[@X]{id=abc localId=uuid}";
20341 let (text, lid, plid) = extract_trailing_local_id(line);
20342 assert_eq!(text, line, "text should be unchanged");
20343 assert!(
20344 lid.is_none(),
20345 "should not extract localId from directive attrs"
20346 );
20347 assert!(plid.is_none());
20348 }
20349
20350 #[test]
20351 fn extract_trailing_local_id_matches_standalone_block() {
20352 let line = "some text {localId=abc-123}";
20354 let (text, lid, plid) = extract_trailing_local_id(line);
20355 assert_eq!(text, "some text");
20356 assert_eq!(lid.as_deref(), Some("abc-123"));
20357 assert!(plid.is_none());
20358 }
20359
20360 #[test]
20363 fn newline_in_text_node_roundtrips_in_bullet_list() {
20364 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"}]}]}]}]}"#;
20368 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20369 let md = adf_to_markdown(&doc).unwrap();
20370 let rt = markdown_to_adf(&md).unwrap();
20371
20372 assert_eq!(rt.content.len(), 1);
20374 let list = &rt.content[0];
20375 assert_eq!(list.node_type, "bulletList");
20376 let items = list.content.as_ref().unwrap();
20377 assert_eq!(items.len(), 1);
20378
20379 let item_content = items[0].content.as_ref().unwrap();
20381 assert_eq!(
20382 item_content.len(),
20383 1,
20384 "listItem should have exactly one paragraph"
20385 );
20386 assert_eq!(item_content[0].node_type, "paragraph");
20387
20388 let inlines = item_content[0].content.as_ref().unwrap();
20390 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20391 assert_eq!(
20392 types,
20393 vec!["text", "hardBreak", "text"],
20394 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20395 );
20396 assert_eq!(
20397 inlines[2].text.as_deref(),
20398 Some("first command\nsecond command")
20399 );
20400 }
20401
20402 #[test]
20403 fn newline_in_text_node_roundtrips_in_ordered_list() {
20404 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"}]}]}]}]}"#;
20406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20407 let md = adf_to_markdown(&doc).unwrap();
20408 let rt = markdown_to_adf(&md).unwrap();
20409
20410 let list = &rt.content[0];
20411 assert_eq!(list.node_type, "orderedList");
20412 let items = list.content.as_ref().unwrap();
20413 assert_eq!(items.len(), 1);
20414
20415 let item_content = items[0].content.as_ref().unwrap();
20416 assert_eq!(item_content.len(), 1);
20417 assert_eq!(item_content[0].node_type, "paragraph");
20418
20419 let inlines = item_content[0].content.as_ref().unwrap();
20420 assert_eq!(inlines.len(), 1);
20421 assert_eq!(inlines[0].node_type, "text");
20422 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20423 }
20424
20425 #[test]
20426 fn newline_in_text_node_roundtrips_in_paragraph() {
20427 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20430 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20431 let md = adf_to_markdown(&doc).unwrap();
20432 assert!(
20433 md.contains("hello\\nworld"),
20434 "newline in text node should render as escaped \\n: {md:?}"
20435 );
20436
20437 let rt = markdown_to_adf(&md).unwrap();
20438 let inlines = rt.content[0].content.as_ref().unwrap();
20439 assert_eq!(inlines.len(), 1);
20440 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20441 }
20442
20443 #[test]
20444 fn multiple_newlines_in_text_node_roundtrip() {
20445 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20447 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20448 let md = adf_to_markdown(&doc).unwrap();
20449 let rt = markdown_to_adf(&md).unwrap();
20450
20451 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20452 .content
20453 .as_ref()
20454 .unwrap();
20455 assert_eq!(item_content.len(), 1);
20456
20457 let inlines = item_content[0].content.as_ref().unwrap();
20458 assert_eq!(inlines.len(), 1);
20459 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20460 }
20461
20462 #[test]
20463 fn newline_in_marked_text_node_roundtrips() {
20464 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20467 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20468 let md = adf_to_markdown(&doc).unwrap();
20469 assert!(
20470 md.contains("**bold\\ntext**"),
20471 "bold text with embedded newline should stay in one marked run: {md:?}"
20472 );
20473
20474 let rt = markdown_to_adf(&md).unwrap();
20475 let inlines = rt.content[0].content.as_ref().unwrap();
20476 assert_eq!(inlines.len(), 1);
20477 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20478 assert!(inlines[0]
20479 .marks
20480 .as_ref()
20481 .unwrap()
20482 .iter()
20483 .any(|m| m.mark_type == "strong"));
20484 }
20485
20486 #[test]
20487 fn trailing_newline_in_text_node_roundtrips() {
20488 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20492 let md = adf_to_markdown(&doc).unwrap();
20493 assert!(
20494 md.contains("trailing\\n"),
20495 "trailing newline should be escaped: {md:?}"
20496 );
20497
20498 let rt = markdown_to_adf(&md).unwrap();
20499 let inlines = rt.content[0].content.as_ref().unwrap();
20500 assert_eq!(inlines.len(), 1);
20501 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20502 }
20503
20504 #[test]
20505 fn hardbreak_and_embedded_newline_are_distinct() {
20506 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"}]}]}"#;
20509 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20510 let md = adf_to_markdown(&doc).unwrap();
20511 let rt = markdown_to_adf(&md).unwrap();
20512
20513 let inlines = rt.content[0].content.as_ref().unwrap();
20514 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20515 assert_eq!(
20516 types,
20517 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20518 );
20519 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20520 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20521 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20522 }
20523
20524 #[test]
20527 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20528 let adf_json = r#"{"version":1,"type":"doc","content":[
20531 {"type":"bulletList","content":[
20532 {"type":"listItem","content":[
20533 {"type":"paragraph","content":[
20534 {"type":"text","text":"First item"},
20535 {"type":"hardBreak"}
20536 ]}
20537 ]},
20538 {"type":"listItem","content":[
20539 {"type":"paragraph","content":[
20540 {"type":"text","text":"Second item"}
20541 ]}
20542 ]}
20543 ]}
20544 ]}"#;
20545 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20546 let md = adf_to_markdown(&doc).unwrap();
20547 let rt = markdown_to_adf(&md).unwrap();
20548
20549 assert_eq!(
20551 rt.content.len(),
20552 1,
20553 "Should be 1 block (bulletList), got {}",
20554 rt.content.len()
20555 );
20556 assert_eq!(rt.content[0].node_type, "bulletList");
20557 let items = rt.content[0].content.as_ref().unwrap();
20558 assert_eq!(
20559 items.len(),
20560 2,
20561 "Should have 2 listItems, got {}",
20562 items.len()
20563 );
20564
20565 let p1 = items[0].content.as_ref().unwrap()[0]
20567 .content
20568 .as_ref()
20569 .unwrap();
20570 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20571 assert_eq!(types1, vec!["text", "hardBreak"]);
20572 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20573
20574 let p2 = items[1].content.as_ref().unwrap()[0]
20576 .content
20577 .as_ref()
20578 .unwrap();
20579 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20580 }
20581
20582 #[test]
20583 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20584 let adf_json = r#"{"version":1,"type":"doc","content":[
20586 {"type":"orderedList","attrs":{"order":1},"content":[
20587 {"type":"listItem","content":[
20588 {"type":"paragraph","content":[
20589 {"type":"text","text":"Alpha"},
20590 {"type":"hardBreak"}
20591 ]}
20592 ]},
20593 {"type":"listItem","content":[
20594 {"type":"paragraph","content":[
20595 {"type":"text","text":"Beta"}
20596 ]}
20597 ]}
20598 ]}
20599 ]}"#;
20600 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20601 let md = adf_to_markdown(&doc).unwrap();
20602 let rt = markdown_to_adf(&md).unwrap();
20603
20604 assert_eq!(rt.content.len(), 1);
20605 assert_eq!(rt.content[0].node_type, "orderedList");
20606 let items = rt.content[0].content.as_ref().unwrap();
20607 assert_eq!(items.len(), 2);
20608
20609 let p1 = items[0].content.as_ref().unwrap()[0]
20610 .content
20611 .as_ref()
20612 .unwrap();
20613 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20614 assert_eq!(types1, vec!["text", "hardBreak"]);
20615 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20616 }
20617
20618 #[test]
20619 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20620 let adf_json = r#"{"version":1,"type":"doc","content":[
20623 {"type":"bulletList","content":[
20624 {"type":"listItem","content":[
20625 {"type":"paragraph","content":[
20626 {"type":"text","text":"Hello"},
20627 {"type":"hardBreak"}
20628 ]}
20629 ]},
20630 {"type":"listItem","content":[
20631 {"type":"paragraph","content":[
20632 {"type":"text","text":"World"}
20633 ]}
20634 ]}
20635 ]}
20636 ]}"#;
20637 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20638 let md = adf_to_markdown(&doc).unwrap();
20639
20640 assert_eq!(md, "- Hello\\\n- World\n");
20642 }
20643
20644 #[test]
20645 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20646 let adf_json = r#"{"version":1,"type":"doc","content":[
20648 {"type":"bulletList","content":[
20649 {"type":"listItem","content":[
20650 {"type":"paragraph","content":[
20651 {"type":"text","text":"Item"},
20652 {"type":"hardBreak"},
20653 {"type":"hardBreak"}
20654 ]}
20655 ]},
20656 {"type":"listItem","content":[
20657 {"type":"paragraph","content":[
20658 {"type":"text","text":"Next"}
20659 ]}
20660 ]}
20661 ]}
20662 ]}"#;
20663 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20664 let md = adf_to_markdown(&doc).unwrap();
20665 let rt = markdown_to_adf(&md).unwrap();
20666
20667 assert_eq!(rt.content.len(), 1);
20669 assert_eq!(rt.content[0].node_type, "bulletList");
20670 let items = rt.content[0].content.as_ref().unwrap();
20671 assert_eq!(items.len(), 2);
20672
20673 let p1 = items[0].content.as_ref().unwrap()[0]
20675 .content
20676 .as_ref()
20677 .unwrap();
20678 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20679 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20680 }
20681
20682 #[test]
20683 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20684 let adf_json = r#"{"version":1,"type":"doc","content":[
20686 {"type":"bulletList","content":[
20687 {"type":"listItem","content":[
20688 {"type":"paragraph","content":[
20689 {"type":"text","text":"Line one"},
20690 {"type":"hardBreak"},
20691 {"type":"text","text":"Line two"},
20692 {"type":"hardBreak"}
20693 ]}
20694 ]},
20695 {"type":"listItem","content":[
20696 {"type":"paragraph","content":[
20697 {"type":"text","text":"Other item"}
20698 ]}
20699 ]}
20700 ]}
20701 ]}"#;
20702 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20703 let md = adf_to_markdown(&doc).unwrap();
20704 let rt = markdown_to_adf(&md).unwrap();
20705
20706 assert_eq!(rt.content.len(), 1);
20707 assert_eq!(rt.content[0].node_type, "bulletList");
20708 let items = rt.content[0].content.as_ref().unwrap();
20709 assert_eq!(items.len(), 2);
20710
20711 let p1 = items[0].content.as_ref().unwrap()[0]
20712 .content
20713 .as_ref()
20714 .unwrap();
20715 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20716 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20717 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20718 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20719 }
20720
20721 #[test]
20722 fn issue_472_only_hardbreak_in_listitem_paragraph() {
20723 let adf_json = r#"{"version":1,"type":"doc","content":[
20725 {"type":"bulletList","content":[
20726 {"type":"listItem","content":[
20727 {"type":"paragraph","content":[
20728 {"type":"hardBreak"}
20729 ]}
20730 ]},
20731 {"type":"listItem","content":[
20732 {"type":"paragraph","content":[
20733 {"type":"text","text":"After"}
20734 ]}
20735 ]}
20736 ]}
20737 ]}"#;
20738 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20739 let md = adf_to_markdown(&doc).unwrap();
20740 let rt = markdown_to_adf(&md).unwrap();
20741
20742 assert_eq!(rt.content.len(), 1);
20744 assert_eq!(rt.content[0].node_type, "bulletList");
20745 let items = rt.content[0].content.as_ref().unwrap();
20746 assert_eq!(items.len(), 2);
20747 }
20748
20749 #[test]
20750 fn issue_472_three_items_middle_has_trailing_hardbreak() {
20751 let adf_json = r#"{"version":1,"type":"doc","content":[
20753 {"type":"bulletList","content":[
20754 {"type":"listItem","content":[
20755 {"type":"paragraph","content":[
20756 {"type":"text","text":"First"}
20757 ]}
20758 ]},
20759 {"type":"listItem","content":[
20760 {"type":"paragraph","content":[
20761 {"type":"text","text":"Second"},
20762 {"type":"hardBreak"}
20763 ]}
20764 ]},
20765 {"type":"listItem","content":[
20766 {"type":"paragraph","content":[
20767 {"type":"text","text":"Third"}
20768 ]}
20769 ]}
20770 ]}
20771 ]}"#;
20772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20773 let md = adf_to_markdown(&doc).unwrap();
20774 let rt = markdown_to_adf(&md).unwrap();
20775
20776 assert_eq!(rt.content.len(), 1);
20777 assert_eq!(rt.content[0].node_type, "bulletList");
20778 let items = rt.content[0].content.as_ref().unwrap();
20779 assert_eq!(items.len(), 3);
20780 assert_eq!(
20781 items[0].content.as_ref().unwrap()[0]
20782 .content
20783 .as_ref()
20784 .unwrap()[0]
20785 .text
20786 .as_deref(),
20787 Some("First")
20788 );
20789 assert_eq!(
20790 items[2].content.as_ref().unwrap()[0]
20791 .content
20792 .as_ref()
20793 .unwrap()[0]
20794 .text
20795 .as_deref(),
20796 Some("Third")
20797 );
20798 }
20799
20800 #[test]
20803 fn issue_494_space_after_hardbreak_roundtrip() {
20804 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20807 {"type":"text","text":"Some text"},
20808 {"type":"hardBreak"},
20809 {"type":"text","text":" "}
20810 ]}]}"#;
20811 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20812 let md = adf_to_markdown(&doc).unwrap();
20813 let rt = markdown_to_adf(&md).unwrap();
20814 let inlines = rt.content[0].content.as_ref().unwrap();
20815 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20816 assert_eq!(
20817 types,
20818 vec!["text", "hardBreak", "text"],
20819 "space-only text node after hardBreak should survive round-trip"
20820 );
20821 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20822 }
20823
20824 #[test]
20825 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20826 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20828 {"type":"text","text":"Hello"},
20829 {"type":"hardBreak"},
20830 {"type":"text","text":" "}
20831 ]}]}"#;
20832 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20833 let md = adf_to_markdown(&doc).unwrap();
20834 let rt = markdown_to_adf(&md).unwrap();
20835 let inlines = rt.content[0].content.as_ref().unwrap();
20836 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20837 assert_eq!(
20838 types,
20839 vec!["text", "hardBreak", "text"],
20840 "multi-space text node after hardBreak should survive round-trip"
20841 );
20842 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20843 }
20844
20845 #[test]
20846 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20847 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20850 {"type":"text","text":"Before"},
20851 {"type":"hardBreak"},
20852 {"type":"text","text":" After"}
20853 ]}]}"#;
20854 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20855 let md = adf_to_markdown(&doc).unwrap();
20856 let rt = markdown_to_adf(&md).unwrap();
20857 let inlines = rt.content[0].content.as_ref().unwrap();
20858 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20859 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20860 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20861 }
20862
20863 #[test]
20864 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20865 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20867 {"type":"text","text":"A"},
20868 {"type":"hardBreak"},
20869 {"type":"text","text":" "},
20870 {"type":"hardBreak"},
20871 {"type":"text","text":"B"}
20872 ]}]}"#;
20873 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20874 let md = adf_to_markdown(&doc).unwrap();
20875 let rt = markdown_to_adf(&md).unwrap();
20876 let inlines = rt.content[0].content.as_ref().unwrap();
20877 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20878 assert_eq!(
20879 types,
20880 vec!["text", "hardBreak", "text", "hardBreak", "text"],
20881 "space between two hardBreaks should survive round-trip"
20882 );
20883 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20884 assert_eq!(inlines[4].text.as_deref(), Some("B"));
20885 }
20886
20887 #[test]
20888 fn issue_494_trailing_space_hardbreak_style_not_confused() {
20889 let md = "first paragraph\n\nsecond paragraph\n";
20892 let doc = markdown_to_adf(md).unwrap();
20893 assert_eq!(
20894 doc.content.len(),
20895 2,
20896 "blank line should still separate paragraphs"
20897 );
20898 }
20899
20900 #[test]
20901 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
20902 let md = "line one \n \n";
20905 let doc = markdown_to_adf(md).unwrap();
20909 let inlines = doc.content[0].content.as_ref().unwrap();
20910 let has_text_after_break = inlines.iter().any(|n| {
20911 n.node_type == "text"
20912 && n.text
20913 .as_deref()
20914 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
20915 });
20916 assert!(
20917 has_text_after_break || inlines.len() >= 2,
20918 "space-only line after trailing-space hardBreak should be preserved"
20919 );
20920 }
20921
20922 #[test]
20923 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
20924 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20926 {"type":"listItem","content":[{"type":"paragraph","content":[
20927 {"type":"text","text":"item"},
20928 {"type":"hardBreak"},
20929 {"type":"text","text":" "}
20930 ]}]}
20931 ]}]}"#;
20932 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20933 let md = adf_to_markdown(&doc).unwrap();
20934 let rt = markdown_to_adf(&md).unwrap();
20935 let list = &rt.content[0];
20936 let item = &list.content.as_ref().unwrap()[0];
20937 let para = &item.content.as_ref().unwrap()[0];
20938 let inlines = para.content.as_ref().unwrap();
20939 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20940 assert_eq!(
20941 types,
20942 vec!["text", "hardBreak", "text"],
20943 "space after hardBreak in list item should survive round-trip"
20944 );
20945 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20946 }
20947
20948 #[test]
20951 fn issue_510_trailing_double_space_paragraph_roundtrip() {
20952 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"}]}]}"#;
20955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20956 let md = adf_to_markdown(&doc).unwrap();
20957 let rt = markdown_to_adf(&md).unwrap();
20958
20959 assert_eq!(
20961 rt.content.len(),
20962 2,
20963 "should produce two paragraphs, got: {}",
20964 rt.content.len()
20965 );
20966 assert_eq!(rt.content[0].node_type, "paragraph");
20967 assert_eq!(rt.content[1].node_type, "paragraph");
20968
20969 let p1 = rt.content[0].content.as_ref().unwrap();
20971 assert_eq!(
20972 p1[0].text.as_deref(),
20973 Some("first paragraph with trailing spaces "),
20974 "trailing spaces should be preserved in first paragraph"
20975 );
20976
20977 let p2 = rt.content[1].content.as_ref().unwrap();
20979 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
20980
20981 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20983 assert!(
20984 !all_types.contains(&"hardBreak"),
20985 "trailing spaces should not produce hardBreak, got: {all_types:?}"
20986 );
20987 }
20988
20989 #[test]
20990 fn issue_510_trailing_triple_space_roundtrip() {
20991 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
20993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20994 let md = adf_to_markdown(&doc).unwrap();
20995 let rt = markdown_to_adf(&md).unwrap();
20996
20997 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
20998 let p1 = rt.content[0].content.as_ref().unwrap();
20999 assert_eq!(
21000 p1[0].text.as_deref(),
21001 Some("text "),
21002 "three trailing spaces should be preserved"
21003 );
21004 }
21005
21006 #[test]
21007 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21008 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21010 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21011 let md = adf_to_markdown(&doc).unwrap();
21012 let rt = markdown_to_adf(&md).unwrap();
21013 let p = rt.content[0].content.as_ref().unwrap();
21014 assert_eq!(
21015 p[0].text.as_deref(),
21016 Some("end\\ "),
21017 "backslash + trailing spaces should both survive"
21018 );
21019 }
21020
21021 #[test]
21022 fn issue_510_jfm_contains_escaped_trailing_space() {
21023 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21026 let md = adf_to_markdown(&doc).unwrap();
21027 assert!(
21028 md.contains(r"\ "),
21029 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21030 );
21031 for line in md.lines() {
21033 assert!(
21034 !line.ends_with(" "),
21035 "no JFM line should end with two plain spaces, got: {line:?}"
21036 );
21037 }
21038 }
21039
21040 #[test]
21041 fn issue_510_single_trailing_space_not_escaped() {
21042 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21044 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21045 let md = adf_to_markdown(&doc).unwrap();
21046 assert!(
21047 !md.contains('\\'),
21048 "single trailing space should not be escaped, got: {md:?}"
21049 );
21050 let rt = markdown_to_adf(&md).unwrap();
21051 let p = rt.content[0].content.as_ref().unwrap();
21052 assert_eq!(p[0].text.as_deref(), Some("word "));
21053 }
21054
21055 #[test]
21056 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21057 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21059 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21060 let md = adf_to_markdown(&doc).unwrap();
21061 let rt = markdown_to_adf(&md).unwrap();
21062 let h = rt.content[0].content.as_ref().unwrap();
21063 assert_eq!(
21064 h[0].text.as_deref(),
21065 Some("heading "),
21066 "trailing spaces in heading should be preserved"
21067 );
21068 }
21069
21070 #[test]
21071 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21072 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21074 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21075 let md = adf_to_markdown(&doc).unwrap();
21076 let rt = markdown_to_adf(&md).unwrap();
21077 let list = &rt.content[0];
21078 let item = &list.content.as_ref().unwrap()[0];
21079 let para = &item.content.as_ref().unwrap()[0];
21080 let inlines = para.content.as_ref().unwrap();
21081 assert_eq!(
21082 inlines[0].text.as_deref(),
21083 Some("item "),
21084 "trailing spaces in list item should be preserved"
21085 );
21086 }
21087
21088 #[test]
21089 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21090 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21094 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21095 let md = adf_to_markdown(&doc).unwrap();
21096 let rt = markdown_to_adf(&md).unwrap();
21097 let p = rt.content[0].content.as_ref().unwrap();
21098 assert_eq!(
21099 p[0].text.as_deref(),
21100 Some("bold "),
21101 "trailing spaces in bold text should be preserved"
21102 );
21103 }
21104
21105 #[test]
21106 fn issue_510_hardbreak_between_paragraphs_still_works() {
21107 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21110 let md = adf_to_markdown(&doc).unwrap();
21111 let rt = markdown_to_adf(&md).unwrap();
21112 let inlines = rt.content[0].content.as_ref().unwrap();
21113 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21114 assert_eq!(
21115 types,
21116 vec!["text", "hardBreak", "text"],
21117 "explicit hardBreak should still round-trip"
21118 );
21119 }
21120
21121 #[test]
21122 fn issue_510_all_spaces_text_node_roundtrip() {
21123 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21125 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21126 let md = adf_to_markdown(&doc).unwrap();
21127 let rt = markdown_to_adf(&md).unwrap();
21128 let p = rt.content[0].content.as_ref().unwrap();
21129 assert_eq!(
21130 p[0].text.as_deref(),
21131 Some(" "),
21132 "space-only text node should survive round-trip"
21133 );
21134 }
21135
21136 #[test]
21139 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21140 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"}]}]}]}]}"#;
21143 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21144 let md = adf_to_markdown(&doc).unwrap();
21145 let rt = markdown_to_adf(&md).unwrap();
21146
21147 let items = rt.content[0].content.as_ref().unwrap();
21148 assert_eq!(items.len(), 1);
21149 let children = items[0].content.as_ref().unwrap();
21150 assert_eq!(
21151 children.len(),
21152 3,
21153 "Expected 3 paragraphs in listItem, got {}",
21154 children.len()
21155 );
21156 assert_eq!(children[0].node_type, "paragraph");
21157 assert_eq!(children[1].node_type, "paragraph");
21158 assert_eq!(children[2].node_type, "paragraph");
21159
21160 let text1 = children[1].content.as_ref().unwrap()[0]
21162 .text
21163 .as_deref()
21164 .unwrap();
21165 assert_eq!(text1, "second paragraph");
21166 let text2 = children[2].content.as_ref().unwrap()[0]
21167 .text
21168 .as_deref()
21169 .unwrap();
21170 assert_eq!(text2, "third paragraph");
21171 }
21172
21173 #[test]
21174 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21175 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"}]}]}]}]}"#;
21177 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21178 let md = adf_to_markdown(&doc).unwrap();
21179 let rt = markdown_to_adf(&md).unwrap();
21180
21181 let items = rt.content[0].content.as_ref().unwrap();
21182 let children = items[0].content.as_ref().unwrap();
21183 assert_eq!(
21184 children.len(),
21185 3,
21186 "Expected 3 paragraphs in ordered listItem, got {}",
21187 children.len()
21188 );
21189 assert_eq!(children[1].node_type, "paragraph");
21190 assert_eq!(children[2].node_type, "paragraph");
21191 assert_eq!(
21192 children[1].content.as_ref().unwrap()[0]
21193 .text
21194 .as_deref()
21195 .unwrap(),
21196 "second para"
21197 );
21198 assert_eq!(
21199 children[2].content.as_ref().unwrap()[0]
21200 .text
21201 .as_deref()
21202 .unwrap(),
21203 "third para"
21204 );
21205 }
21206
21207 #[test]
21208 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21209 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"}]}]}]}]}"#;
21211 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21212 let md = adf_to_markdown(&doc).unwrap();
21213 let rt = markdown_to_adf(&md).unwrap();
21214
21215 let items = rt.content[0].content.as_ref().unwrap();
21216 let children = items[0].content.as_ref().unwrap();
21217 assert_eq!(
21218 children.len(),
21219 2,
21220 "Expected 2 paragraphs in listItem, got {}",
21221 children.len()
21222 );
21223 assert_eq!(children[0].node_type, "paragraph");
21224 assert_eq!(children[1].node_type, "paragraph");
21225 }
21226
21227 #[test]
21228 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21229 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"}]}]}]}]}]}]}"#;
21232 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21233 let md = adf_to_markdown(&doc).unwrap();
21234 assert!(
21236 !md.contains(" \n -"),
21237 "No blank separator between paragraph and nested list"
21238 );
21239 let rt = markdown_to_adf(&md).unwrap();
21240
21241 let items = rt.content[0].content.as_ref().unwrap();
21242 let children = items[0].content.as_ref().unwrap();
21243 assert_eq!(children.len(), 2);
21244 assert_eq!(children[0].node_type, "paragraph");
21245 assert_eq!(children[1].node_type, "bulletList");
21246 }
21247
21248 #[test]
21249 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21250 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"}]}]}]}]}"#;
21252 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21253 let md = adf_to_markdown(&doc).unwrap();
21254 let rt = markdown_to_adf(&md).unwrap();
21255
21256 let items = rt.content[0].content.as_ref().unwrap();
21257 let children = items[0].content.as_ref().unwrap();
21258 assert_eq!(
21259 children.len(),
21260 3,
21261 "Expected 3 paragraphs, got {}",
21262 children.len()
21263 );
21264 for (i, child) in children.iter().enumerate() {
21265 assert_eq!(
21266 child.node_type, "paragraph",
21267 "Child {} should be a paragraph",
21268 i
21269 );
21270 }
21271 }
21272
21273 #[test]
21274 fn issue_522_multiple_list_items_each_with_paragraphs() {
21275 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"}]}]}]}]}"#;
21277 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21278 let md = adf_to_markdown(&doc).unwrap();
21279 let rt = markdown_to_adf(&md).unwrap();
21280
21281 let items = rt.content[0].content.as_ref().unwrap();
21282 assert_eq!(items.len(), 2, "Expected 2 list items");
21283
21284 let item1 = items[0].content.as_ref().unwrap();
21285 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21286
21287 let item2 = items[1].content.as_ref().unwrap();
21288 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21289 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21291 let types: Vec<&str> = item2_p1_inlines
21292 .iter()
21293 .map(|n| n.node_type.as_str())
21294 .collect();
21295 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21296 }
21297
21298 #[test]
21299 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21300 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"}]}]}]}"#;
21304 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21305 let md = adf_to_markdown(&doc).unwrap();
21306 let rt = markdown_to_adf(&md).unwrap();
21307
21308 let children = rt.content[0].content.as_ref().unwrap();
21309 assert_eq!(
21310 children.len(),
21311 3,
21312 "Expected 3 paragraphs in blockquote, got {}",
21313 children.len()
21314 );
21315 assert_eq!(children[0].node_type, "paragraph");
21316 assert_eq!(children[1].node_type, "paragraph");
21317 assert_eq!(children[2].node_type, "paragraph");
21318
21319 let text1 = children[1].content.as_ref().unwrap()[0]
21320 .text
21321 .as_deref()
21322 .unwrap();
21323 assert_eq!(text1, "second paragraph");
21324 let text2 = children[2].content.as_ref().unwrap()[0]
21325 .text
21326 .as_deref()
21327 .unwrap();
21328 assert_eq!(text2, "third paragraph");
21329 }
21330
21331 #[test]
21332 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21333 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"}]}]}]}"#;
21335 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21336 let md = adf_to_markdown(&doc).unwrap();
21337 let rt = markdown_to_adf(&md).unwrap();
21338
21339 let children = rt.content[0].content.as_ref().unwrap();
21340 assert_eq!(
21341 children.len(),
21342 2,
21343 "Expected 2 paragraphs in blockquote, got {}",
21344 children.len()
21345 );
21346 assert_eq!(children[0].node_type, "paragraph");
21347 assert_eq!(children[1].node_type, "paragraph");
21348 }
21349
21350 #[test]
21351 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21352 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"}]}]}]}"#;
21354 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21355 let md = adf_to_markdown(&doc).unwrap();
21356 let rt = markdown_to_adf(&md).unwrap();
21357
21358 let children = rt.content[0].content.as_ref().unwrap();
21359 assert_eq!(
21360 children.len(),
21361 3,
21362 "Expected 3 paragraphs in blockquote, got {}",
21363 children.len()
21364 );
21365 for child in children {
21366 assert_eq!(child.node_type, "paragraph");
21367 }
21368 }
21369
21370 #[test]
21371 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21372 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"}]}]}]}]}]}"#;
21375 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21376 let md = adf_to_markdown(&doc).unwrap();
21377 let rt = markdown_to_adf(&md).unwrap();
21378
21379 let children = rt.content[0].content.as_ref().unwrap();
21380 assert_eq!(children[0].node_type, "paragraph");
21381 assert_eq!(children[1].node_type, "bulletList");
21382 }
21383
21384 #[test]
21385 fn issue_531_blockquote_single_paragraph_unchanged() {
21386 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21388 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21389 let md = adf_to_markdown(&doc).unwrap();
21390 let rt = markdown_to_adf(&md).unwrap();
21391
21392 let children = rt.content[0].content.as_ref().unwrap();
21393 assert_eq!(children.len(), 1);
21394 assert_eq!(children[0].node_type, "paragraph");
21395 let text = children[0].content.as_ref().unwrap()[0]
21396 .text
21397 .as_deref()
21398 .unwrap();
21399 assert_eq!(text, "solo");
21400 }
21401
21402 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21407 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21408 let md = adf_to_markdown(&doc).unwrap();
21409 let rt = markdown_to_adf(&md).unwrap();
21410 let node = &rt.content[0].content.as_ref().unwrap()[0];
21411 let mark_types: Vec<&str> = node
21412 .marks
21413 .as_ref()
21414 .expect("should have marks")
21415 .iter()
21416 .map(|m| m.mark_type.as_str())
21417 .collect();
21418 assert_eq!(
21419 mark_types, expected_marks,
21420 "mark order mismatch for md={md}"
21421 );
21422 }
21423
21424 #[test]
21425 fn issue_554_code_and_text_color_preserved() {
21426 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21427 {"type":"text","text":"x","marks":[
21428 {"type":"textColor","attrs":{"color":"#008000"}},
21429 {"type":"code"}
21430 ]}
21431 ]}]}"##;
21432 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21433 }
21434
21435 #[test]
21436 fn issue_554_code_and_bg_color_preserved() {
21437 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21438 {"type":"text","text":"x","marks":[
21439 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21440 {"type":"code"}
21441 ]}
21442 ]}]}"##;
21443 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21444 }
21445
21446 #[test]
21447 fn issue_554_code_and_subsup_preserved() {
21448 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21449 {"type":"text","text":"x","marks":[
21450 {"type":"subsup","attrs":{"type":"sub"}},
21451 {"type":"code"}
21452 ]}
21453 ]}]}"#;
21454 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21455 }
21456
21457 #[test]
21458 fn issue_554_code_and_underline_preserved() {
21459 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21460 {"type":"text","text":"x","marks":[
21461 {"type":"underline"},
21462 {"type":"code"}
21463 ]}
21464 ]}]}"#;
21465 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21466 }
21467
21468 #[test]
21469 fn issue_554_code_textcolor_and_underline_preserved() {
21470 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21471 {"type":"text","text":"x","marks":[
21472 {"type":"textColor","attrs":{"color":"#008000"}},
21473 {"type":"underline"},
21474 {"type":"code"}
21475 ]}
21476 ]}]}"##;
21477 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21478 }
21479
21480 #[test]
21481 fn issue_554_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 ]}
21487 ]}]}"##;
21488 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21489 }
21490
21491 #[test]
21492 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21493 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21494 {"type":"text","text":"x","marks":[
21495 {"type":"underline"},
21496 {"type":"textColor","attrs":{"color":"#008000"}}
21497 ]}
21498 ]}]}"##;
21499 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21501 }
21502
21503 #[test]
21504 fn issue_554_textcolor_and_annotation_preserved() {
21505 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21506 {"type":"text","text":"x","marks":[
21507 {"type":"textColor","attrs":{"color":"#008000"}},
21508 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21509 ]}
21510 ]}]}"##;
21511 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21512 }
21513
21514 #[test]
21515 fn issue_554_bgcolor_and_underline_preserved() {
21516 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21517 {"type":"text","text":"x","marks":[
21518 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21519 {"type":"underline"}
21520 ]}
21521 ]}]}"##;
21522 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21523 }
21524
21525 #[test]
21526 fn issue_554_subsup_and_underline_preserved() {
21527 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21528 {"type":"text","text":"x","marks":[
21529 {"type":"subsup","attrs":{"type":"sub"}},
21530 {"type":"underline"}
21531 ]}
21532 ]}]}"#;
21533 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21534 }
21535
21536 #[test]
21537 fn issue_554_exact_reproducer_full_match() {
21538 let adf_json = r##"{
21541 "version": 1,
21542 "type": "doc",
21543 "content": [
21544 {
21545 "type": "paragraph",
21546 "content": [
21547 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21548 {"type":"text","text":"Approved","marks":[
21549 {"type":"textColor","attrs":{"color":"#008000"}}
21550 ]},
21551 {"type":"text","text":" — ready to proceed"}
21552 ]
21553 }
21554 ]
21555 }"##;
21556 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21557 let md = adf_to_markdown(&doc).unwrap();
21558 assert!(
21559 md.contains(":span[Approved]{color=#008000}"),
21560 "JFM should contain green span: {md}"
21561 );
21562 let rt = markdown_to_adf(&md).unwrap();
21563 let approved = rt.content[0]
21565 .content
21566 .as_ref()
21567 .unwrap()
21568 .iter()
21569 .find(|n| n.text.as_deref() == Some("Approved"))
21570 .expect("Approved text node");
21571 let marks = approved.marks.as_ref().expect("should have marks");
21572 let color_mark = marks
21573 .iter()
21574 .find(|m| m.mark_type == "textColor")
21575 .expect("textColor mark must be preserved");
21576 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21577 }
21578
21579 #[test]
21580 fn issue_554_textcolor_with_code_renders_span_around_code() {
21581 let doc = AdfDocument {
21584 version: 1,
21585 doc_type: "doc".to_string(),
21586 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21587 "fn main",
21588 vec![
21589 AdfMark::text_color("#008000"),
21590 AdfMark {
21591 mark_type: "code".to_string(),
21592 attrs: None,
21593 },
21594 ],
21595 )])],
21596 };
21597 let md = adf_to_markdown(&doc).unwrap();
21598 assert!(
21599 md.contains(":span[`fn main`]{color=#008000}"),
21600 "expected span-wrapped code, got: {md}"
21601 );
21602 }
21603
21604 #[test]
21605 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21606 let doc = AdfDocument {
21607 version: 1,
21608 doc_type: "doc".to_string(),
21609 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21610 "fn main",
21611 vec![
21612 AdfMark::underline(),
21613 AdfMark {
21614 mark_type: "code".to_string(),
21615 attrs: None,
21616 },
21617 ],
21618 )])],
21619 };
21620 let md = adf_to_markdown(&doc).unwrap();
21621 assert!(
21622 md.contains("[`fn main`]{underline}"),
21623 "expected bracketed-span around code, got: {md}"
21624 );
21625 }
21626
21627 #[test]
21630 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21631 let adf_json = r##"{
21636 "version": 1,
21637 "type": "doc",
21638 "content": [
21639 {
21640 "type": "paragraph",
21641 "content": [
21642 {"type":"text","text":"_ "},
21643 {"type":"text","text":"_Action:*","marks":[
21644 {"type":"textColor","attrs":{"color":"#008000"}}
21645 ]},
21646 {"type":"text","text":" Complete the setup process."}
21647 ]
21648 }
21649 ]
21650 }"##;
21651 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21652 let md = adf_to_markdown(&doc).unwrap();
21653 assert!(
21656 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21657 "underscores at node boundaries should be escaped: {md}"
21658 );
21659 let rt = markdown_to_adf(&md).unwrap();
21660 let para_content = rt.content[0].content.as_ref().unwrap();
21661 let colored = para_content
21663 .iter()
21664 .find(|n| {
21665 n.marks
21666 .as_deref()
21667 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21668 })
21669 .expect("textColor node must be preserved");
21670 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21671 let color_mark = colored
21672 .marks
21673 .as_ref()
21674 .unwrap()
21675 .iter()
21676 .find(|m| m.mark_type == "textColor")
21677 .unwrap();
21678 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21679 for n in para_content {
21681 if let Some(ms) = n.marks.as_deref() {
21682 assert!(
21683 !ms.iter().any(|m| m.mark_type == "em"),
21684 "no em mark should appear, got marks {:?}",
21685 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21686 );
21687 }
21688 }
21689 }
21690
21691 #[test]
21692 fn issue_554_underscore_intraword_left_unescaped() {
21693 let doc = AdfDocument {
21697 version: 1,
21698 doc_type: "doc".to_string(),
21699 content: vec![AdfNode::paragraph(vec![AdfNode::text(
21700 "call do_something_useful now",
21701 )])],
21702 };
21703 let md = adf_to_markdown(&doc).unwrap();
21704 assert!(
21705 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21706 "intraword underscores should not be escaped: {md}"
21707 );
21708 }
21709
21710 #[test]
21711 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21712 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21715 {"type":"text","text":"x","marks":[
21716 {"type":"underline"},
21717 {"type":"textColor","attrs":{"color":"#008000"}},
21718 {"type":"code"}
21719 ]}
21720 ]}]}"##;
21721 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21722 let md = adf_to_markdown(&doc).unwrap();
21723 assert!(
21725 md.starts_with('[') && md.contains("underline}"),
21726 "bracketed-span should wrap the span, got: {md}"
21727 );
21728 let rt = markdown_to_adf(&md).unwrap();
21729 let node = &rt.content[0].content.as_ref().unwrap()[0];
21730 let mark_types: Vec<&str> = node
21731 .marks
21732 .as_ref()
21733 .unwrap()
21734 .iter()
21735 .map(|m| m.mark_type.as_str())
21736 .collect();
21737 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21738 }
21739
21740 #[test]
21741 fn issue_554_textcolor_underline_link_all_preserved() {
21742 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21746 {"type":"text","text":"linked","marks":[
21747 {"type":"textColor","attrs":{"color":"#008000"}},
21748 {"type":"underline"},
21749 {"type":"link","attrs":{"href":"https://example.com"}}
21750 ]}
21751 ]}]}"##;
21752 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21753 let md = adf_to_markdown(&doc).unwrap();
21754 let rt = markdown_to_adf(&md).unwrap();
21755 let node = &rt.content[0].content.as_ref().unwrap()[0];
21756 let mark_types: Vec<&str> = node
21757 .marks
21758 .as_ref()
21759 .unwrap()
21760 .iter()
21761 .map(|m| m.mark_type.as_str())
21762 .collect();
21763 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21764 }
21765
21766 #[test]
21767 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21768 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21771 {"type":"text","text":"linked","marks":[
21772 {"type":"underline"},
21773 {"type":"textColor","attrs":{"color":"#008000"}},
21774 {"type":"link","attrs":{"href":"https://example.com"}}
21775 ]}
21776 ]}]}"##;
21777 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21778 let md = adf_to_markdown(&doc).unwrap();
21779 let rt = markdown_to_adf(&md).unwrap();
21780 let node = &rt.content[0].content.as_ref().unwrap()[0];
21781 let mark_types: Vec<&str> = node
21782 .marks
21783 .as_ref()
21784 .unwrap()
21785 .iter()
21786 .map(|m| m.mark_type.as_str())
21787 .collect();
21788 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21789 }
21790
21791 #[test]
21792 fn issue_554_link_underline_textcolor_link_outer() {
21793 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21797 {"type":"text","text":"linked","marks":[
21798 {"type":"link","attrs":{"href":"https://example.com"}},
21799 {"type":"underline"},
21800 {"type":"textColor","attrs":{"color":"#008000"}}
21801 ]}
21802 ]}]}"##;
21803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21804 let md = adf_to_markdown(&doc).unwrap();
21805 assert!(
21806 md.starts_with('[') && md.contains("](https://example.com)"),
21807 "link should be outermost, got: {md}"
21808 );
21809 let rt = markdown_to_adf(&md).unwrap();
21810 let node = &rt.content[0].content.as_ref().unwrap()[0];
21811 let mark_types: Vec<&str> = node
21812 .marks
21813 .as_ref()
21814 .unwrap()
21815 .iter()
21816 .map(|m| m.mark_type.as_str())
21817 .collect();
21818 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21819 }
21820
21821 #[test]
21822 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21823 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21827 {"type":"text","text":"end_"},
21828 {"type":"text","text":"_start"}
21829 ]}]}"#;
21830 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21831 let md = adf_to_markdown(&doc).unwrap();
21832 let rt = markdown_to_adf(&md).unwrap();
21833 let combined: String = rt.content[0]
21835 .content
21836 .as_ref()
21837 .unwrap()
21838 .iter()
21839 .filter_map(|n| n.text.as_deref())
21840 .collect();
21841 assert_eq!(combined, "end__start");
21842 for n in rt.content[0].content.as_ref().unwrap() {
21844 if let Some(ms) = n.marks.as_deref() {
21845 assert!(!ms.iter().any(|m| m.mark_type == "em"));
21846 }
21847 }
21848 }
21849}